@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 facilis-dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # @facilis.dev/auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Facilis authentication and RBAC primitives: sessions, role expansion, permission checks.
4
+
5
+ Part of [Facilis](https://github.com/facilis-dev/facilis), a browser-first low-code framework: the Studio
6
+ runs a generated project in a WebContainer where vite dev is the only dev server
7
+ and Nitro v3 runs as a Vite plugin. Framework contracts live in
8
+ [docs/SPEC.md](https://github.com/facilis-dev/facilis/blob/main/docs/SPEC.md) and architectural decisions in
9
+ [docs/DECISIONS.md](https://github.com/facilis-dev/facilis/blob/main/docs/DECISIONS.md).
10
+
11
+ ```sh
12
+ npm install @facilis.dev/auth
13
+ ```
14
+
15
+ The package publishes its TypeScript sources and its compiled dist/; the exports map
16
+ of the published manifest points at dist/, so Node and bundlers load JavaScript and
17
+ the bundled .d.ts files.
18
+
19
+ Licensed under the MIT License.
@@ -0,0 +1,71 @@
1
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
2
+ export interface CreateApiClientInput {
3
+ readonly name: string;
4
+ readonly role: string;
5
+ /** Interactive user who created the client, when the caller knows it. */
6
+ readonly createdBy?: string | null;
7
+ }
8
+ export interface CreatedApiClient {
9
+ readonly id: string;
10
+ readonly clientId: string;
11
+ /** One-time plaintext secret: never persisted and not recoverable afterwards. */
12
+ readonly secret: string;
13
+ readonly name: string;
14
+ readonly role: string;
15
+ readonly userId: string;
16
+ }
17
+ /** Management projection of a client: same columns minus `secretHash`, which never leaves. */
18
+ export interface ApiClientSummary {
19
+ readonly id: string;
20
+ readonly clientId: string;
21
+ readonly name: string;
22
+ readonly role: string;
23
+ readonly createdAt: Date | null;
24
+ readonly revokedAt: Date | null;
25
+ readonly lastUsedAt: Date | null;
26
+ readonly userId: string;
27
+ }
28
+ export interface IssueApiTokenInput {
29
+ readonly clientId: string;
30
+ readonly clientSecret: string;
31
+ readonly ttlMs: number;
32
+ }
33
+ export interface IssuedApiToken {
34
+ readonly accessToken: string;
35
+ readonly expiresAt: Date;
36
+ }
37
+ export interface ValidatedApiToken {
38
+ readonly userId: string;
39
+ /** The client's fixed role, not the service user's (empty) interactive roles. */
40
+ readonly role: string;
41
+ /** SHA-256 of the raw token: the audit correlation value, safe to log. */
42
+ readonly tokenHash: string;
43
+ }
44
+ /**
45
+ * Creates a service user, its role assignment and an API client.
46
+ *
47
+ * Callers run this under the audited system identity: the `roles` table is system-only
48
+ * (D-136) and here it is the migration-time projection of `config.auth.roles`
49
+ * (`seedFromConfig`), so a role the project never declared is rejected before any row is
50
+ * written. The service user takes its tenant from the policy-scope GUC, exactly like a
51
+ * registration insert (D-040).
52
+ */
53
+ export declare function createApiClient(db: PolicyScopedDatabase, input: CreateApiClientInput): Promise<CreatedApiClient>;
54
+ /** Lists clients for management; the secret hash is never part of the projection. */
55
+ export declare function listApiClients(db: PolicyScopedDatabase): Promise<ApiClientSummary[]>;
56
+ /**
57
+ * Revokes a client and every token issued to it. Returns false for an unknown id; revoking
58
+ * twice is a no-op that keeps the first revocation timestamp (it is an audit fact).
59
+ */
60
+ export declare function revokeApiClient(db: PolicyScopedDatabase, id: string): Promise<boolean>;
61
+ /**
62
+ * Exchanges a client id + secret for a bearer token. Returns null for an unknown client, a
63
+ * wrong secret or a revoked client: the caller cannot tell which one failed.
64
+ */
65
+ export declare function issueApiToken(db: PolicyScopedDatabase, input: IssueApiTokenInput): Promise<IssuedApiToken | null>;
66
+ /**
67
+ * Validates a raw bearer token: revoked/expired tokens and tokens of a revoked client are
68
+ * rejected. Refreshing `last_used_at` is a conditional UPDATE, so a burst of requests does
69
+ * not turn into a burst of writes or race on the same row.
70
+ */
71
+ export declare function validateApiToken(db: PolicyScopedDatabase, rawToken: string): Promise<ValidatedApiToken | null>;
@@ -0,0 +1,186 @@
1
+ import { and, eq, isNull, lt, or } from "drizzle-orm";
2
+ import { constantTimeEqual, randomToken, sha256Hex } from "./crypto.js";
3
+ import { apiClients, apiTokens, roles, userRoles, users } from "./tables.js";
4
+ /**
5
+ * Programmatic API clients (V3a): a service user plus a hashed secret, and short-lived
6
+ * bearer tokens derived from it.
7
+ *
8
+ * Nothing secret is ever stored in the clear: the client secret and every access token are
9
+ * written as SHA-256 hex digests, and the plaintext appears only in the return value of the
10
+ * call that creates it (a one-time secret, like a password reset). Secrets are compared
11
+ * through `constantTimeEqual` so verification does not leak the first mismatching byte.
12
+ */
13
+ /** Public credential prefixes: a leaked value is recognisable to secret scanners. */
14
+ const CLIENT_ID_PREFIX = "fcli_";
15
+ const CLIENT_SECRET_PREFIX = "fsec_";
16
+ const ACCESS_TOKEN_PREFIX = "fat_";
17
+ const CLIENT_ID_BYTES = 24;
18
+ const CLIENT_SECRET_BYTES = 32;
19
+ const ACCESS_TOKEN_BYTES = 32;
20
+ /**
21
+ * A successful validation stamps `last_used_at` at most once per window instead of on every
22
+ * request: the column is for operators ("is this client still used?"), not an access log.
23
+ */
24
+ const LAST_USED_WRITE_WINDOW_MS = 5 * 60 * 1000;
25
+ /**
26
+ * Creates a service user, its role assignment and an API client.
27
+ *
28
+ * Callers run this under the audited system identity: the `roles` table is system-only
29
+ * (D-136) and here it is the migration-time projection of `config.auth.roles`
30
+ * (`seedFromConfig`), so a role the project never declared is rejected before any row is
31
+ * written. The service user takes its tenant from the policy-scope GUC, exactly like a
32
+ * registration insert (D-040).
33
+ */
34
+ export async function createApiClient(db, input) {
35
+ const knownRole = await db
36
+ .select({ name: roles.name })
37
+ .from(roles)
38
+ .where(eq(roles.name, input.role))
39
+ .limit(1);
40
+ if (knownRole.length === 0)
41
+ throw new Error(`createApiClient: unknown role "${input.role}"`);
42
+ const createdUser = await db
43
+ .insert(users)
44
+ .values({
45
+ email: `service+${globalThis.crypto.randomUUID()}@facilis.invalid`,
46
+ passwordHash: null,
47
+ isService: true,
48
+ })
49
+ .returning({ id: users.id });
50
+ const user = createdUser[0];
51
+ if (user === undefined)
52
+ throw new Error("createApiClient: service user insert returned no row");
53
+ await db.insert(userRoles).values({
54
+ userId: user.id,
55
+ role: input.role,
56
+ assignedBy: input.createdBy ?? null,
57
+ });
58
+ const clientId = `${CLIENT_ID_PREFIX}${randomToken(CLIENT_ID_BYTES)}`;
59
+ const secret = `${CLIENT_SECRET_PREFIX}${randomToken(CLIENT_SECRET_BYTES)}`;
60
+ const createdClient = await db
61
+ .insert(apiClients)
62
+ .values({
63
+ clientId,
64
+ secretHash: await sha256Hex(secret),
65
+ name: input.name,
66
+ role: input.role,
67
+ userId: user.id,
68
+ createdBy: input.createdBy ?? null,
69
+ })
70
+ .returning({ id: apiClients.id });
71
+ const client = createdClient[0];
72
+ if (client === undefined)
73
+ throw new Error("createApiClient: api client insert returned no row");
74
+ return { id: client.id, clientId, secret, name: input.name, role: input.role, userId: user.id };
75
+ }
76
+ /** Lists clients for management; the secret hash is never part of the projection. */
77
+ export async function listApiClients(db) {
78
+ return db
79
+ .select({
80
+ id: apiClients.id,
81
+ clientId: apiClients.clientId,
82
+ name: apiClients.name,
83
+ role: apiClients.role,
84
+ createdAt: apiClients.createdAt,
85
+ revokedAt: apiClients.revokedAt,
86
+ lastUsedAt: apiClients.lastUsedAt,
87
+ userId: apiClients.userId,
88
+ })
89
+ .from(apiClients);
90
+ }
91
+ /**
92
+ * Revokes a client and every token issued to it. Returns false for an unknown id; revoking
93
+ * twice is a no-op that keeps the first revocation timestamp (it is an audit fact).
94
+ */
95
+ export async function revokeApiClient(db, id) {
96
+ const found = await db
97
+ .select({ id: apiClients.id })
98
+ .from(apiClients)
99
+ .where(eq(apiClients.id, id))
100
+ .limit(1);
101
+ if (found.length === 0)
102
+ return false;
103
+ const now = new Date();
104
+ await db
105
+ .update(apiClients)
106
+ .set({ revokedAt: now })
107
+ .where(and(eq(apiClients.id, id), isNull(apiClients.revokedAt)));
108
+ await db
109
+ .update(apiTokens)
110
+ .set({ revokedAt: now })
111
+ .where(and(eq(apiTokens.clientId, id), isNull(apiTokens.revokedAt)));
112
+ return true;
113
+ }
114
+ /**
115
+ * Exchanges a client id + secret for a bearer token. Returns null for an unknown client, a
116
+ * wrong secret or a revoked client: the caller cannot tell which one failed.
117
+ */
118
+ export async function issueApiToken(db, input) {
119
+ const clients = await db
120
+ .select({
121
+ id: apiClients.id,
122
+ userId: apiClients.userId,
123
+ secretHash: apiClients.secretHash,
124
+ revokedAt: apiClients.revokedAt,
125
+ })
126
+ .from(apiClients)
127
+ .where(eq(apiClients.clientId, input.clientId))
128
+ .limit(1);
129
+ const client = clients[0];
130
+ if (client === undefined)
131
+ return null;
132
+ if (client.revokedAt !== null)
133
+ return null;
134
+ const presentedHash = await sha256Hex(input.clientSecret);
135
+ if (!hashesEqual(presentedHash, client.secretHash))
136
+ return null;
137
+ const accessToken = `${ACCESS_TOKEN_PREFIX}${randomToken(ACCESS_TOKEN_BYTES)}`;
138
+ const expiresAt = new Date(Date.now() + input.ttlMs);
139
+ await db.insert(apiTokens).values({
140
+ tokenHash: await sha256Hex(accessToken),
141
+ clientId: client.id,
142
+ userId: client.userId,
143
+ expiresAt,
144
+ });
145
+ await db.update(apiClients).set({ lastUsedAt: new Date() }).where(eq(apiClients.id, client.id));
146
+ return { accessToken, expiresAt };
147
+ }
148
+ /**
149
+ * Validates a raw bearer token: revoked/expired tokens and tokens of a revoked client are
150
+ * rejected. Refreshing `last_used_at` is a conditional UPDATE, so a burst of requests does
151
+ * not turn into a burst of writes or race on the same row.
152
+ */
153
+ export async function validateApiToken(db, rawToken) {
154
+ const tokenHash = await sha256Hex(rawToken);
155
+ const rows = await db
156
+ .select({
157
+ id: apiTokens.id,
158
+ userId: apiTokens.userId,
159
+ expiresAt: apiTokens.expiresAt,
160
+ revokedAt: apiTokens.revokedAt,
161
+ role: apiClients.role,
162
+ clientRevokedAt: apiClients.revokedAt,
163
+ })
164
+ .from(apiTokens)
165
+ .innerJoin(apiClients, eq(apiTokens.clientId, apiClients.id))
166
+ .where(eq(apiTokens.tokenHash, tokenHash))
167
+ .limit(1);
168
+ const found = rows[0];
169
+ if (found === undefined)
170
+ return null;
171
+ if (found.revokedAt !== null || found.clientRevokedAt !== null)
172
+ return null;
173
+ if (found.expiresAt.getTime() <= Date.now())
174
+ return null;
175
+ const now = new Date();
176
+ const staleBefore = new Date(now.getTime() - LAST_USED_WRITE_WINDOW_MS);
177
+ await db
178
+ .update(apiTokens)
179
+ .set({ lastUsedAt: now })
180
+ .where(and(eq(apiTokens.id, found.id), or(isNull(apiTokens.lastUsedAt), lt(apiTokens.lastUsedAt, staleBefore))));
181
+ return { userId: found.userId, role: found.role, tokenHash };
182
+ }
183
+ /** Constant-time comparison of two hex digests (both fixed-length SHA-256 output). */
184
+ function hashesEqual(left, right) {
185
+ return constantTimeEqual(new TextEncoder().encode(left), new TextEncoder().encode(right));
186
+ }
@@ -0,0 +1,31 @@
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
+ export interface SubtleCryptoLike {
6
+ importKey(format: "raw", keyData: Uint8Array, algorithm: "PBKDF2", extractable: boolean, keyUsages: readonly string[]): Promise<unknown>;
7
+ deriveBits(algorithm: {
8
+ name: "PBKDF2";
9
+ hash: "SHA-256";
10
+ salt: Uint8Array;
11
+ iterations: number;
12
+ }, baseKey: unknown, length: number): Promise<ArrayBuffer>;
13
+ digest(algorithm: "SHA-256", data: Uint8Array): Promise<ArrayBuffer>;
14
+ }
15
+ export interface WebCryptoLike {
16
+ readonly subtle: SubtleCryptoLike;
17
+ getRandomValues<T extends Uint8Array>(array: T): T;
18
+ }
19
+ export declare function webcrypto(): WebCryptoLike;
20
+ export declare function randomBytes(length: number): Uint8Array;
21
+ export declare function encodeBase64(bytes: Uint8Array): string;
22
+ export declare function decodeBase64(value: string): Uint8Array | null;
23
+ /** URL-safe random token (cookie-friendly, no padding). */
24
+ export declare function randomToken(byteLength: number): string;
25
+ /**
26
+ * SHA-256 of a string as lowercase hex. WebCrypto only, so the session-hash path also runs
27
+ * inside the browser WebContainer; the raw value is never stored or logged.
28
+ */
29
+ export declare function sha256Hex(value: string): Promise<string>;
30
+ /** Compares secrets without leaking the index of the first mismatch. */
31
+ export declare function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean;
package/dist/crypto.js ADDED
@@ -0,0 +1,76 @@
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
+ export function webcrypto() {
6
+ const crypto = globalThis.crypto;
7
+ if (crypto === undefined)
8
+ throw new Error("WebCrypto is unavailable in this runtime");
9
+ return crypto;
10
+ }
11
+ export function randomBytes(length) {
12
+ return webcrypto().getRandomValues(new Uint8Array(length));
13
+ }
14
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
15
+ export function encodeBase64(bytes) {
16
+ let output = "";
17
+ for (let index = 0; index < bytes.length; index += 3) {
18
+ const first = bytes[index] ?? 0;
19
+ const second = bytes[index + 1];
20
+ const third = bytes[index + 2];
21
+ const triple = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
22
+ output += BASE64_ALPHABET[(triple >> 18) & 63] ?? "";
23
+ output += BASE64_ALPHABET[(triple >> 12) & 63] ?? "";
24
+ output += second === undefined ? "=" : (BASE64_ALPHABET[(triple >> 6) & 63] ?? "");
25
+ output += third === undefined ? "=" : (BASE64_ALPHABET[triple & 63] ?? "");
26
+ }
27
+ return output;
28
+ }
29
+ export function decodeBase64(value) {
30
+ if (value.length % 4 !== 0)
31
+ return null;
32
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
33
+ const bytes = [];
34
+ for (let index = 0; index < value.length; index += 4) {
35
+ const chunk = value.slice(index, index + 4);
36
+ const symbols = [...chunk].map((character, offset) => {
37
+ if (character === "=" && offset >= 4 - padding)
38
+ return 0;
39
+ return BASE64_ALPHABET.indexOf(character);
40
+ });
41
+ if (symbols.some((symbol) => symbol < 0))
42
+ return null;
43
+ const [first = 0, second = 0, third = 0, fourth = 0] = symbols;
44
+ const triple = (first << 18) | (second << 12) | (third << 6) | fourth;
45
+ bytes.push((triple >> 16) & 255, (triple >> 8) & 255, triple & 255);
46
+ }
47
+ const paddingBytes = padding === 0 ? [] : bytes.slice(-padding);
48
+ if (paddingBytes.some((byte) => byte !== 0))
49
+ return null;
50
+ return new Uint8Array(padding === 0 ? bytes : bytes.slice(0, -padding));
51
+ }
52
+ /** URL-safe random token (cookie-friendly, no padding). */
53
+ export function randomToken(byteLength) {
54
+ return encodeBase64(randomBytes(byteLength))
55
+ .replaceAll("+", "-")
56
+ .replaceAll("/", "_")
57
+ .replaceAll("=", "");
58
+ }
59
+ /**
60
+ * SHA-256 of a string as lowercase hex. WebCrypto only, so the session-hash path also runs
61
+ * inside the browser WebContainer; the raw value is never stored or logged.
62
+ */
63
+ export async function sha256Hex(value) {
64
+ const digest = await webcrypto().subtle.digest("SHA-256", new TextEncoder().encode(value));
65
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
66
+ }
67
+ /** Compares secrets without leaking the index of the first mismatch. */
68
+ export function constantTimeEqual(left, right) {
69
+ if (left.length !== right.length)
70
+ return false;
71
+ let difference = 0;
72
+ for (let index = 0; index < left.length; index += 1) {
73
+ difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
74
+ }
75
+ return difference === 0;
76
+ }
@@ -0,0 +1,45 @@
1
+ import type { PolicyScopedDatabase } from "@facilis.dev/db";
2
+ /**
3
+ * Email verification for password accounts (D-156).
4
+ *
5
+ * The primitive is split from delivery on purpose: `@facilis.dev/auth` owns the single-use token and
6
+ * the `users.email_verified_at` transition, while the project (or the blueprint) owns how the
7
+ * link reaches the mailbox. Every call runs inside the caller's audited `ctx.system` elevation,
8
+ * because the verification route is reached before a session exists (same D-099 shape as
9
+ * registration).
10
+ *
11
+ * Tokens are stored as `sha256(token)`: a tenant-scoped read of the table yields no usable link,
12
+ * exactly like sessions (D-144) and SSO state (D-120).
13
+ */
14
+ export declare const EMAIL_VERIFICATION_TABLES: readonly ["auth_email_verifications", "users"];
15
+ export interface IssueEmailVerificationOptions {
16
+ /** Link lifetime (`auth.verification.tokenTtlMs`); always validated by the caller's config. */
17
+ readonly ttlMs: number;
18
+ /** Injectable clock for tests; defaults to the wall clock. */
19
+ readonly now?: Date;
20
+ }
21
+ export interface IssuedEmailVerification {
22
+ /** The raw token: only the delivered link carries it, never the database or the logs. */
23
+ readonly token: string;
24
+ readonly expiresAt: Date;
25
+ }
26
+ /**
27
+ * Rotates the user's outstanding link and returns the new token. A single active token per user
28
+ * keeps the table small and makes "the latest email wins" true: re-registering or resending
29
+ * invalidates the previous link.
30
+ */
31
+ export declare function issueEmailVerification(db: PolicyScopedDatabase, userId: string, options: IssueEmailVerificationOptions): Promise<IssuedEmailVerification>;
32
+ /**
33
+ * Consumes a verification link: marks the user verified and returns the user id, or `null` for an
34
+ * unknown/expired token. The row is deleted before the expiry check, so a token is single-use even
35
+ * when it is already stale.
36
+ */
37
+ export declare function consumeEmailVerification(db: PolicyScopedDatabase, token: string, now?: Date): Promise<{
38
+ userId: string;
39
+ } | null>;
40
+ /**
41
+ * Marks the account verified without a link. Used by the blueprint's development fallback (no
42
+ * delivery channel configured in `development`/`test`, D-146) and by tests; production always
43
+ * goes through the token.
44
+ */
45
+ export declare function markEmailVerified(db: PolicyScopedDatabase, userId: string, now?: Date): Promise<boolean>;
@@ -0,0 +1,72 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { randomToken, sha256Hex } from "./crypto.js";
3
+ import { authEmailVerifications, users } from "./tables.js";
4
+ /**
5
+ * Email verification for password accounts (D-156).
6
+ *
7
+ * The primitive is split from delivery on purpose: `@facilis.dev/auth` owns the single-use token and
8
+ * the `users.email_verified_at` transition, while the project (or the blueprint) owns how the
9
+ * link reaches the mailbox. Every call runs inside the caller's audited `ctx.system` elevation,
10
+ * because the verification route is reached before a session exists (same D-099 shape as
11
+ * registration).
12
+ *
13
+ * Tokens are stored as `sha256(token)`: a tenant-scoped read of the table yields no usable link,
14
+ * exactly like sessions (D-144) and SSO state (D-120).
15
+ */
16
+ export const EMAIL_VERIFICATION_TABLES = ["auth_email_verifications", "users"];
17
+ /**
18
+ * Rotates the user's outstanding link and returns the new token. A single active token per user
19
+ * keeps the table small and makes "the latest email wins" true: re-registering or resending
20
+ * invalidates the previous link.
21
+ */
22
+ export async function issueEmailVerification(db, userId, options) {
23
+ const now = options.now ?? new Date();
24
+ const expiresAt = new Date(now.getTime() + options.ttlMs);
25
+ const token = randomToken(32);
26
+ await db.delete(authEmailVerifications).where(eq(authEmailVerifications.userId, userId));
27
+ await db.insert(authEmailVerifications).values({
28
+ id: await sha256Hex(token),
29
+ userId,
30
+ createdAt: now,
31
+ expiresAt,
32
+ });
33
+ return { token, expiresAt };
34
+ }
35
+ /**
36
+ * Consumes a verification link: marks the user verified and returns the user id, or `null` for an
37
+ * unknown/expired token. The row is deleted before the expiry check, so a token is single-use even
38
+ * when it is already stale.
39
+ */
40
+ export async function consumeEmailVerification(db, token, now = new Date()) {
41
+ const id = await sha256Hex(token);
42
+ const rows = await db
43
+ .select({ userId: authEmailVerifications.userId, expiresAt: authEmailVerifications.expiresAt })
44
+ .from(authEmailVerifications)
45
+ .where(eq(authEmailVerifications.id, id))
46
+ .limit(1);
47
+ const row = rows[0];
48
+ if (row === undefined)
49
+ return null;
50
+ await db.delete(authEmailVerifications).where(eq(authEmailVerifications.id, id));
51
+ if (row.expiresAt.getTime() <= now.getTime())
52
+ return null;
53
+ const updated = await db
54
+ .update(users)
55
+ .set({ emailVerifiedAt: now })
56
+ .where(eq(users.id, row.userId))
57
+ .returning({ id: users.id });
58
+ return updated[0] === undefined ? null : { userId: updated[0].id };
59
+ }
60
+ /**
61
+ * Marks the account verified without a link. Used by the blueprint's development fallback (no
62
+ * delivery channel configured in `development`/`test`, D-146) and by tests; production always
63
+ * goes through the token.
64
+ */
65
+ export async function markEmailVerified(db, userId, now = new Date()) {
66
+ const updated = await db
67
+ .update(users)
68
+ .set({ emailVerifiedAt: now })
69
+ .where(eq(users.id, userId))
70
+ .returning({ id: users.id });
71
+ return updated[0] !== undefined;
72
+ }
@@ -0,0 +1,7 @@
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 declare function normalizeEmail(email: string): string;
package/dist/email.js 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) {
8
+ return email.trim().toLowerCase();
9
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @facilis.dev/auth — auth tables, RBAC expansion, password hashing, sessions and seeding.
3
+ */
4
+ export { apiClients, apiTokens, authEmailVerifications, rolePermissions, roles, sessions, userRoles, users, type ApiClientRow, type ApiTokenRow, type AuthUser, type EmailVerificationRow, type NewEmailVerificationRow, type NewUserRow, type RolePermissionRow, type RoleRow, type SessionRow, type UserRoleRow, type UserRow, } from "./tables.js";
5
+ export { createApiClient, issueApiToken, listApiClients, revokeApiClient, validateApiToken, type ApiClientSummary, type CreateApiClientInput, type CreatedApiClient, type IssuedApiToken, type IssueApiTokenInput, type ValidatedApiToken, } from "./api-clients.js";
6
+ export { normalizeEmail } from "./email.js";
7
+ export { expandRoles, hasPermission, type AuthConfig, type AuthContext } from "./roles.js";
8
+ export { consumeEmailVerification, EMAIL_VERIFICATION_TABLES, issueEmailVerification, markEmailVerified, type IssuedEmailVerification, type IssueEmailVerificationOptions, } from "./email-verification.js";
9
+ export { hashPassword, verifyPassword } from "./password.js";
10
+ export { createSession, destroySession, SESSION_COOKIE_NAME, sessionMiddleware, validateSession, type CreateSessionOptions, type SessionEvent, type SessionEventContext, type SessionMiddlewareDeps, type ValidatedSession, } from "./session.js";
11
+ export { registerInitialAdmin, seedFromConfig, seedStatements, type SeedEntry } from "./seed.js";
12
+ export * from "./sso/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @facilis.dev/auth — auth tables, RBAC expansion, password hashing, sessions and seeding.
3
+ */
4
+ export { apiClients, apiTokens, authEmailVerifications, rolePermissions, roles, sessions, userRoles, users, } from "./tables.js";
5
+ export { createApiClient, issueApiToken, listApiClients, revokeApiClient, validateApiToken, } from "./api-clients.js";
6
+ export { normalizeEmail } from "./email.js";
7
+ export { expandRoles, hasPermission } from "./roles.js";
8
+ export { consumeEmailVerification, EMAIL_VERIFICATION_TABLES, issueEmailVerification, markEmailVerified, } from "./email-verification.js";
9
+ export { hashPassword, verifyPassword } from "./password.js";
10
+ export { createSession, destroySession, SESSION_COOKIE_NAME, sessionMiddleware, validateSession, } from "./session.js";
11
+ export { registerInitialAdmin, seedFromConfig, seedStatements } from "./seed.js";
12
+ export * from "./sso/index.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Hashes a password with PBKDF2-SHA256 (WebCrypto). Encoding is versioned and
3
+ * self-describing: `pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>`.
4
+ */
5
+ export declare function hashPassword(password: string): Promise<string>;
6
+ /** Constant-time verification; malformed or unsupported hashes return false, never throw. */
7
+ export declare function verifyPassword(hash: string, password: string): Promise<boolean>;
@@ -0,0 +1,54 @@
1
+ import { constantTimeEqual, decodeBase64, encodeBase64, randomBytes, webcrypto } from "./crypto.js";
2
+ const ALGORITHM = "pbkdf2-sha256";
3
+ const DEFAULT_ITERATIONS = 600_000;
4
+ const MAX_ITERATIONS = 1_000_000;
5
+ const SALT_BYTES = 16;
6
+ const KEY_BITS = 256;
7
+ const KEY_BYTES = KEY_BITS / 8;
8
+ /**
9
+ * Hashes a password with PBKDF2-SHA256 (WebCrypto). Encoding is versioned and
10
+ * self-describing: `pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>`.
11
+ */
12
+ export async function hashPassword(password) {
13
+ const salt = randomBytes(SALT_BYTES);
14
+ const hash = await deriveKey(password, salt, DEFAULT_ITERATIONS);
15
+ return [ALGORITHM, String(DEFAULT_ITERATIONS), encodeBase64(salt), encodeBase64(hash)].join("$");
16
+ }
17
+ /** Constant-time verification; malformed or unsupported hashes return false, never throw. */
18
+ export async function verifyPassword(hash, password) {
19
+ const parsed = parsePasswordHash(hash);
20
+ if (parsed === null)
21
+ return false;
22
+ const candidate = await deriveKey(password, parsed.salt, parsed.iterations);
23
+ return constantTimeEqual(candidate, parsed.hash);
24
+ }
25
+ function parsePasswordHash(value) {
26
+ const parts = value.split("$");
27
+ if (parts.length !== 4)
28
+ return null;
29
+ const [algorithm, iterationsText, saltText, hashText] = parts;
30
+ if (algorithm !== ALGORITHM ||
31
+ iterationsText === undefined ||
32
+ saltText === undefined ||
33
+ hashText === undefined ||
34
+ !/^[1-9][0-9]*$/.test(iterationsText)) {
35
+ return null;
36
+ }
37
+ const iterations = Number(iterationsText);
38
+ if (iterations > MAX_ITERATIONS)
39
+ return null;
40
+ const salt = decodeBase64(saltText);
41
+ const hash = decodeBase64(hashText);
42
+ if (salt === null || hash === null || salt.length !== SALT_BYTES || hash.length !== KEY_BYTES) {
43
+ return null;
44
+ }
45
+ return { iterations, salt, hash };
46
+ }
47
+ async function deriveKey(password, salt, iterations) {
48
+ const { subtle } = webcrypto();
49
+ const key = await subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [
50
+ "deriveBits",
51
+ ]);
52
+ const bits = await subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, KEY_BITS);
53
+ return new Uint8Array(bits);
54
+ }