@facilis.dev/auth 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/dist/api-clients.d.ts +71 -0
  4. package/dist/api-clients.js +186 -0
  5. package/dist/crypto.d.ts +31 -0
  6. package/dist/crypto.js +76 -0
  7. package/dist/email-verification.d.ts +45 -0
  8. package/dist/email-verification.js +72 -0
  9. package/dist/email.d.ts +7 -0
  10. package/dist/email.js +9 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.js +12 -0
  13. package/dist/password.d.ts +7 -0
  14. package/dist/password.js +54 -0
  15. package/dist/roles.d.ts +40 -0
  16. package/dist/roles.js +46 -0
  17. package/dist/seed.d.ts +28 -0
  18. package/dist/seed.js +115 -0
  19. package/dist/session.d.ts +66 -0
  20. package/dist/session.js +161 -0
  21. package/dist/sso/contracts.d.ts +52 -0
  22. package/dist/sso/contracts.js +8 -0
  23. package/dist/sso/errors.d.ts +22 -0
  24. package/dist/sso/errors.js +45 -0
  25. package/dist/sso/identity.d.ts +41 -0
  26. package/dist/sso/identity.js +162 -0
  27. package/dist/sso/index.d.ts +27 -0
  28. package/dist/sso/index.js +16 -0
  29. package/dist/sso/login.d.ts +60 -0
  30. package/dist/sso/login.js +209 -0
  31. package/dist/sso/oauth2.d.ts +52 -0
  32. package/dist/sso/oauth2.js +180 -0
  33. package/dist/sso/oidc.d.ts +60 -0
  34. package/dist/sso/oidc.js +201 -0
  35. package/dist/sso/paths.d.ts +14 -0
  36. package/dist/sso/paths.js +17 -0
  37. package/dist/sso/pkce.d.ts +6 -0
  38. package/dist/sso/pkce.js +21 -0
  39. package/dist/sso/providers.d.ts +100 -0
  40. package/dist/sso/providers.js +70 -0
  41. package/dist/sso/saml.d.ts +44 -0
  42. package/dist/sso/saml.js +159 -0
  43. package/dist/sso/state.d.ts +34 -0
  44. package/dist/sso/state.js +91 -0
  45. package/dist/sso/tables.d.ts +306 -0
  46. package/dist/sso/tables.js +59 -0
  47. package/dist/tables.d.ts +769 -0
  48. package/dist/tables.js +148 -0
  49. package/package.json +45 -4
  50. package/src/api-clients.ts +261 -0
  51. package/src/crypto.ts +101 -0
  52. package/src/email-verification.ts +107 -0
  53. package/src/email.ts +9 -0
  54. package/src/index.ts +63 -0
  55. package/src/password.ts +74 -0
  56. package/src/roles.ts +77 -0
  57. package/src/seed.ts +146 -0
  58. package/src/session.ts +209 -0
  59. package/src/sso/contracts.ts +71 -0
  60. package/src/sso/errors.ts +53 -0
  61. package/src/sso/identity.ts +220 -0
  62. package/src/sso/index.ts +105 -0
  63. package/src/sso/login.ts +296 -0
  64. package/src/sso/oauth2.ts +235 -0
  65. package/src/sso/oidc.ts +285 -0
  66. package/src/sso/paths.ts +21 -0
  67. package/src/sso/pkce.ts +30 -0
  68. package/src/sso/providers.ts +176 -0
  69. package/src/sso/saml.ts +222 -0
  70. package/src/sso/state.ts +114 -0
  71. package/src/sso/tables.ts +77 -0
  72. package/src/tables.ts +214 -0
  73. package/src/test-support.ts +149 -0
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @facilis.dev/auth — auth tables, RBAC expansion, password hashing, sessions and seeding.
3
+ */
4
+
5
+ export {
6
+ apiClients,
7
+ apiTokens,
8
+ authEmailVerifications,
9
+ rolePermissions,
10
+ roles,
11
+ sessions,
12
+ userRoles,
13
+ users,
14
+ type ApiClientRow,
15
+ type ApiTokenRow,
16
+ type AuthUser,
17
+ type EmailVerificationRow,
18
+ type NewEmailVerificationRow,
19
+ type NewUserRow,
20
+ type RolePermissionRow,
21
+ type RoleRow,
22
+ type SessionRow,
23
+ type UserRoleRow,
24
+ type UserRow,
25
+ } from "./tables.ts";
26
+ export {
27
+ createApiClient,
28
+ issueApiToken,
29
+ listApiClients,
30
+ revokeApiClient,
31
+ validateApiToken,
32
+ type ApiClientSummary,
33
+ type CreateApiClientInput,
34
+ type CreatedApiClient,
35
+ type IssuedApiToken,
36
+ type IssueApiTokenInput,
37
+ type ValidatedApiToken,
38
+ } from "./api-clients.ts";
39
+ export { normalizeEmail } from "./email.ts";
40
+ export { expandRoles, hasPermission, type AuthConfig, type AuthContext } from "./roles.ts";
41
+ export {
42
+ consumeEmailVerification,
43
+ EMAIL_VERIFICATION_TABLES,
44
+ issueEmailVerification,
45
+ markEmailVerified,
46
+ type IssuedEmailVerification,
47
+ type IssueEmailVerificationOptions,
48
+ } from "./email-verification.ts";
49
+ export { hashPassword, verifyPassword } from "./password.ts";
50
+ export {
51
+ createSession,
52
+ destroySession,
53
+ SESSION_COOKIE_NAME,
54
+ sessionMiddleware,
55
+ validateSession,
56
+ type CreateSessionOptions,
57
+ type SessionEvent,
58
+ type SessionEventContext,
59
+ type SessionMiddlewareDeps,
60
+ type ValidatedSession,
61
+ } from "./session.ts";
62
+ export { registerInitialAdmin, seedFromConfig, seedStatements, type SeedEntry } from "./seed.ts";
63
+ export * from "./sso/index.ts";
@@ -0,0 +1,74 @@
1
+ import { constantTimeEqual, decodeBase64, encodeBase64, randomBytes, webcrypto } from "./crypto.ts";
2
+
3
+ const ALGORITHM = "pbkdf2-sha256";
4
+ const DEFAULT_ITERATIONS = 600_000;
5
+ const MAX_ITERATIONS = 1_000_000;
6
+ const SALT_BYTES = 16;
7
+ const KEY_BITS = 256;
8
+ const KEY_BYTES = KEY_BITS / 8;
9
+
10
+ /**
11
+ * Hashes a password with PBKDF2-SHA256 (WebCrypto). Encoding is versioned and
12
+ * self-describing: `pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>`.
13
+ */
14
+ export async function hashPassword(password: string): Promise<string> {
15
+ const salt = randomBytes(SALT_BYTES);
16
+ const hash = await deriveKey(password, salt, DEFAULT_ITERATIONS);
17
+ return [ALGORITHM, String(DEFAULT_ITERATIONS), encodeBase64(salt), encodeBase64(hash)].join("$");
18
+ }
19
+
20
+ /** Constant-time verification; malformed or unsupported hashes return false, never throw. */
21
+ export async function verifyPassword(hash: string, password: string): Promise<boolean> {
22
+ const parsed = parsePasswordHash(hash);
23
+ if (parsed === null) return false;
24
+ const candidate = await deriveKey(password, parsed.salt, parsed.iterations);
25
+ return constantTimeEqual(candidate, parsed.hash);
26
+ }
27
+
28
+ interface ParsedPasswordHash {
29
+ iterations: number;
30
+ salt: Uint8Array;
31
+ hash: Uint8Array;
32
+ }
33
+
34
+ function parsePasswordHash(value: string): ParsedPasswordHash | null {
35
+ const parts = value.split("$");
36
+ if (parts.length !== 4) return null;
37
+ const [algorithm, iterationsText, saltText, hashText] = parts;
38
+ if (
39
+ algorithm !== ALGORITHM ||
40
+ iterationsText === undefined ||
41
+ saltText === undefined ||
42
+ hashText === undefined ||
43
+ !/^[1-9][0-9]*$/.test(iterationsText)
44
+ ) {
45
+ return null;
46
+ }
47
+
48
+ const iterations = Number(iterationsText);
49
+ if (iterations > MAX_ITERATIONS) return null;
50
+
51
+ const salt = decodeBase64(saltText);
52
+ const hash = decodeBase64(hashText);
53
+ if (salt === null || hash === null || salt.length !== SALT_BYTES || hash.length !== KEY_BYTES) {
54
+ return null;
55
+ }
56
+ return { iterations, salt, hash };
57
+ }
58
+
59
+ async function deriveKey(
60
+ password: string,
61
+ salt: Uint8Array,
62
+ iterations: number,
63
+ ): Promise<Uint8Array> {
64
+ const { subtle } = webcrypto();
65
+ const key = await subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [
66
+ "deriveBits",
67
+ ]);
68
+ const bits = await subtle.deriveBits(
69
+ { name: "PBKDF2", hash: "SHA-256", salt, iterations },
70
+ key,
71
+ KEY_BITS,
72
+ );
73
+ return new Uint8Array(bits);
74
+ }
package/src/roles.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Auth context attached to requests once a session is validated (contract §4.1/§6).
3
+ *
4
+ * It deliberately carries no tenant (D-040): the tenant is the deployment's, resolved from
5
+ * FACILIS_TENANT_ID for every request, so a session can neither widen nor change the RLS scope.
6
+ */
7
+ export interface AuthContext {
8
+ readonly userId: string;
9
+ readonly roles: readonly string[];
10
+ readonly permissions: readonly string[];
11
+ /**
12
+ * SHA-256 (hex) of the session id (D-099): the sudo audit log correlates a request with
13
+ * the session it ran under without ever storing the value of the cookie.
14
+ */
15
+ readonly sessionHash?: string;
16
+ /**
17
+ * Which credential produced this context (V3a): a browser session or a programmatic API
18
+ * bearer token. Audit/logging code reads it to separate machine traffic from human sessions.
19
+ */
20
+ readonly via?: "session" | "bearer";
21
+ }
22
+
23
+ /** Shape of `facilis.config.ts`'s `auth` block that @facilis.dev/auth needs. */
24
+ export interface AuthConfig {
25
+ readonly permissions: readonly string[];
26
+ readonly roles: Readonly<Record<string, readonly string[]>>;
27
+ readonly defaultRole: string;
28
+ readonly sessionDurationMs: number;
29
+ }
30
+
31
+ const WILDCARD = "*";
32
+
33
+ /**
34
+ * Expands app roles into concrete permissions, deterministically: roles keep their input
35
+ * order, permissions keep config order, duplicates collapse to the first occurrence.
36
+ * Unknown roles (e.g. a stale `user_roles` row) contribute nothing.
37
+ */
38
+ export function expandRoles(roles: readonly string[], config: AuthConfig): string[] {
39
+ const expanded: string[] = [];
40
+ const seen = new Set<string>();
41
+
42
+ const add = (permission: string): void => {
43
+ if (seen.has(permission)) return;
44
+ seen.add(permission);
45
+ expanded.push(permission);
46
+ };
47
+
48
+ for (const role of roles) {
49
+ const declared = config.roles[role];
50
+ if (declared === undefined) continue;
51
+ for (const permission of declared) {
52
+ if (permission === WILDCARD) {
53
+ for (const granted of config.permissions) add(granted);
54
+ } else {
55
+ add(permission);
56
+ }
57
+ }
58
+ }
59
+
60
+ return expanded;
61
+ }
62
+
63
+ /**
64
+ * Route RBAC check (contract §4.4): `[]` requires nothing (public route), a non-empty
65
+ * list requires at least one match, and null means no session. A literal "*" permission
66
+ * on the subject grants everything as a safety net for hand-built contexts.
67
+ */
68
+ export function hasPermission(
69
+ subject: Pick<AuthContext, "permissions"> | null,
70
+ permission: string | readonly string[],
71
+ ): boolean {
72
+ const required = typeof permission === "string" ? [permission] : permission;
73
+ if (required.length === 0) return true;
74
+ if (subject === null) return false;
75
+ if (subject.permissions.includes(WILDCARD)) return true;
76
+ return required.some((candidate) => subject.permissions.includes(candidate));
77
+ }
package/src/seed.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { eq, sql } from "drizzle-orm";
2
+
3
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
4
+
5
+ import { constantTimeEqual, sha256Hex } from "./crypto.ts";
6
+ import { normalizeEmail } from "./email.ts";
7
+ import type { AuthConfig } from "./roles.ts";
8
+ import { userRoles, users } from "./tables.ts";
9
+
10
+ const INITIAL_ADMIN_ROLE = "admin";
11
+
12
+ export interface SeedEntry {
13
+ readonly role: string;
14
+ readonly permission: string;
15
+ }
16
+
17
+ /**
18
+ * Pure projection of `config.auth.roles` into `role_permissions` rows (SPEC §5.3/§8.4.2).
19
+ * Wildcards stay literal ("*") exactly as the reference seed shows; the table is written
20
+ * only at migration time, never at runtime.
21
+ */
22
+ export function seedFromConfig(config: AuthConfig): SeedEntry[] {
23
+ const entries: SeedEntry[] = [];
24
+ const seen = new Set<string>();
25
+
26
+ for (const [role, permissions] of Object.entries(config.roles)) {
27
+ for (const permission of permissions) {
28
+ const key = `${role}\u0000${permission}`;
29
+ if (seen.has(key)) continue;
30
+ seen.add(key);
31
+ entries.push({ role, permission });
32
+ }
33
+ }
34
+
35
+ return entries;
36
+ }
37
+
38
+ /**
39
+ * Deterministic, idempotent SQL for the migration/seed step: roles first, then
40
+ * role_permissions, every statement guarded by ON CONFLICT DO NOTHING.
41
+ */
42
+ export function seedStatements(config: AuthConfig): string[] {
43
+ const statements: string[] = [];
44
+
45
+ for (const role of Object.keys(config.roles)) {
46
+ statements.push(
47
+ `INSERT INTO roles (name) VALUES (${sqlLiteral(role)}) ON CONFLICT DO NOTHING;`,
48
+ );
49
+ }
50
+ for (const { role, permission } of seedFromConfig(config)) {
51
+ statements.push(
52
+ `INSERT INTO role_permissions (role, permission) VALUES (${sqlLiteral(role)}, ${sqlLiteral(permission)}) ON CONFLICT DO NOTHING;`,
53
+ );
54
+ }
55
+
56
+ return statements;
57
+ }
58
+
59
+ /**
60
+ * Grants `admin` to the user whose email matches FACILIS_INITIAL_ADMIN (SPEC §8.4.3).
61
+ *
62
+ * The email alone is not a credential: it is public and the registration route is public, so
63
+ * the promotion ALSO requires the operator's one-time FACILIS_INITIAL_ADMIN_TOKEN (D-150).
64
+ * Both variables must be set; a missing token fails closed, the token is compared in constant
65
+ * time, and the promotion happens only while no admin exists yet, so a deploy that keeps the
66
+ * token configured after bootstrap cannot be re-triggered. Accepts a user id or email; returns
67
+ * false when any condition fails.
68
+ */
69
+ export async function registerInitialAdmin(
70
+ db: PolicyScopedDatabase,
71
+ userIdOrEmail: string,
72
+ bootstrapToken?: string,
73
+ ): Promise<boolean> {
74
+ const expected = readInitialAdminEmail();
75
+ if (expected === null) return false;
76
+ const expectedToken = readInitialAdminToken();
77
+ if (expectedToken === null || bootstrapToken === undefined) return false;
78
+ if (!(await tokenMatches(bootstrapToken, expectedToken))) return false;
79
+ // One-time: an admin already assigned means bootstrap is over, even if the env stays set.
80
+ if (await hasAdmin(db)) return false;
81
+
82
+ const user = await findUser(db, userIdOrEmail.trim());
83
+ if (user === null) return false;
84
+ // Emails compare in canonical form: a case variant of the configured address is not the
85
+ // configured address (the unique lower(email) index makes the two the same account, D-150).
86
+ if (normalizeEmail(user.email) !== expected) return false;
87
+
88
+ await db
89
+ .insert(userRoles)
90
+ .values({ userId: user.id, role: INITIAL_ADMIN_ROLE })
91
+ .onConflictDoNothing();
92
+ return true;
93
+ }
94
+
95
+ function readInitialAdminEmail(): string | null {
96
+ const configured = process.env.FACILIS_INITIAL_ADMIN?.trim();
97
+ return configured === undefined || configured.length === 0 ? null : normalizeEmail(configured);
98
+ }
99
+
100
+ function readInitialAdminToken(): string | null {
101
+ const configured = process.env.FACILIS_INITIAL_ADMIN_TOKEN?.trim();
102
+ return configured === undefined || configured.length === 0 ? null : configured;
103
+ }
104
+
105
+ /**
106
+ * Hash-then-compare keeps the comparison independent of the two input lengths, so a wrong
107
+ * token leaks neither its length nor the index of the first wrong character.
108
+ */
109
+ async function tokenMatches(provided: string, expected: string): Promise<boolean> {
110
+ const [left, right] = await Promise.all([sha256Hex(provided), sha256Hex(expected)]);
111
+ return constantTimeEqual(new TextEncoder().encode(left), new TextEncoder().encode(right));
112
+ }
113
+
114
+ async function hasAdmin(db: PolicyScopedDatabase): Promise<boolean> {
115
+ const rows = await db
116
+ .select({ role: userRoles.role })
117
+ .from(userRoles)
118
+ .where(eq(userRoles.role, INITIAL_ADMIN_ROLE))
119
+ .limit(1);
120
+ return rows.length > 0;
121
+ }
122
+
123
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
124
+
125
+ async function findUser(
126
+ db: PolicyScopedDatabase,
127
+ userIdOrEmail: string,
128
+ ): Promise<{ id: string; email: string } | null> {
129
+ const rows = UUID_PATTERN.test(userIdOrEmail)
130
+ ? await db
131
+ .select({ id: users.id, email: users.email })
132
+ .from(users)
133
+ .where(eq(users.id, userIdOrEmail))
134
+ .limit(1)
135
+ : await db
136
+ .select({ id: users.id, email: users.email })
137
+ .from(users)
138
+ .where(sql`lower(${users.email}) = ${normalizeEmail(userIdOrEmail)}`)
139
+ .limit(1);
140
+
141
+ return rows[0] ?? null;
142
+ }
143
+
144
+ function sqlLiteral(value: string): string {
145
+ return `'${value.replaceAll("'", "''")}'`;
146
+ }
package/src/session.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { eq } from "drizzle-orm";
2
+
3
+ import { runWithPolicyScope, type FacilisDb, type PolicyScopedDatabase } from "@facilis.dev/db";
4
+
5
+ import { validateApiToken } from "./api-clients.ts";
6
+ import { randomToken, sha256Hex } from "./crypto.ts";
7
+ import { expandRoles, type AuthConfig } from "./roles.ts";
8
+ import { sessions, userRoles, users, type AuthUser, type SessionRow } from "./tables.ts";
9
+
10
+ /** Cookie name fixed by contract §4.4; HttpOnly/Path=/SameSite=Lax/Secure-in-prod. The cookie
11
+ * carries the raw 32-byte token; the sessions row stores only its SHA-256 (D-144). */
12
+ export const SESSION_COOKIE_NAME = "facilis_session";
13
+
14
+ const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7;
15
+
16
+ export interface CreateSessionOptions {
17
+ readonly ttlMs?: number;
18
+ }
19
+
20
+ export interface ValidatedSession {
21
+ readonly user: AuthUser;
22
+ readonly session: SessionRow;
23
+ }
24
+
25
+ /** Creates an opaque session row; callers set the cookie themselves (route blueprint). */
26
+ export async function createSession(
27
+ db: PolicyScopedDatabase,
28
+ userId: string,
29
+ options: CreateSessionOptions = {},
30
+ ): Promise<{ id: string; expiresAt: Date }> {
31
+ // A service account exists only for machine credentials: minting a cookie session for one
32
+ // would make it loggable in a browser, so refuse here even if a caller missed the flag.
33
+ const serviceAccount = await db
34
+ .select({ isService: users.isService })
35
+ .from(users)
36
+ .where(eq(users.id, userId))
37
+ .limit(1);
38
+ if (serviceAccount[0]?.isService === true) {
39
+ throw new Error("createSession: service accounts cannot log in");
40
+ }
41
+
42
+ const id = randomToken(32);
43
+ const expiresAt = new Date(Date.now() + (options.ttlMs ?? DEFAULT_SESSION_TTL_MS));
44
+ // The row key is the SHA-256 of the cookie value: the raw id is the credential and never
45
+ // reaches the database, so a SELECT on sessions (RLS-scoped, tenant-visible) cannot be
46
+ // replayed as a session. The cookie keeps the raw value; lookups hash it again.
47
+ await db.insert(sessions).values({ id: await sha256Hex(id), userId, expiresAt });
48
+ return { id, expiresAt };
49
+ }
50
+
51
+ /**
52
+ * Joins the session to its user; expired or unknown sessions yield null.
53
+ *
54
+ * Callers pass a policy-scoped client (`runWithPolicyScope`) with app.tenant_id set: users
55
+ * carries tenant RLS, so an unscoped read as the non-superuser facilis_app role cannot see
56
+ * the joined user row at all.
57
+ */
58
+ export async function validateSession(
59
+ db: PolicyScopedDatabase,
60
+ sessionId: string,
61
+ ): Promise<ValidatedSession | null> {
62
+ // Rows hold sha256(cookie id): the caller passes the raw cookie and the lookup hashes it.
63
+ const rows = await db
64
+ .select({
65
+ session: sessions,
66
+ user: {
67
+ id: users.id,
68
+ email: users.email,
69
+ tenantId: users.tenantId,
70
+ createdAt: users.createdAt,
71
+ },
72
+ isService: users.isService,
73
+ })
74
+ .from(sessions)
75
+ .innerJoin(users, eq(sessions.userId, users.id))
76
+ .where(eq(sessions.id, await sha256Hex(sessionId)))
77
+ .limit(1);
78
+
79
+ const found = rows[0];
80
+ if (found === undefined) return null;
81
+ // Defense in depth: a service account never validates a cookie session, even one inserted
82
+ // before createSession started refusing them.
83
+ if (found.isService) return null;
84
+ if (found.session.expiresAt.getTime() <= Date.now()) return null;
85
+ return { user: found.user, session: found.session };
86
+ }
87
+
88
+ /** Deletes a session row; deleting an unknown id is a no-op. */
89
+ export async function destroySession(db: PolicyScopedDatabase, sessionId: string): Promise<void> {
90
+ // Deleting an unknown id stays a no-op; the stored key is the hash of the cookie value.
91
+ await db.delete(sessions).where(eq(sessions.id, await sha256Hex(sessionId)));
92
+ }
93
+
94
+ /**
95
+ * Structural event type: an H3/Nitro event satisfies it without importing h3 here.
96
+ * `context` stays a plain record because h3's H3EventContext exposes unknown values
97
+ * through an index signature; middleware writes `context.auth` (AuthContext | null).
98
+ */
99
+ export type SessionEventContext = Record<string, unknown>;
100
+
101
+ export interface SessionEvent {
102
+ context: SessionEventContext;
103
+ req: { headers: { get(name: string): string | null } };
104
+ }
105
+
106
+ export interface SessionMiddlewareDeps {
107
+ /** Database the middleware scopes; the runtime may connect as the facilis_app role. */
108
+ readonly db: FacilisDb;
109
+ readonly config: AuthConfig;
110
+ /** The deployment's tenant; a session whose user row names another one is rejected (D-040). */
111
+ readonly tenantId: string;
112
+ }
113
+
114
+ /**
115
+ * Builds the `facilis-session` middleware handler. An `Authorization: Bearer` token wins
116
+ * over the session cookie; otherwise it reads the cookie, then validates the session and
117
+ * loads app roles inside ONE deployment-tenant policy scope (`runWithPolicyScope`), expands
118
+ * the roles from config (SPEC §8.4.4) and sets `context.auth` with the auth channel in `via`.
119
+ *
120
+ * The scope matters: production connects as the non-superuser facilis_app role, so `users`
121
+ * is RLS-filtered and an unscoped read cannot see any user row. Running both queries in the
122
+ * tenant's transaction is what makes sessions work without a privileged client (D-068).
123
+ */
124
+ export function sessionMiddleware(
125
+ deps: SessionMiddlewareDeps,
126
+ ): (event: SessionEvent) => Promise<void> {
127
+ return async (event) => {
128
+ const bearerToken = readBearerToken(event.req.headers.get("authorization"));
129
+ if (bearerToken !== null) {
130
+ // A bearer request never falls back to the cookie: the client chose token auth, so an
131
+ // invalid token is anonymous rather than a browser session. The raw token is only ever
132
+ // hashed here — it must never be logged or stored (D-099).
133
+ event.context.auth = await runWithPolicyScope(
134
+ deps.db,
135
+ { tenantId: deps.tenantId },
136
+ async (tx) => {
137
+ const validated = await validateApiToken(tx, bearerToken);
138
+ if (validated === null) return null;
139
+ return {
140
+ userId: validated.userId,
141
+ roles: [validated.role],
142
+ permissions: expandRoles([validated.role], deps.config),
143
+ sessionHash: validated.tokenHash,
144
+ via: "bearer" as const,
145
+ };
146
+ },
147
+ );
148
+ return;
149
+ }
150
+
151
+ const sessionId = readCookie(event.req.headers.get("cookie"), SESSION_COOKIE_NAME);
152
+ if (sessionId === null) {
153
+ event.context.auth = null;
154
+ return;
155
+ }
156
+
157
+ const auth = await runWithPolicyScope(deps.db, { tenantId: deps.tenantId }, async (tx) => {
158
+ const found = await validateSession(tx, sessionId);
159
+ if (found === null) return null;
160
+ // The tenant is the deployment's, never the session's (D-040). RLS already hides another
161
+ // tenant's user row from the lookup; this explicit check keeps the rule even on a
162
+ // connection where RLS would not apply (defense in depth).
163
+ if (found.user.tenantId !== deps.tenantId) return null;
164
+
165
+ const roles = await loadUserRoles(tx, found.user.id);
166
+ return {
167
+ userId: found.user.id,
168
+ roles,
169
+ permissions: expandRoles(roles, deps.config),
170
+ };
171
+ });
172
+ // Only a valid session gets a hash: the audit trail correlates requests with a session
173
+ // without ever storing the cookie value (D-099).
174
+ event.context.auth =
175
+ auth === null ? null : { ...auth, sessionHash: await sha256Hex(sessionId), via: "session" };
176
+ };
177
+ }
178
+
179
+ async function loadUserRoles(db: PolicyScopedDatabase, userId: string): Promise<string[]> {
180
+ const rows = await db
181
+ .select({ role: userRoles.role })
182
+ .from(userRoles)
183
+ .where(eq(userRoles.userId, userId));
184
+ return rows.map((row) => row.role);
185
+ }
186
+
187
+ /**
188
+ * Extracts the token from an `Authorization: Bearer <token>` header. The scheme is
189
+ * case-insensitive (RFC 7235) and the token is returned verbatim; an empty, absent or
190
+ * malformed header yields null, i.e. "no bearer auth".
191
+ */
192
+ export function readBearerToken(header: string | null): string | null {
193
+ if (header === null || header.length === 0) return null;
194
+ const match = /^Bearer[ \t]+(.+)$/i.exec(header.trim());
195
+ const token = match?.[1]?.trim();
196
+ return token === undefined || token.length === 0 ? null : token;
197
+ }
198
+
199
+ function readCookie(header: string | null, name: string): string | null {
200
+ if (header === null || header.length === 0) return null;
201
+ for (const part of header.split(";")) {
202
+ const separator = part.indexOf("=");
203
+ if (separator === -1) continue;
204
+ if (part.slice(0, separator).trim() !== name) continue;
205
+ const value = part.slice(separator + 1).trim();
206
+ return value.length === 0 ? null : value;
207
+ }
208
+ return null;
209
+ }
@@ -0,0 +1,71 @@
1
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
2
+
3
+ /**
4
+ * Structural contracts shared by the SSO modules (D-120).
5
+ *
6
+ * @facilis.dev/core depends on @facilis.dev/auth (route.ts imports the RBAC helpers), so auth cannot
7
+ * import core without a package cycle. A system elevation is therefore described structurally:
8
+ * `ctx.system` from `defineAuthorizedHandler` satisfies {@link SystemRunner} without auth
9
+ * owning a copy of the core type.
10
+ */
11
+
12
+ /** What a system elevation must say about itself; all fields are audited (D-099). */
13
+ export interface SystemElevationOptions {
14
+ readonly actor: `system:${string}`;
15
+ readonly operation: string;
16
+ readonly reason: string;
17
+ /** Non-empty subset of the route's `dbTables`. */
18
+ readonly tables: readonly string[];
19
+ }
20
+
21
+ /** The audited system elevation passed into the SSO flow: never a raw database client. */
22
+ export type SystemRunner = <T>(
23
+ options: SystemElevationOptions,
24
+ fn: (systemDb: PolicyScopedDatabase) => Promise<T>,
25
+ ) => Promise<T>;
26
+
27
+ /** The identity an IdP asserts: the key, the email and whether that email is verified. */
28
+ export interface IdentityClaims {
29
+ readonly provider: string;
30
+ readonly subject: string;
31
+ readonly email: string;
32
+ readonly emailVerified: boolean;
33
+ }
34
+
35
+ /** The audit input subset the SSO flow writes through `ctx.log.audit` (D-119). */
36
+ export interface SsoAuditInput {
37
+ readonly operation: string;
38
+ readonly outcome?: "succeeded" | "failed";
39
+ readonly errorCode?: string;
40
+ readonly reason?: string;
41
+ readonly tables?: readonly string[];
42
+ readonly details?: Record<string, unknown>;
43
+ }
44
+
45
+ /** A narrower audit sink: `ctx.log.audit` satisfies it structurally. */
46
+ export type SsoAudit = (input: SsoAuditInput) => void;
47
+
48
+ /** The subset of the Fetch API the OAuth/OIDC/OAuth2 HTTP calls need (no Node dependency). */
49
+ export interface FetchInit {
50
+ readonly method?: string;
51
+ readonly headers?: Record<string, string>;
52
+ readonly body?: string;
53
+ }
54
+
55
+ export interface FetchResponse {
56
+ readonly ok: boolean;
57
+ readonly status: number;
58
+ json(): Promise<unknown>;
59
+ text(): Promise<string>;
60
+ }
61
+
62
+ export type FetchLike = (url: string, init?: FetchInit) => Promise<FetchResponse>;
63
+
64
+ /** The runtime fetch, narrowed to {@link FetchLike}; tests inject their own. */
65
+ export function runtimeFetch(): FetchLike {
66
+ const candidate = (globalThis as unknown as { fetch?: unknown }).fetch;
67
+ if (typeof candidate !== "function") {
68
+ throw new Error("facilis: the runtime has no fetch implementation for SSO");
69
+ }
70
+ return candidate as FetchLike;
71
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Safe machine codes for the SSO namespace (D-120).
3
+ *
4
+ * The browser is redirected to `failureRedirect?sso_error=<code>`; an internal message, a token,
5
+ * an authorization code or a SAML payload never reaches the client. Callers log the cause
6
+ * server-side through `ctx.log` and expose only the code.
7
+ */
8
+
9
+ export const SSO_ERROR_CODES = [
10
+ "sso_provider_unknown",
11
+ "sso_provider_unavailable",
12
+ "sso_state_invalid",
13
+ "sso_exchange_failed",
14
+ "sso_identity_invalid",
15
+ "sso_email_unverified",
16
+ // Verified IdP email matched a PASSWORD account that is not verified, in a deployment where
17
+ // email verification is off: linking it would be account pre-hijacking (SEC-02, D-156).
18
+ "sso_email_conflict",
19
+ "sso_account_not_found",
20
+ "sso_internal_error",
21
+ ] as const;
22
+
23
+ export type SsoErrorCode = (typeof SSO_ERROR_CODES)[number];
24
+
25
+ /** Thrown inside the flow; the route maps it to a redirect code, never to its message. */
26
+ export class SsoError extends Error {
27
+ readonly code: SsoErrorCode;
28
+
29
+ constructor(code: SsoErrorCode, message?: string, options?: ErrorOptions) {
30
+ super(message ?? code, options);
31
+ this.name = "SsoError";
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ export function isSsoError(error: unknown): error is SsoError {
37
+ return error instanceof SsoError;
38
+ }
39
+
40
+ /** Normalises any thrown value to a safe code; an unexpected failure is `sso_internal_error`. */
41
+ export function ssoErrorCode(error: unknown): SsoErrorCode {
42
+ return isSsoError(error) ? error.code : "sso_internal_error";
43
+ }
44
+
45
+ /**
46
+ * Appends `sso_error=<code>` to the configured failure redirect, keeping its existing query
47
+ * string and fragment-free path. The redirect is same-origin by config validation (D-120).
48
+ */
49
+ export function failureRedirectPath(failureRedirect: string, code: SsoErrorCode): string {
50
+ const url = new URL(failureRedirect, "http://facilis.invalid");
51
+ url.searchParams.set("sso_error", code);
52
+ return `${url.pathname}${url.search}`;
53
+ }