@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
@@ -0,0 +1,40 @@
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
+ /** Shape of `facilis.config.ts`'s `auth` block that @facilis.dev/auth needs. */
23
+ export interface AuthConfig {
24
+ readonly permissions: readonly string[];
25
+ readonly roles: Readonly<Record<string, readonly string[]>>;
26
+ readonly defaultRole: string;
27
+ readonly sessionDurationMs: number;
28
+ }
29
+ /**
30
+ * Expands app roles into concrete permissions, deterministically: roles keep their input
31
+ * order, permissions keep config order, duplicates collapse to the first occurrence.
32
+ * Unknown roles (e.g. a stale `user_roles` row) contribute nothing.
33
+ */
34
+ export declare function expandRoles(roles: readonly string[], config: AuthConfig): string[];
35
+ /**
36
+ * Route RBAC check (contract §4.4): `[]` requires nothing (public route), a non-empty
37
+ * list requires at least one match, and null means no session. A literal "*" permission
38
+ * on the subject grants everything as a safety net for hand-built contexts.
39
+ */
40
+ export declare function hasPermission(subject: Pick<AuthContext, "permissions"> | null, permission: string | readonly string[]): boolean;
package/dist/roles.js ADDED
@@ -0,0 +1,46 @@
1
+ const WILDCARD = "*";
2
+ /**
3
+ * Expands app roles into concrete permissions, deterministically: roles keep their input
4
+ * order, permissions keep config order, duplicates collapse to the first occurrence.
5
+ * Unknown roles (e.g. a stale `user_roles` row) contribute nothing.
6
+ */
7
+ export function expandRoles(roles, config) {
8
+ const expanded = [];
9
+ const seen = new Set();
10
+ const add = (permission) => {
11
+ if (seen.has(permission))
12
+ return;
13
+ seen.add(permission);
14
+ expanded.push(permission);
15
+ };
16
+ for (const role of roles) {
17
+ const declared = config.roles[role];
18
+ if (declared === undefined)
19
+ continue;
20
+ for (const permission of declared) {
21
+ if (permission === WILDCARD) {
22
+ for (const granted of config.permissions)
23
+ add(granted);
24
+ }
25
+ else {
26
+ add(permission);
27
+ }
28
+ }
29
+ }
30
+ return expanded;
31
+ }
32
+ /**
33
+ * Route RBAC check (contract §4.4): `[]` requires nothing (public route), a non-empty
34
+ * list requires at least one match, and null means no session. A literal "*" permission
35
+ * on the subject grants everything as a safety net for hand-built contexts.
36
+ */
37
+ export function hasPermission(subject, permission) {
38
+ const required = typeof permission === "string" ? [permission] : permission;
39
+ if (required.length === 0)
40
+ return true;
41
+ if (subject === null)
42
+ return false;
43
+ if (subject.permissions.includes(WILDCARD))
44
+ return true;
45
+ return required.some((candidate) => subject.permissions.includes(candidate));
46
+ }
package/dist/seed.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
2
+ import type { AuthConfig } from "./roles.js";
3
+ export interface SeedEntry {
4
+ readonly role: string;
5
+ readonly permission: string;
6
+ }
7
+ /**
8
+ * Pure projection of `config.auth.roles` into `role_permissions` rows (SPEC §5.3/§8.4.2).
9
+ * Wildcards stay literal ("*") exactly as the reference seed shows; the table is written
10
+ * only at migration time, never at runtime.
11
+ */
12
+ export declare function seedFromConfig(config: AuthConfig): SeedEntry[];
13
+ /**
14
+ * Deterministic, idempotent SQL for the migration/seed step: roles first, then
15
+ * role_permissions, every statement guarded by ON CONFLICT DO NOTHING.
16
+ */
17
+ export declare function seedStatements(config: AuthConfig): string[];
18
+ /**
19
+ * Grants `admin` to the user whose email matches FACILIS_INITIAL_ADMIN (SPEC §8.4.3).
20
+ *
21
+ * The email alone is not a credential: it is public and the registration route is public, so
22
+ * the promotion ALSO requires the operator's one-time FACILIS_INITIAL_ADMIN_TOKEN (D-150).
23
+ * Both variables must be set; a missing token fails closed, the token is compared in constant
24
+ * time, and the promotion happens only while no admin exists yet, so a deploy that keeps the
25
+ * token configured after bootstrap cannot be re-triggered. Accepts a user id or email; returns
26
+ * false when any condition fails.
27
+ */
28
+ export declare function registerInitialAdmin(db: PolicyScopedDatabase, userIdOrEmail: string, bootstrapToken?: string): Promise<boolean>;
package/dist/seed.js ADDED
@@ -0,0 +1,115 @@
1
+ import { eq, sql } from "drizzle-orm";
2
+ import { constantTimeEqual, sha256Hex } from "./crypto.js";
3
+ import { normalizeEmail } from "./email.js";
4
+ import { userRoles, users } from "./tables.js";
5
+ const INITIAL_ADMIN_ROLE = "admin";
6
+ /**
7
+ * Pure projection of `config.auth.roles` into `role_permissions` rows (SPEC §5.3/§8.4.2).
8
+ * Wildcards stay literal ("*") exactly as the reference seed shows; the table is written
9
+ * only at migration time, never at runtime.
10
+ */
11
+ export function seedFromConfig(config) {
12
+ const entries = [];
13
+ const seen = new Set();
14
+ for (const [role, permissions] of Object.entries(config.roles)) {
15
+ for (const permission of permissions) {
16
+ const key = `${role}\u0000${permission}`;
17
+ if (seen.has(key))
18
+ continue;
19
+ seen.add(key);
20
+ entries.push({ role, permission });
21
+ }
22
+ }
23
+ return entries;
24
+ }
25
+ /**
26
+ * Deterministic, idempotent SQL for the migration/seed step: roles first, then
27
+ * role_permissions, every statement guarded by ON CONFLICT DO NOTHING.
28
+ */
29
+ export function seedStatements(config) {
30
+ const statements = [];
31
+ for (const role of Object.keys(config.roles)) {
32
+ statements.push(`INSERT INTO roles (name) VALUES (${sqlLiteral(role)}) ON CONFLICT DO NOTHING;`);
33
+ }
34
+ for (const { role, permission } of seedFromConfig(config)) {
35
+ statements.push(`INSERT INTO role_permissions (role, permission) VALUES (${sqlLiteral(role)}, ${sqlLiteral(permission)}) ON CONFLICT DO NOTHING;`);
36
+ }
37
+ return statements;
38
+ }
39
+ /**
40
+ * Grants `admin` to the user whose email matches FACILIS_INITIAL_ADMIN (SPEC §8.4.3).
41
+ *
42
+ * The email alone is not a credential: it is public and the registration route is public, so
43
+ * the promotion ALSO requires the operator's one-time FACILIS_INITIAL_ADMIN_TOKEN (D-150).
44
+ * Both variables must be set; a missing token fails closed, the token is compared in constant
45
+ * time, and the promotion happens only while no admin exists yet, so a deploy that keeps the
46
+ * token configured after bootstrap cannot be re-triggered. Accepts a user id or email; returns
47
+ * false when any condition fails.
48
+ */
49
+ export async function registerInitialAdmin(db, userIdOrEmail, bootstrapToken) {
50
+ const expected = readInitialAdminEmail();
51
+ if (expected === null)
52
+ return false;
53
+ const expectedToken = readInitialAdminToken();
54
+ if (expectedToken === null || bootstrapToken === undefined)
55
+ return false;
56
+ if (!(await tokenMatches(bootstrapToken, expectedToken)))
57
+ return false;
58
+ // One-time: an admin already assigned means bootstrap is over, even if the env stays set.
59
+ if (await hasAdmin(db))
60
+ return false;
61
+ const user = await findUser(db, userIdOrEmail.trim());
62
+ if (user === null)
63
+ return false;
64
+ // Emails compare in canonical form: a case variant of the configured address is not the
65
+ // configured address (the unique lower(email) index makes the two the same account, D-150).
66
+ if (normalizeEmail(user.email) !== expected)
67
+ return false;
68
+ await db
69
+ .insert(userRoles)
70
+ .values({ userId: user.id, role: INITIAL_ADMIN_ROLE })
71
+ .onConflictDoNothing();
72
+ return true;
73
+ }
74
+ function readInitialAdminEmail() {
75
+ const configured = process.env.FACILIS_INITIAL_ADMIN?.trim();
76
+ return configured === undefined || configured.length === 0 ? null : normalizeEmail(configured);
77
+ }
78
+ function readInitialAdminToken() {
79
+ const configured = process.env.FACILIS_INITIAL_ADMIN_TOKEN?.trim();
80
+ return configured === undefined || configured.length === 0 ? null : configured;
81
+ }
82
+ /**
83
+ * Hash-then-compare keeps the comparison independent of the two input lengths, so a wrong
84
+ * token leaks neither its length nor the index of the first wrong character.
85
+ */
86
+ async function tokenMatches(provided, expected) {
87
+ const [left, right] = await Promise.all([sha256Hex(provided), sha256Hex(expected)]);
88
+ return constantTimeEqual(new TextEncoder().encode(left), new TextEncoder().encode(right));
89
+ }
90
+ async function hasAdmin(db) {
91
+ const rows = await db
92
+ .select({ role: userRoles.role })
93
+ .from(userRoles)
94
+ .where(eq(userRoles.role, INITIAL_ADMIN_ROLE))
95
+ .limit(1);
96
+ return rows.length > 0;
97
+ }
98
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
99
+ async function findUser(db, userIdOrEmail) {
100
+ const rows = UUID_PATTERN.test(userIdOrEmail)
101
+ ? await db
102
+ .select({ id: users.id, email: users.email })
103
+ .from(users)
104
+ .where(eq(users.id, userIdOrEmail))
105
+ .limit(1)
106
+ : await db
107
+ .select({ id: users.id, email: users.email })
108
+ .from(users)
109
+ .where(sql `lower(${users.email}) = ${normalizeEmail(userIdOrEmail)}`)
110
+ .limit(1);
111
+ return rows[0] ?? null;
112
+ }
113
+ function sqlLiteral(value) {
114
+ return `'${value.replaceAll("'", "''")}'`;
115
+ }
@@ -0,0 +1,66 @@
1
+ import { type FacilisDb, type PolicyScopedDatabase } from "@facilis.dev/db";
2
+ import { type AuthConfig } from "./roles.js";
3
+ import { type AuthUser, type SessionRow } from "./tables.js";
4
+ /** Cookie name fixed by contract §4.4; HttpOnly/Path=/SameSite=Lax/Secure-in-prod. The cookie
5
+ * carries the raw 32-byte token; the sessions row stores only its SHA-256 (D-144). */
6
+ export declare const SESSION_COOKIE_NAME = "facilis_session";
7
+ export interface CreateSessionOptions {
8
+ readonly ttlMs?: number;
9
+ }
10
+ export interface ValidatedSession {
11
+ readonly user: AuthUser;
12
+ readonly session: SessionRow;
13
+ }
14
+ /** Creates an opaque session row; callers set the cookie themselves (route blueprint). */
15
+ export declare function createSession(db: PolicyScopedDatabase, userId: string, options?: CreateSessionOptions): Promise<{
16
+ id: string;
17
+ expiresAt: Date;
18
+ }>;
19
+ /**
20
+ * Joins the session to its user; expired or unknown sessions yield null.
21
+ *
22
+ * Callers pass a policy-scoped client (`runWithPolicyScope`) with app.tenant_id set: users
23
+ * carries tenant RLS, so an unscoped read as the non-superuser facilis_app role cannot see
24
+ * the joined user row at all.
25
+ */
26
+ export declare function validateSession(db: PolicyScopedDatabase, sessionId: string): Promise<ValidatedSession | null>;
27
+ /** Deletes a session row; deleting an unknown id is a no-op. */
28
+ export declare function destroySession(db: PolicyScopedDatabase, sessionId: string): Promise<void>;
29
+ /**
30
+ * Structural event type: an H3/Nitro event satisfies it without importing h3 here.
31
+ * `context` stays a plain record because h3's H3EventContext exposes unknown values
32
+ * through an index signature; middleware writes `context.auth` (AuthContext | null).
33
+ */
34
+ export type SessionEventContext = Record<string, unknown>;
35
+ export interface SessionEvent {
36
+ context: SessionEventContext;
37
+ req: {
38
+ headers: {
39
+ get(name: string): string | null;
40
+ };
41
+ };
42
+ }
43
+ export interface SessionMiddlewareDeps {
44
+ /** Database the middleware scopes; the runtime may connect as the facilis_app role. */
45
+ readonly db: FacilisDb;
46
+ readonly config: AuthConfig;
47
+ /** The deployment's tenant; a session whose user row names another one is rejected (D-040). */
48
+ readonly tenantId: string;
49
+ }
50
+ /**
51
+ * Builds the `facilis-session` middleware handler. An `Authorization: Bearer` token wins
52
+ * over the session cookie; otherwise it reads the cookie, then validates the session and
53
+ * loads app roles inside ONE deployment-tenant policy scope (`runWithPolicyScope`), expands
54
+ * the roles from config (SPEC §8.4.4) and sets `context.auth` with the auth channel in `via`.
55
+ *
56
+ * The scope matters: production connects as the non-superuser facilis_app role, so `users`
57
+ * is RLS-filtered and an unscoped read cannot see any user row. Running both queries in the
58
+ * tenant's transaction is what makes sessions work without a privileged client (D-068).
59
+ */
60
+ export declare function sessionMiddleware(deps: SessionMiddlewareDeps): (event: SessionEvent) => Promise<void>;
61
+ /**
62
+ * Extracts the token from an `Authorization: Bearer <token>` header. The scheme is
63
+ * case-insensitive (RFC 7235) and the token is returned verbatim; an empty, absent or
64
+ * malformed header yields null, i.e. "no bearer auth".
65
+ */
66
+ export declare function readBearerToken(header: string | null): string | null;
@@ -0,0 +1,161 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { runWithPolicyScope } from "@facilis.dev/db";
3
+ import { validateApiToken } from "./api-clients.js";
4
+ import { randomToken, sha256Hex } from "./crypto.js";
5
+ import { expandRoles } from "./roles.js";
6
+ import { sessions, userRoles, users } from "./tables.js";
7
+ /** Cookie name fixed by contract §4.4; HttpOnly/Path=/SameSite=Lax/Secure-in-prod. The cookie
8
+ * carries the raw 32-byte token; the sessions row stores only its SHA-256 (D-144). */
9
+ export const SESSION_COOKIE_NAME = "facilis_session";
10
+ const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7;
11
+ /** Creates an opaque session row; callers set the cookie themselves (route blueprint). */
12
+ export async function createSession(db, userId, options = {}) {
13
+ // A service account exists only for machine credentials: minting a cookie session for one
14
+ // would make it loggable in a browser, so refuse here even if a caller missed the flag.
15
+ const serviceAccount = await db
16
+ .select({ isService: users.isService })
17
+ .from(users)
18
+ .where(eq(users.id, userId))
19
+ .limit(1);
20
+ if (serviceAccount[0]?.isService === true) {
21
+ throw new Error("createSession: service accounts cannot log in");
22
+ }
23
+ const id = randomToken(32);
24
+ const expiresAt = new Date(Date.now() + (options.ttlMs ?? DEFAULT_SESSION_TTL_MS));
25
+ // The row key is the SHA-256 of the cookie value: the raw id is the credential and never
26
+ // reaches the database, so a SELECT on sessions (RLS-scoped, tenant-visible) cannot be
27
+ // replayed as a session. The cookie keeps the raw value; lookups hash it again.
28
+ await db.insert(sessions).values({ id: await sha256Hex(id), userId, expiresAt });
29
+ return { id, expiresAt };
30
+ }
31
+ /**
32
+ * Joins the session to its user; expired or unknown sessions yield null.
33
+ *
34
+ * Callers pass a policy-scoped client (`runWithPolicyScope`) with app.tenant_id set: users
35
+ * carries tenant RLS, so an unscoped read as the non-superuser facilis_app role cannot see
36
+ * the joined user row at all.
37
+ */
38
+ export async function validateSession(db, sessionId) {
39
+ // Rows hold sha256(cookie id): the caller passes the raw cookie and the lookup hashes it.
40
+ const rows = await db
41
+ .select({
42
+ session: sessions,
43
+ user: {
44
+ id: users.id,
45
+ email: users.email,
46
+ tenantId: users.tenantId,
47
+ createdAt: users.createdAt,
48
+ },
49
+ isService: users.isService,
50
+ })
51
+ .from(sessions)
52
+ .innerJoin(users, eq(sessions.userId, users.id))
53
+ .where(eq(sessions.id, await sha256Hex(sessionId)))
54
+ .limit(1);
55
+ const found = rows[0];
56
+ if (found === undefined)
57
+ return null;
58
+ // Defense in depth: a service account never validates a cookie session, even one inserted
59
+ // before createSession started refusing them.
60
+ if (found.isService)
61
+ return null;
62
+ if (found.session.expiresAt.getTime() <= Date.now())
63
+ return null;
64
+ return { user: found.user, session: found.session };
65
+ }
66
+ /** Deletes a session row; deleting an unknown id is a no-op. */
67
+ export async function destroySession(db, sessionId) {
68
+ // Deleting an unknown id stays a no-op; the stored key is the hash of the cookie value.
69
+ await db.delete(sessions).where(eq(sessions.id, await sha256Hex(sessionId)));
70
+ }
71
+ /**
72
+ * Builds the `facilis-session` middleware handler. An `Authorization: Bearer` token wins
73
+ * over the session cookie; otherwise it reads the cookie, then validates the session and
74
+ * loads app roles inside ONE deployment-tenant policy scope (`runWithPolicyScope`), expands
75
+ * the roles from config (SPEC §8.4.4) and sets `context.auth` with the auth channel in `via`.
76
+ *
77
+ * The scope matters: production connects as the non-superuser facilis_app role, so `users`
78
+ * is RLS-filtered and an unscoped read cannot see any user row. Running both queries in the
79
+ * tenant's transaction is what makes sessions work without a privileged client (D-068).
80
+ */
81
+ export function sessionMiddleware(deps) {
82
+ return async (event) => {
83
+ const bearerToken = readBearerToken(event.req.headers.get("authorization"));
84
+ if (bearerToken !== null) {
85
+ // A bearer request never falls back to the cookie: the client chose token auth, so an
86
+ // invalid token is anonymous rather than a browser session. The raw token is only ever
87
+ // hashed here — it must never be logged or stored (D-099).
88
+ event.context.auth = await runWithPolicyScope(deps.db, { tenantId: deps.tenantId }, async (tx) => {
89
+ const validated = await validateApiToken(tx, bearerToken);
90
+ if (validated === null)
91
+ return null;
92
+ return {
93
+ userId: validated.userId,
94
+ roles: [validated.role],
95
+ permissions: expandRoles([validated.role], deps.config),
96
+ sessionHash: validated.tokenHash,
97
+ via: "bearer",
98
+ };
99
+ });
100
+ return;
101
+ }
102
+ const sessionId = readCookie(event.req.headers.get("cookie"), SESSION_COOKIE_NAME);
103
+ if (sessionId === null) {
104
+ event.context.auth = null;
105
+ return;
106
+ }
107
+ const auth = await runWithPolicyScope(deps.db, { tenantId: deps.tenantId }, async (tx) => {
108
+ const found = await validateSession(tx, sessionId);
109
+ if (found === null)
110
+ return null;
111
+ // The tenant is the deployment's, never the session's (D-040). RLS already hides another
112
+ // tenant's user row from the lookup; this explicit check keeps the rule even on a
113
+ // connection where RLS would not apply (defense in depth).
114
+ if (found.user.tenantId !== deps.tenantId)
115
+ return null;
116
+ const roles = await loadUserRoles(tx, found.user.id);
117
+ return {
118
+ userId: found.user.id,
119
+ roles,
120
+ permissions: expandRoles(roles, deps.config),
121
+ };
122
+ });
123
+ // Only a valid session gets a hash: the audit trail correlates requests with a session
124
+ // without ever storing the cookie value (D-099).
125
+ event.context.auth =
126
+ auth === null ? null : { ...auth, sessionHash: await sha256Hex(sessionId), via: "session" };
127
+ };
128
+ }
129
+ async function loadUserRoles(db, userId) {
130
+ const rows = await db
131
+ .select({ role: userRoles.role })
132
+ .from(userRoles)
133
+ .where(eq(userRoles.userId, userId));
134
+ return rows.map((row) => row.role);
135
+ }
136
+ /**
137
+ * Extracts the token from an `Authorization: Bearer <token>` header. The scheme is
138
+ * case-insensitive (RFC 7235) and the token is returned verbatim; an empty, absent or
139
+ * malformed header yields null, i.e. "no bearer auth".
140
+ */
141
+ export function readBearerToken(header) {
142
+ if (header === null || header.length === 0)
143
+ return null;
144
+ const match = /^Bearer[ \t]+(.+)$/i.exec(header.trim());
145
+ const token = match?.[1]?.trim();
146
+ return token === undefined || token.length === 0 ? null : token;
147
+ }
148
+ function readCookie(header, name) {
149
+ if (header === null || header.length === 0)
150
+ return null;
151
+ for (const part of header.split(";")) {
152
+ const separator = part.indexOf("=");
153
+ if (separator === -1)
154
+ continue;
155
+ if (part.slice(0, separator).trim() !== name)
156
+ continue;
157
+ const value = part.slice(separator + 1).trim();
158
+ return value.length === 0 ? null : value;
159
+ }
160
+ return null;
161
+ }
@@ -0,0 +1,52 @@
1
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
2
+ /**
3
+ * Structural contracts shared by the SSO modules (D-120).
4
+ *
5
+ * @facilis.dev/core depends on @facilis.dev/auth (route.ts imports the RBAC helpers), so auth cannot
6
+ * import core without a package cycle. A system elevation is therefore described structurally:
7
+ * `ctx.system` from `defineAuthorizedHandler` satisfies {@link SystemRunner} without auth
8
+ * owning a copy of the core type.
9
+ */
10
+ /** What a system elevation must say about itself; all fields are audited (D-099). */
11
+ export interface SystemElevationOptions {
12
+ readonly actor: `system:${string}`;
13
+ readonly operation: string;
14
+ readonly reason: string;
15
+ /** Non-empty subset of the route's `dbTables`. */
16
+ readonly tables: readonly string[];
17
+ }
18
+ /** The audited system elevation passed into the SSO flow: never a raw database client. */
19
+ export type SystemRunner = <T>(options: SystemElevationOptions, fn: (systemDb: PolicyScopedDatabase) => Promise<T>) => Promise<T>;
20
+ /** The identity an IdP asserts: the key, the email and whether that email is verified. */
21
+ export interface IdentityClaims {
22
+ readonly provider: string;
23
+ readonly subject: string;
24
+ readonly email: string;
25
+ readonly emailVerified: boolean;
26
+ }
27
+ /** The audit input subset the SSO flow writes through `ctx.log.audit` (D-119). */
28
+ export interface SsoAuditInput {
29
+ readonly operation: string;
30
+ readonly outcome?: "succeeded" | "failed";
31
+ readonly errorCode?: string;
32
+ readonly reason?: string;
33
+ readonly tables?: readonly string[];
34
+ readonly details?: Record<string, unknown>;
35
+ }
36
+ /** A narrower audit sink: `ctx.log.audit` satisfies it structurally. */
37
+ export type SsoAudit = (input: SsoAuditInput) => void;
38
+ /** The subset of the Fetch API the OAuth/OIDC/OAuth2 HTTP calls need (no Node dependency). */
39
+ export interface FetchInit {
40
+ readonly method?: string;
41
+ readonly headers?: Record<string, string>;
42
+ readonly body?: string;
43
+ }
44
+ export interface FetchResponse {
45
+ readonly ok: boolean;
46
+ readonly status: number;
47
+ json(): Promise<unknown>;
48
+ text(): Promise<string>;
49
+ }
50
+ export type FetchLike = (url: string, init?: FetchInit) => Promise<FetchResponse>;
51
+ /** The runtime fetch, narrowed to {@link FetchLike}; tests inject their own. */
52
+ export declare function runtimeFetch(): FetchLike;
@@ -0,0 +1,8 @@
1
+ /** The runtime fetch, narrowed to {@link FetchLike}; tests inject their own. */
2
+ export function runtimeFetch() {
3
+ const candidate = globalThis.fetch;
4
+ if (typeof candidate !== "function") {
5
+ throw new Error("facilis: the runtime has no fetch implementation for SSO");
6
+ }
7
+ return candidate;
8
+ }
@@ -0,0 +1,22 @@
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
+ export declare const SSO_ERROR_CODES: readonly ["sso_provider_unknown", "sso_provider_unavailable", "sso_state_invalid", "sso_exchange_failed", "sso_identity_invalid", "sso_email_unverified", "sso_email_conflict", "sso_account_not_found", "sso_internal_error"];
9
+ export type SsoErrorCode = (typeof SSO_ERROR_CODES)[number];
10
+ /** Thrown inside the flow; the route maps it to a redirect code, never to its message. */
11
+ export declare class SsoError extends Error {
12
+ readonly code: SsoErrorCode;
13
+ constructor(code: SsoErrorCode, message?: string, options?: ErrorOptions);
14
+ }
15
+ export declare function isSsoError(error: unknown): error is SsoError;
16
+ /** Normalises any thrown value to a safe code; an unexpected failure is `sso_internal_error`. */
17
+ export declare function ssoErrorCode(error: unknown): SsoErrorCode;
18
+ /**
19
+ * Appends `sso_error=<code>` to the configured failure redirect, keeping its existing query
20
+ * string and fragment-free path. The redirect is same-origin by config validation (D-120).
21
+ */
22
+ export declare function failureRedirectPath(failureRedirect: string, code: SsoErrorCode): string;
@@ -0,0 +1,45 @@
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
+ export const SSO_ERROR_CODES = [
9
+ "sso_provider_unknown",
10
+ "sso_provider_unavailable",
11
+ "sso_state_invalid",
12
+ "sso_exchange_failed",
13
+ "sso_identity_invalid",
14
+ "sso_email_unverified",
15
+ // Verified IdP email matched a PASSWORD account that is not verified, in a deployment where
16
+ // email verification is off: linking it would be account pre-hijacking (SEC-02, D-156).
17
+ "sso_email_conflict",
18
+ "sso_account_not_found",
19
+ "sso_internal_error",
20
+ ];
21
+ /** Thrown inside the flow; the route maps it to a redirect code, never to its message. */
22
+ export class SsoError extends Error {
23
+ code;
24
+ constructor(code, message, options) {
25
+ super(message ?? code, options);
26
+ this.name = "SsoError";
27
+ this.code = code;
28
+ }
29
+ }
30
+ export function isSsoError(error) {
31
+ return error instanceof SsoError;
32
+ }
33
+ /** Normalises any thrown value to a safe code; an unexpected failure is `sso_internal_error`. */
34
+ export function ssoErrorCode(error) {
35
+ return isSsoError(error) ? error.code : "sso_internal_error";
36
+ }
37
+ /**
38
+ * Appends `sso_error=<code>` to the configured failure redirect, keeping its existing query
39
+ * string and fragment-free path. The redirect is same-origin by config validation (D-120).
40
+ */
41
+ export function failureRedirectPath(failureRedirect, code) {
42
+ const url = new URL(failureRedirect, "http://facilis.invalid");
43
+ url.searchParams.set("sso_error", code);
44
+ return `${url.pathname}${url.search}`;
45
+ }
@@ -0,0 +1,41 @@
1
+ import type { IdentityClaims, SsoAudit, SystemRunner } from "./contracts.js";
2
+ /**
3
+ * Account linking and JIT provisioning (D-120).
4
+ *
5
+ * The identity key is `(provider, subject)`: a changed email never breaks an existing link.
6
+ * Without a link, the asserted email must be verified before it can match a user, and a user is
7
+ * only created when the provider's `autoProvision` allows it. Every read/write happens inside the
8
+ * caller's audited `ctx.system` elevation — never on a raw client (D-099).
9
+ *
10
+ * Races are handled with the unique keys the schema already has: `(provider, subject)` for the
11
+ * identity and `users.email` for the user, both `ON CONFLICT DO NOTHING` followed by a re-read.
12
+ */
13
+ export declare const IDENTITY_TABLES: readonly ["auth_identities", "users", "user_roles"];
14
+ export interface IdentityProviderPolicy {
15
+ readonly id: string;
16
+ readonly autoProvision: boolean;
17
+ readonly defaultRole?: string;
18
+ }
19
+ export interface ResolveIdentityOptions {
20
+ /** `config.auth.defaultRole`, used when the provider has no `defaultRole`. */
21
+ readonly defaultRole: string;
22
+ /**
23
+ * `config.auth.verification.enabled` (D-156): with email verification on, a pending password
24
+ * account is claimed (the IdP just proved the mailbox). With it off, the account was never
25
+ * meant to be verified, so the link is refused instead of silently resetting its password.
26
+ */
27
+ readonly claimUnverifiedPasswordAccount: boolean;
28
+ readonly audit: SsoAudit;
29
+ }
30
+ export type IdentityOutcome = "existing" | "linked" | "provisioned";
31
+ export interface IdentityResolution {
32
+ readonly userId: string;
33
+ readonly outcome: IdentityOutcome;
34
+ }
35
+ /**
36
+ * Resolves the Facilis user for a verified identity.
37
+ *
38
+ * Throws `sso_email_unverified` for an unverified email that has no existing link and
39
+ * `sso_account_not_found` when provisioning is disabled; both are safe to redirect.
40
+ */
41
+ export declare function resolveIdentity(system: SystemRunner, claims: IdentityClaims, provider: IdentityProviderPolicy, options: ResolveIdentityOptions, now?: Date): Promise<IdentityResolution>;