@facilis.dev/auth 0.0.0-stage → 0.1.1

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/dist/tables.js ADDED
@@ -0,0 +1,148 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { boolean, index, pgPolicy, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid, } from "drizzle-orm/pg-core";
3
+ import { rlsForTable, tenantColumn } from "@facilis.dev/db";
4
+ const usersRls = rlsForTable({ tenantColumn: "tenant_id" });
5
+ /**
6
+ * Auth tables (SPEC §5.3). Access is two-class (D-136): a table with RLS is reachable by the
7
+ * app role under its policies; a table without RLS is system-only (`facilis_app` has no
8
+ * privilege on it, only the audited system identity can read or write it).
9
+ *
10
+ * `role_permissions` and `roles` are the system-only pair: `role_permissions` is a
11
+ * projection of facilis.config.ts, written only by migrations/seed and never read at runtime
12
+ * (permissions come from the config through `expandRoles`). `sessions` and `user_roles` are
13
+ * app-reachable, so they carry an RLS policy that inherits the parent `users` row's
14
+ * visibility instead of restating tenant isolation.
15
+ *
16
+ * Users carry both policies: restrictive tenant isolation (D-008) AND-ed with a permissive
17
+ * "tenant scope present" policy, since Postgres denies by default when no permissive policy
18
+ * applies. Net effect: non-superusers only reach users of the tenant in `app.tenant_id`.
19
+ */
20
+ export const users = pgTable("users", {
21
+ id: uuid("id").primaryKey().defaultRandom(),
22
+ email: text("email").notNull().unique(),
23
+ // The registration insert has no session yet; the column takes the deployment tenant
24
+ // from the policy-scope GUC (D-040). Postgres fills it before the RLS check runs.
25
+ tenantId: tenantColumn(),
26
+ passwordHash: text("password_hash"),
27
+ // Service accounts exist only to back an API client. They have no password and every
28
+ // login path rejects them, so the flag is a stored fact the session/SSO code can check.
29
+ isService: boolean("is_service").notNull().default(false),
30
+ // null means "never verified" (D-156): a password account starts here and only the
31
+ // single-use link consumed by the verification route sets the timestamp.
32
+ emailVerifiedAt: timestamp("email_verified_at"),
33
+ createdAt: timestamp("created_at").defaultNow(),
34
+ }, (table) => [
35
+ // Canonical identity: emails are stored normalized, and the unique lower(email) index makes
36
+ // a case variant rejected by the database too, not only by the app (D-150).
37
+ uniqueIndex("users_email_lower_idx").on(sql `lower(${table.email})`),
38
+ ...usersRls.policies,
39
+ pgPolicy("users_tenant_access", {
40
+ for: "all",
41
+ using: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
42
+ withCheck: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
43
+ }),
44
+ ]).enableRLS();
45
+ /**
46
+ * Inherits the parent `users` row's visibility instead of restating it (the D-053 pattern):
47
+ * a session or role assignment is reachable exactly when the user it points at is visible in
48
+ * the current tenant scope. This is what keeps the two tables app-reachable — their rows have
49
+ * no tenant column of their own (D-136).
50
+ */
51
+ function parentUserAccessPolicy(name, column) {
52
+ const predicate = sql `exists (select 1 from ${users} u where u.id = ${column})`;
53
+ return pgPolicy(name, { for: "all", using: predicate, withCheck: predicate });
54
+ }
55
+ /**
56
+ * Email verification links (D-156): one active token per user, stored as `sha256(token)`; the raw
57
+ * link exists only in the delivered email. App-scoped like the other auth tables (D-136), and
58
+ * reached through the audited system elevation by the verification route, which has no session.
59
+ */
60
+ const emailVerificationsRls = rlsForTable({ tenantColumn: "tenant_id" });
61
+ export const authEmailVerifications = pgTable("auth_email_verifications", {
62
+ // sha256(token): a tenant-scoped read of the table cannot be replayed as a verification link.
63
+ id: text("id").primaryKey(),
64
+ userId: uuid("user_id")
65
+ .notNull()
66
+ .references(() => users.id, { onDelete: "cascade" }),
67
+ createdAt: timestamp("created_at").notNull().defaultNow(),
68
+ expiresAt: timestamp("expires_at").notNull(),
69
+ tenantId: tenantColumn(),
70
+ }, (table) => [
71
+ // One lookup per user for "rotate the outstanding link"; the primary key covers the token.
72
+ index("auth_email_verifications_user_idx").on(table.userId),
73
+ ...emailVerificationsRls.policies,
74
+ pgPolicy("auth_email_verifications_tenant_access", {
75
+ for: "all",
76
+ using: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
77
+ withCheck: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
78
+ }),
79
+ ]).enableRLS();
80
+ export const sessions = pgTable("sessions", {
81
+ // sha256(cookie id): the raw credential never reaches the table (D-144).
82
+ id: text("id").primaryKey(),
83
+ userId: uuid("user_id")
84
+ .notNull()
85
+ .references(() => users.id),
86
+ expiresAt: timestamp("expires_at").notNull(),
87
+ }, (table) => [parentUserAccessPolicy("sessions_user_access", table.userId)]).enableRLS();
88
+ // System-only (no RLS): seeded by the migration, never read by a route (D-136).
89
+ export const roles = pgTable("roles", {
90
+ name: text("name").primaryKey(),
91
+ });
92
+ export const rolePermissions = pgTable("role_permissions", {
93
+ role: text("role")
94
+ .notNull()
95
+ .references(() => roles.name),
96
+ permission: text("permission").notNull(),
97
+ }, (table) => [primaryKey({ columns: [table.role, table.permission] })]);
98
+ export const userRoles = pgTable("user_roles", {
99
+ userId: uuid("user_id")
100
+ .notNull()
101
+ .references(() => users.id),
102
+ role: text("role")
103
+ .notNull()
104
+ .references(() => roles.name),
105
+ assignedBy: uuid("assigned_by"),
106
+ assignedAt: timestamp("assigned_at").defaultNow(),
107
+ }, (table) => [
108
+ primaryKey({ columns: [table.userId, table.role] }),
109
+ parentUserAccessPolicy("user_roles_user_access", table.userId),
110
+ ]).enableRLS();
111
+ /**
112
+ * Programmatic API clients (V3a). A client is a service user plus a hashed secret and a fixed
113
+ * role; both new tables are app-scoped through the parent user (D-136), so the RLS boundary
114
+ * stays the `users` table and a client row is reachable exactly when its service user is.
115
+ */
116
+ export const apiClients = pgTable("api_clients", {
117
+ id: uuid("id").primaryKey().defaultRandom(),
118
+ clientId: text("client_id").notNull().unique(),
119
+ // Only the SHA-256 of the secret is stored; the plaintext leaves the process once, in
120
+ // the createApiClient result, and can never be recovered from the database.
121
+ secretHash: text("secret_hash").notNull(),
122
+ name: text("name").notNull(),
123
+ // The client's fixed role: an issued token carries exactly this role, never the user's
124
+ // interactive roles (a service account has none worth expanding).
125
+ role: text("role").notNull(),
126
+ userId: uuid("user_id")
127
+ .notNull()
128
+ .references(() => users.id),
129
+ createdBy: uuid("created_by"),
130
+ createdAt: timestamp("created_at").defaultNow(),
131
+ revokedAt: timestamp("revoked_at"),
132
+ lastUsedAt: timestamp("last_used_at"),
133
+ }, (table) => [parentUserAccessPolicy("api_clients_user_access", table.userId)]).enableRLS();
134
+ export const apiTokens = pgTable("api_tokens", {
135
+ id: uuid("id").primaryKey().defaultRandom(),
136
+ // Lookup key for a presented bearer token: SHA-256 of the raw value, never the value.
137
+ tokenHash: text("token_hash").notNull().unique(),
138
+ clientId: uuid("client_id")
139
+ .notNull()
140
+ .references(() => apiClients.id),
141
+ userId: uuid("user_id")
142
+ .notNull()
143
+ .references(() => users.id),
144
+ expiresAt: timestamp("expires_at").notNull(),
145
+ createdAt: timestamp("created_at").defaultNow(),
146
+ revokedAt: timestamp("revoked_at"),
147
+ lastUsedAt: timestamp("last_used_at"),
148
+ }, (table) => [parentUserAccessPolicy("api_tokens_user_access", table.userId)]).enableRLS();
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@facilis.dev/auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Facilis authentication and RBAC primitives: sessions, role expansion, permission checks.",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22.15.0"
19
+ },
20
+ "dependencies": {
21
+ "@facilis.dev/db": "0.1.1",
22
+ "@node-saml/node-saml": "5.2.0",
23
+ "drizzle-orm": "0.45.4",
24
+ "jose": "6.2.13"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/facilis-dev/facilis.git",
32
+ "directory": "packages/auth"
33
+ },
34
+ "homepage": "https://github.com/facilis-dev/facilis/tree/main/packages/auth#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/facilis-dev/facilis/issues"
37
+ },
38
+ "keywords": [
39
+ "facilis",
40
+ "framework",
41
+ "vite",
42
+ "nitro",
43
+ "low-code"
44
+ ],
45
+ "main": "./dist/index.js",
46
+ "types": "./dist/index.d.ts"
47
+ }
@@ -0,0 +1,261 @@
1
+ import { and, eq, isNull, lt, or } from "drizzle-orm";
2
+
3
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
4
+
5
+ import { constantTimeEqual, randomToken, sha256Hex } from "./crypto.ts";
6
+ import { apiClients, apiTokens, roles, userRoles, users } from "./tables.ts";
7
+
8
+ /**
9
+ * Programmatic API clients (V3a): a service user plus a hashed secret, and short-lived
10
+ * bearer tokens derived from it.
11
+ *
12
+ * Nothing secret is ever stored in the clear: the client secret and every access token are
13
+ * written as SHA-256 hex digests, and the plaintext appears only in the return value of the
14
+ * call that creates it (a one-time secret, like a password reset). Secrets are compared
15
+ * through `constantTimeEqual` so verification does not leak the first mismatching byte.
16
+ */
17
+
18
+ /** Public credential prefixes: a leaked value is recognisable to secret scanners. */
19
+ const CLIENT_ID_PREFIX = "fcli_";
20
+ const CLIENT_SECRET_PREFIX = "fsec_";
21
+ const ACCESS_TOKEN_PREFIX = "fat_";
22
+
23
+ const CLIENT_ID_BYTES = 24;
24
+ const CLIENT_SECRET_BYTES = 32;
25
+ const ACCESS_TOKEN_BYTES = 32;
26
+
27
+ /**
28
+ * A successful validation stamps `last_used_at` at most once per window instead of on every
29
+ * request: the column is for operators ("is this client still used?"), not an access log.
30
+ */
31
+ const LAST_USED_WRITE_WINDOW_MS = 5 * 60 * 1000;
32
+
33
+ export interface CreateApiClientInput {
34
+ readonly name: string;
35
+ readonly role: string;
36
+ /** Interactive user who created the client, when the caller knows it. */
37
+ readonly createdBy?: string | null;
38
+ }
39
+
40
+ export interface CreatedApiClient {
41
+ readonly id: string;
42
+ readonly clientId: string;
43
+ /** One-time plaintext secret: never persisted and not recoverable afterwards. */
44
+ readonly secret: string;
45
+ readonly name: string;
46
+ readonly role: string;
47
+ readonly userId: string;
48
+ }
49
+
50
+ /** Management projection of a client: same columns minus `secretHash`, which never leaves. */
51
+ export interface ApiClientSummary {
52
+ readonly id: string;
53
+ readonly clientId: string;
54
+ readonly name: string;
55
+ readonly role: string;
56
+ readonly createdAt: Date | null;
57
+ readonly revokedAt: Date | null;
58
+ readonly lastUsedAt: Date | null;
59
+ readonly userId: string;
60
+ }
61
+
62
+ export interface IssueApiTokenInput {
63
+ readonly clientId: string;
64
+ readonly clientSecret: string;
65
+ readonly ttlMs: number;
66
+ }
67
+
68
+ export interface IssuedApiToken {
69
+ readonly accessToken: string;
70
+ readonly expiresAt: Date;
71
+ }
72
+
73
+ export interface ValidatedApiToken {
74
+ readonly userId: string;
75
+ /** The client's fixed role, not the service user's (empty) interactive roles. */
76
+ readonly role: string;
77
+ /** SHA-256 of the raw token: the audit correlation value, safe to log. */
78
+ readonly tokenHash: string;
79
+ }
80
+
81
+ /**
82
+ * Creates a service user, its role assignment and an API client.
83
+ *
84
+ * Callers run this under the audited system identity: the `roles` table is system-only
85
+ * (D-136) and here it is the migration-time projection of `config.auth.roles`
86
+ * (`seedFromConfig`), so a role the project never declared is rejected before any row is
87
+ * written. The service user takes its tenant from the policy-scope GUC, exactly like a
88
+ * registration insert (D-040).
89
+ */
90
+ export async function createApiClient(
91
+ db: PolicyScopedDatabase,
92
+ input: CreateApiClientInput,
93
+ ): Promise<CreatedApiClient> {
94
+ const knownRole = await db
95
+ .select({ name: roles.name })
96
+ .from(roles)
97
+ .where(eq(roles.name, input.role))
98
+ .limit(1);
99
+ if (knownRole.length === 0) throw new Error(`createApiClient: unknown role "${input.role}"`);
100
+
101
+ const createdUser = await db
102
+ .insert(users)
103
+ .values({
104
+ email: `service+${globalThis.crypto.randomUUID()}@facilis.invalid`,
105
+ passwordHash: null,
106
+ isService: true,
107
+ })
108
+ .returning({ id: users.id });
109
+ const user = createdUser[0];
110
+ if (user === undefined) throw new Error("createApiClient: service user insert returned no row");
111
+
112
+ await db.insert(userRoles).values({
113
+ userId: user.id,
114
+ role: input.role,
115
+ assignedBy: input.createdBy ?? null,
116
+ });
117
+
118
+ const clientId = `${CLIENT_ID_PREFIX}${randomToken(CLIENT_ID_BYTES)}`;
119
+ const secret = `${CLIENT_SECRET_PREFIX}${randomToken(CLIENT_SECRET_BYTES)}`;
120
+ const createdClient = await db
121
+ .insert(apiClients)
122
+ .values({
123
+ clientId,
124
+ secretHash: await sha256Hex(secret),
125
+ name: input.name,
126
+ role: input.role,
127
+ userId: user.id,
128
+ createdBy: input.createdBy ?? null,
129
+ })
130
+ .returning({ id: apiClients.id });
131
+ const client = createdClient[0];
132
+ if (client === undefined) throw new Error("createApiClient: api client insert returned no row");
133
+
134
+ return { id: client.id, clientId, secret, name: input.name, role: input.role, userId: user.id };
135
+ }
136
+
137
+ /** Lists clients for management; the secret hash is never part of the projection. */
138
+ export async function listApiClients(db: PolicyScopedDatabase): Promise<ApiClientSummary[]> {
139
+ return db
140
+ .select({
141
+ id: apiClients.id,
142
+ clientId: apiClients.clientId,
143
+ name: apiClients.name,
144
+ role: apiClients.role,
145
+ createdAt: apiClients.createdAt,
146
+ revokedAt: apiClients.revokedAt,
147
+ lastUsedAt: apiClients.lastUsedAt,
148
+ userId: apiClients.userId,
149
+ })
150
+ .from(apiClients);
151
+ }
152
+
153
+ /**
154
+ * Revokes a client and every token issued to it. Returns false for an unknown id; revoking
155
+ * twice is a no-op that keeps the first revocation timestamp (it is an audit fact).
156
+ */
157
+ export async function revokeApiClient(db: PolicyScopedDatabase, id: string): Promise<boolean> {
158
+ const found = await db
159
+ .select({ id: apiClients.id })
160
+ .from(apiClients)
161
+ .where(eq(apiClients.id, id))
162
+ .limit(1);
163
+ if (found.length === 0) return false;
164
+
165
+ const now = new Date();
166
+ await db
167
+ .update(apiClients)
168
+ .set({ revokedAt: now })
169
+ .where(and(eq(apiClients.id, id), isNull(apiClients.revokedAt)));
170
+ await db
171
+ .update(apiTokens)
172
+ .set({ revokedAt: now })
173
+ .where(and(eq(apiTokens.clientId, id), isNull(apiTokens.revokedAt)));
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * Exchanges a client id + secret for a bearer token. Returns null for an unknown client, a
179
+ * wrong secret or a revoked client: the caller cannot tell which one failed.
180
+ */
181
+ export async function issueApiToken(
182
+ db: PolicyScopedDatabase,
183
+ input: IssueApiTokenInput,
184
+ ): Promise<IssuedApiToken | null> {
185
+ const clients = await db
186
+ .select({
187
+ id: apiClients.id,
188
+ userId: apiClients.userId,
189
+ secretHash: apiClients.secretHash,
190
+ revokedAt: apiClients.revokedAt,
191
+ })
192
+ .from(apiClients)
193
+ .where(eq(apiClients.clientId, input.clientId))
194
+ .limit(1);
195
+ const client = clients[0];
196
+ if (client === undefined) return null;
197
+ if (client.revokedAt !== null) return null;
198
+
199
+ const presentedHash = await sha256Hex(input.clientSecret);
200
+ if (!hashesEqual(presentedHash, client.secretHash)) return null;
201
+
202
+ const accessToken = `${ACCESS_TOKEN_PREFIX}${randomToken(ACCESS_TOKEN_BYTES)}`;
203
+ const expiresAt = new Date(Date.now() + input.ttlMs);
204
+ await db.insert(apiTokens).values({
205
+ tokenHash: await sha256Hex(accessToken),
206
+ clientId: client.id,
207
+ userId: client.userId,
208
+ expiresAt,
209
+ });
210
+ await db.update(apiClients).set({ lastUsedAt: new Date() }).where(eq(apiClients.id, client.id));
211
+ return { accessToken, expiresAt };
212
+ }
213
+
214
+ /**
215
+ * Validates a raw bearer token: revoked/expired tokens and tokens of a revoked client are
216
+ * rejected. Refreshing `last_used_at` is a conditional UPDATE, so a burst of requests does
217
+ * not turn into a burst of writes or race on the same row.
218
+ */
219
+ export async function validateApiToken(
220
+ db: PolicyScopedDatabase,
221
+ rawToken: string,
222
+ ): Promise<ValidatedApiToken | null> {
223
+ const tokenHash = await sha256Hex(rawToken);
224
+ const rows = await db
225
+ .select({
226
+ id: apiTokens.id,
227
+ userId: apiTokens.userId,
228
+ expiresAt: apiTokens.expiresAt,
229
+ revokedAt: apiTokens.revokedAt,
230
+ role: apiClients.role,
231
+ clientRevokedAt: apiClients.revokedAt,
232
+ })
233
+ .from(apiTokens)
234
+ .innerJoin(apiClients, eq(apiTokens.clientId, apiClients.id))
235
+ .where(eq(apiTokens.tokenHash, tokenHash))
236
+ .limit(1);
237
+
238
+ const found = rows[0];
239
+ if (found === undefined) return null;
240
+ if (found.revokedAt !== null || found.clientRevokedAt !== null) return null;
241
+ if (found.expiresAt.getTime() <= Date.now()) return null;
242
+
243
+ const now = new Date();
244
+ const staleBefore = new Date(now.getTime() - LAST_USED_WRITE_WINDOW_MS);
245
+ await db
246
+ .update(apiTokens)
247
+ .set({ lastUsedAt: now })
248
+ .where(
249
+ and(
250
+ eq(apiTokens.id, found.id),
251
+ or(isNull(apiTokens.lastUsedAt), lt(apiTokens.lastUsedAt, staleBefore)),
252
+ ),
253
+ );
254
+
255
+ return { userId: found.userId, role: found.role, tokenHash };
256
+ }
257
+
258
+ /** Constant-time comparison of two hex digests (both fixed-length SHA-256 output). */
259
+ function hashesEqual(left: string, right: string): boolean {
260
+ return constantTimeEqual(new TextEncoder().encode(left), new TextEncoder().encode(right));
261
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Runtime-agnostic WebCrypto access. @facilis.dev/auth runs inside a browser WebContainer too,
3
+ * so it uses the standard `globalThis.crypto` surface and never `node:crypto`.
4
+ */
5
+
6
+ export interface SubtleCryptoLike {
7
+ importKey(
8
+ format: "raw",
9
+ keyData: Uint8Array,
10
+ algorithm: "PBKDF2",
11
+ extractable: boolean,
12
+ keyUsages: readonly string[],
13
+ ): Promise<unknown>;
14
+ deriveBits(
15
+ algorithm: { name: "PBKDF2"; hash: "SHA-256"; salt: Uint8Array; iterations: number },
16
+ baseKey: unknown,
17
+ length: number,
18
+ ): Promise<ArrayBuffer>;
19
+ digest(algorithm: "SHA-256", data: Uint8Array): Promise<ArrayBuffer>;
20
+ }
21
+
22
+ export interface WebCryptoLike {
23
+ readonly subtle: SubtleCryptoLike;
24
+ getRandomValues<T extends Uint8Array>(array: T): T;
25
+ }
26
+
27
+ export function webcrypto(): WebCryptoLike {
28
+ const crypto = (globalThis as unknown as { crypto?: WebCryptoLike }).crypto;
29
+ if (crypto === undefined) throw new Error("WebCrypto is unavailable in this runtime");
30
+ return crypto;
31
+ }
32
+
33
+ export function randomBytes(length: number): Uint8Array {
34
+ return webcrypto().getRandomValues(new Uint8Array(length));
35
+ }
36
+
37
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
38
+
39
+ export function encodeBase64(bytes: Uint8Array): string {
40
+ let output = "";
41
+ for (let index = 0; index < bytes.length; index += 3) {
42
+ const first = bytes[index] ?? 0;
43
+ const second = bytes[index + 1];
44
+ const third = bytes[index + 2];
45
+ const triple = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
46
+ output += BASE64_ALPHABET[(triple >> 18) & 63] ?? "";
47
+ output += BASE64_ALPHABET[(triple >> 12) & 63] ?? "";
48
+ output += second === undefined ? "=" : (BASE64_ALPHABET[(triple >> 6) & 63] ?? "");
49
+ output += third === undefined ? "=" : (BASE64_ALPHABET[triple & 63] ?? "");
50
+ }
51
+ return output;
52
+ }
53
+
54
+ export function decodeBase64(value: string): Uint8Array | null {
55
+ if (value.length % 4 !== 0) return null;
56
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
57
+ const bytes: number[] = [];
58
+
59
+ for (let index = 0; index < value.length; index += 4) {
60
+ const chunk = value.slice(index, index + 4);
61
+ const symbols = [...chunk].map((character, offset) => {
62
+ if (character === "=" && offset >= 4 - padding) return 0;
63
+ return BASE64_ALPHABET.indexOf(character);
64
+ });
65
+ if (symbols.some((symbol) => symbol < 0)) return null;
66
+ const [first = 0, second = 0, third = 0, fourth = 0] = symbols;
67
+ const triple = (first << 18) | (second << 12) | (third << 6) | fourth;
68
+ bytes.push((triple >> 16) & 255, (triple >> 8) & 255, triple & 255);
69
+ }
70
+
71
+ const paddingBytes = padding === 0 ? [] : bytes.slice(-padding);
72
+ if (paddingBytes.some((byte) => byte !== 0)) return null;
73
+ return new Uint8Array(padding === 0 ? bytes : bytes.slice(0, -padding));
74
+ }
75
+
76
+ /** URL-safe random token (cookie-friendly, no padding). */
77
+ export function randomToken(byteLength: number): string {
78
+ return encodeBase64(randomBytes(byteLength))
79
+ .replaceAll("+", "-")
80
+ .replaceAll("/", "_")
81
+ .replaceAll("=", "");
82
+ }
83
+
84
+ /**
85
+ * SHA-256 of a string as lowercase hex. WebCrypto only, so the session-hash path also runs
86
+ * inside the browser WebContainer; the raw value is never stored or logged.
87
+ */
88
+ export async function sha256Hex(value: string): Promise<string> {
89
+ const digest = await webcrypto().subtle.digest("SHA-256", new TextEncoder().encode(value));
90
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
91
+ }
92
+
93
+ /** Compares secrets without leaking the index of the first mismatch. */
94
+ export function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
95
+ if (left.length !== right.length) return false;
96
+ let difference = 0;
97
+ for (let index = 0; index < left.length; index += 1) {
98
+ difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
99
+ }
100
+ return difference === 0;
101
+ }
@@ -0,0 +1,107 @@
1
+ import { eq } from "drizzle-orm";
2
+
3
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
4
+
5
+ import { randomToken, sha256Hex } from "./crypto.ts";
6
+ import { authEmailVerifications, users } from "./tables.ts";
7
+
8
+ /**
9
+ * Email verification for password accounts (D-156).
10
+ *
11
+ * The primitive is split from delivery on purpose: `@facilis.dev/auth` owns the single-use token and
12
+ * the `users.email_verified_at` transition, while the project (or the blueprint) owns how the
13
+ * link reaches the mailbox. Every call runs inside the caller's audited `ctx.system` elevation,
14
+ * because the verification route is reached before a session exists (same D-099 shape as
15
+ * registration).
16
+ *
17
+ * Tokens are stored as `sha256(token)`: a tenant-scoped read of the table yields no usable link,
18
+ * exactly like sessions (D-144) and SSO state (D-120).
19
+ */
20
+
21
+ export const EMAIL_VERIFICATION_TABLES = ["auth_email_verifications", "users"] as const;
22
+
23
+ export interface IssueEmailVerificationOptions {
24
+ /** Link lifetime (`auth.verification.tokenTtlMs`); always validated by the caller's config. */
25
+ readonly ttlMs: number;
26
+ /** Injectable clock for tests; defaults to the wall clock. */
27
+ readonly now?: Date;
28
+ }
29
+
30
+ export interface IssuedEmailVerification {
31
+ /** The raw token: only the delivered link carries it, never the database or the logs. */
32
+ readonly token: string;
33
+ readonly expiresAt: Date;
34
+ }
35
+
36
+ /**
37
+ * Rotates the user's outstanding link and returns the new token. A single active token per user
38
+ * keeps the table small and makes "the latest email wins" true: re-registering or resending
39
+ * invalidates the previous link.
40
+ */
41
+ export async function issueEmailVerification(
42
+ db: PolicyScopedDatabase,
43
+ userId: string,
44
+ options: IssueEmailVerificationOptions,
45
+ ): Promise<IssuedEmailVerification> {
46
+ const now = options.now ?? new Date();
47
+ const expiresAt = new Date(now.getTime() + options.ttlMs);
48
+ const token = randomToken(32);
49
+
50
+ await db.delete(authEmailVerifications).where(eq(authEmailVerifications.userId, userId));
51
+ await db.insert(authEmailVerifications).values({
52
+ id: await sha256Hex(token),
53
+ userId,
54
+ createdAt: now,
55
+ expiresAt,
56
+ });
57
+
58
+ return { token, expiresAt };
59
+ }
60
+
61
+ /**
62
+ * Consumes a verification link: marks the user verified and returns the user id, or `null` for an
63
+ * unknown/expired token. The row is deleted before the expiry check, so a token is single-use even
64
+ * when it is already stale.
65
+ */
66
+ export async function consumeEmailVerification(
67
+ db: PolicyScopedDatabase,
68
+ token: string,
69
+ now: Date = new Date(),
70
+ ): Promise<{ userId: string } | null> {
71
+ const id = await sha256Hex(token);
72
+ const rows = await db
73
+ .select({ userId: authEmailVerifications.userId, expiresAt: authEmailVerifications.expiresAt })
74
+ .from(authEmailVerifications)
75
+ .where(eq(authEmailVerifications.id, id))
76
+ .limit(1);
77
+ const row = rows[0];
78
+ if (row === undefined) return null;
79
+
80
+ await db.delete(authEmailVerifications).where(eq(authEmailVerifications.id, id));
81
+ if (row.expiresAt.getTime() <= now.getTime()) return null;
82
+
83
+ const updated = await db
84
+ .update(users)
85
+ .set({ emailVerifiedAt: now })
86
+ .where(eq(users.id, row.userId))
87
+ .returning({ id: users.id });
88
+ return updated[0] === undefined ? null : { userId: updated[0].id };
89
+ }
90
+
91
+ /**
92
+ * Marks the account verified without a link. Used by the blueprint's development fallback (no
93
+ * delivery channel configured in `development`/`test`, D-146) and by tests; production always
94
+ * goes through the token.
95
+ */
96
+ export async function markEmailVerified(
97
+ db: PolicyScopedDatabase,
98
+ userId: string,
99
+ now: Date = new Date(),
100
+ ): Promise<boolean> {
101
+ const updated = await db
102
+ .update(users)
103
+ .set({ emailVerifiedAt: now })
104
+ .where(eq(users.id, userId))
105
+ .returning({ id: users.id });
106
+ return updated[0] !== undefined;
107
+ }
package/src/email.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Canonical email form (D-150). Registration, login, SSO linking and the bootstrap comparison
3
+ * all go through this function: the stored value, the unique `lower(email)` index and every
4
+ * lookup then agree on one identity, so a case variant can never create a second account or
5
+ * be mistaken for another one during the initial-admin promotion.
6
+ */
7
+ export function normalizeEmail(email: string): string {
8
+ return email.trim().toLowerCase();
9
+ }