@facilis.dev/auth 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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
|
-
#
|
|
1
|
+
# @facilis.dev/auth
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/email.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
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, 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>;
|
package/dist/password.js
ADDED
|
@@ -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
|
+
}
|