@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.
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/api-clients.d.ts +71 -0
- package/dist/api-clients.js +186 -0
- package/dist/crypto.d.ts +31 -0
- package/dist/crypto.js +76 -0
- package/dist/email-verification.d.ts +45 -0
- package/dist/email-verification.js +72 -0
- package/dist/email.d.ts +7 -0
- package/dist/email.js +9 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/password.d.ts +7 -0
- package/dist/password.js +54 -0
- package/dist/roles.d.ts +40 -0
- package/dist/roles.js +46 -0
- package/dist/seed.d.ts +28 -0
- package/dist/seed.js +115 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.js +161 -0
- package/dist/sso/contracts.d.ts +52 -0
- package/dist/sso/contracts.js +8 -0
- package/dist/sso/errors.d.ts +22 -0
- package/dist/sso/errors.js +45 -0
- package/dist/sso/identity.d.ts +41 -0
- package/dist/sso/identity.js +162 -0
- package/dist/sso/index.d.ts +27 -0
- package/dist/sso/index.js +16 -0
- package/dist/sso/login.d.ts +60 -0
- package/dist/sso/login.js +209 -0
- package/dist/sso/oauth2.d.ts +52 -0
- package/dist/sso/oauth2.js +180 -0
- package/dist/sso/oidc.d.ts +60 -0
- package/dist/sso/oidc.js +201 -0
- package/dist/sso/paths.d.ts +14 -0
- package/dist/sso/paths.js +17 -0
- package/dist/sso/pkce.d.ts +6 -0
- package/dist/sso/pkce.js +21 -0
- package/dist/sso/providers.d.ts +100 -0
- package/dist/sso/providers.js +70 -0
- package/dist/sso/saml.d.ts +44 -0
- package/dist/sso/saml.js +159 -0
- package/dist/sso/state.d.ts +34 -0
- package/dist/sso/state.js +91 -0
- package/dist/sso/tables.d.ts +306 -0
- package/dist/sso/tables.js +59 -0
- package/dist/tables.d.ts +769 -0
- package/dist/tables.js +148 -0
- package/package.json +45 -4
- package/src/api-clients.ts +261 -0
- package/src/crypto.ts +101 -0
- package/src/email-verification.ts +107 -0
- package/src/email.ts +9 -0
- package/src/index.ts +63 -0
- package/src/password.ts +74 -0
- package/src/roles.ts +77 -0
- package/src/seed.ts +146 -0
- package/src/session.ts +209 -0
- package/src/sso/contracts.ts +71 -0
- package/src/sso/errors.ts +53 -0
- package/src/sso/identity.ts +220 -0
- package/src/sso/index.ts +105 -0
- package/src/sso/login.ts +296 -0
- package/src/sso/oauth2.ts +235 -0
- package/src/sso/oidc.ts +285 -0
- package/src/sso/paths.ts +21 -0
- package/src/sso/pkce.ts +30 -0
- package/src/sso/providers.ts +176 -0
- package/src/sso/saml.ts +222 -0
- package/src/sso/state.ts +114 -0
- package/src/sso/tables.ts +77 -0
- package/src/tables.ts +214 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|