@pramen/auth 0.0.37 → 0.0.39
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/dist/index.d.ts +29 -6
- package/dist/index.js +107 -11
- package/package.json +2 -2
- package/src/index.ts +139 -11
package/dist/index.d.ts
CHANGED
|
@@ -33,12 +33,22 @@ export declare const authSchema: {
|
|
|
33
33
|
}, Record<string, never>>;
|
|
34
34
|
};
|
|
35
35
|
export declare function hashPassword(password: string): Promise<string>;
|
|
36
|
+
/** Verify `password` against a foreign hash `payload` (the stored value with its
|
|
37
|
+
* `<scheme>$` prefix already stripped). May be sync or async; throwing counts as a
|
|
38
|
+
* failed verification, never a 500. */
|
|
39
|
+
export type PasswordVerifier = (password: string, payload: string) => boolean | Promise<boolean>;
|
|
40
|
+
/** Register a verifier for an imported hash scheme. Call once at module scope, before
|
|
41
|
+
* any login can run. `pbkdf2` is built in and cannot be overridden. */
|
|
42
|
+
export declare function registerPasswordVerifier(scheme: string, verify: PasswordVerifier): void;
|
|
43
|
+
/** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
|
|
44
|
+
* after a successful verify. Also true for an unparseable value, which never verifies. */
|
|
45
|
+
export declare function isForeignHash(stored: string): boolean;
|
|
36
46
|
export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
|
|
37
47
|
export declare function signToken(claims: Record<string, unknown>, secret: string, opts?: {
|
|
38
48
|
ttlSeconds?: number;
|
|
39
49
|
}): Promise<string>;
|
|
40
|
-
/** signup / login / me. Roles are assigned server-side (default `["user"]`)
|
|
41
|
-
* client never picks its own roles. Spread into your handler map. */
|
|
50
|
+
/** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
|
|
51
|
+
* — the client never picks its own roles. Spread into your handler map. */
|
|
42
52
|
export declare const authHandlers: {
|
|
43
53
|
signup: import("@pramen/server").Handler<{
|
|
44
54
|
username: string;
|
|
@@ -63,6 +73,13 @@ export declare const authHandlers: {
|
|
|
63
73
|
};
|
|
64
74
|
}>;
|
|
65
75
|
me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
|
|
76
|
+
refreshSession: import("@pramen/server").Handler<unknown, {
|
|
77
|
+
token: string;
|
|
78
|
+
user: {
|
|
79
|
+
username: string;
|
|
80
|
+
roles: string[];
|
|
81
|
+
};
|
|
82
|
+
}>;
|
|
66
83
|
};
|
|
67
84
|
export declare const magicLinkSchema: {
|
|
68
85
|
auth_magic_links: import("@pramen/server").EntityDef<{
|
|
@@ -180,8 +197,11 @@ export declare function createUserHandlers(opts?: {
|
|
|
180
197
|
username: string;
|
|
181
198
|
roles: string[];
|
|
182
199
|
}, Record<string, unknown>>;
|
|
183
|
-
/** Admin: activate / deactivate a user. Deactivating blocks future logins
|
|
184
|
-
*
|
|
200
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins AND
|
|
201
|
+
* refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
|
|
202
|
+
* Worker fails them closed) — so revocation no longer waits out the token TTL. The
|
|
203
|
+
* denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
|
|
204
|
+
* username-scoped, so a stale entry would otherwise lock out even a fresh login). */
|
|
185
205
|
setUserActive: import("@pramen/server").Handler<{
|
|
186
206
|
username: string;
|
|
187
207
|
active: boolean;
|
|
@@ -230,8 +250,11 @@ export declare const userHandlers: {
|
|
|
230
250
|
username: string;
|
|
231
251
|
roles: string[];
|
|
232
252
|
}, Record<string, unknown>>;
|
|
233
|
-
/** Admin: activate / deactivate a user. Deactivating blocks future logins
|
|
234
|
-
*
|
|
253
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins AND
|
|
254
|
+
* refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
|
|
255
|
+
* Worker fails them closed) — so revocation no longer waits out the token TTL. The
|
|
256
|
+
* denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
|
|
257
|
+
* username-scoped, so a stale entry would otherwise lock out even a fresh login). */
|
|
235
258
|
setUserActive: import("@pramen/server").Handler<{
|
|
236
259
|
username: string;
|
|
237
260
|
active: boolean;
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Passwordless magic-link login is also available via createMagicLinkAuth (spread
|
|
16
16
|
// magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
|
|
17
17
|
// the token lifecycle. See createMagicLinkAuth below.
|
|
18
|
-
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
|
|
18
|
+
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized, denySession, allowSession } from "@pramen/server";
|
|
19
19
|
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
20
20
|
export const authSchema = {
|
|
21
21
|
auth_users: Entity((t) => ({
|
|
@@ -84,7 +84,38 @@ function parseStoredHash(stored) {
|
|
|
84
84
|
const hash = algSeg === "sha512" ? "SHA-512" : "SHA-256";
|
|
85
85
|
return { iterations, hash, saltB64, hashB64 };
|
|
86
86
|
}
|
|
87
|
+
const foreignVerifiers = new Map();
|
|
88
|
+
/** Register a verifier for an imported hash scheme. Call once at module scope, before
|
|
89
|
+
* any login can run. `pbkdf2` is built in and cannot be overridden. */
|
|
90
|
+
export function registerPasswordVerifier(scheme, verify) {
|
|
91
|
+
if (!scheme || scheme.includes("$"))
|
|
92
|
+
throw new Error(`invalid hash scheme '${scheme}' (must be non-empty and contain no '$')`);
|
|
93
|
+
if (scheme === "pbkdf2")
|
|
94
|
+
throw new Error("'pbkdf2' is built in and cannot be overridden");
|
|
95
|
+
foreignVerifiers.set(scheme, verify);
|
|
96
|
+
}
|
|
97
|
+
/** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
|
|
98
|
+
* after a successful verify. Also true for an unparseable value, which never verifies. */
|
|
99
|
+
export function isForeignHash(stored) {
|
|
100
|
+
return stored.split("$")[0] !== "pbkdf2";
|
|
101
|
+
}
|
|
87
102
|
export async function verifyPassword(password, stored) {
|
|
103
|
+
const scheme = stored.split("$")[0];
|
|
104
|
+
if (scheme !== "pbkdf2") {
|
|
105
|
+
const verify = foreignVerifiers.get(scheme);
|
|
106
|
+
// Unknown scheme (including the empty passwordHash of a passwordless user) never
|
|
107
|
+
// verifies — same as before this feature existed.
|
|
108
|
+
if (!verify)
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
return await verify(password, stored.slice(scheme.length + 1));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A broken/garbage payload must fail closed, not surface as a 500 that
|
|
115
|
+
// distinguishes it from a wrong password.
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
88
119
|
const parsed = parseStoredHash(stored);
|
|
89
120
|
if (!parsed)
|
|
90
121
|
return false;
|
|
@@ -146,8 +177,35 @@ function parseCreds(raw) {
|
|
|
146
177
|
}
|
|
147
178
|
return { username: o.username, password: o.password, email };
|
|
148
179
|
}
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
180
|
+
/** Build the `refreshSession` handler: an AUTHENTICATED mutation that re-reads the caller's
|
|
181
|
+
* `roles` + `active` from the users table (keyed on `username` = the JWT `sub`) and reissues a
|
|
182
|
+
* fresh token with the configured session TTL — the same `{ token, user }` shape as `login`.
|
|
183
|
+
* Throws Unauthorized if the row is gone or the account is deactivated (the `isActive` helper).
|
|
184
|
+
*
|
|
185
|
+
* Why it exists: the core is stateless verify-only, so roles/active are baked into a token at
|
|
186
|
+
* login. refreshSession lets a client (1) silently refresh at ~half-TTL, so AUTH_SESSION_TTL_SECONDS
|
|
187
|
+
* can be kept SHORT (bounded revocation lag) without logging the user out; and (2) pick up a role
|
|
188
|
+
* GRANT immediately — e.g. right after a subscription checkout flips the role — with no re-login.
|
|
189
|
+
* Shared by password users (`authHandlers`) and magic-link users (`createMagicLinkAuth`): both
|
|
190
|
+
* store the row in the same authSchema-shaped table keyed on the immutable `username`. `ttlOf`
|
|
191
|
+
* supplies each factory's own configured TTL. */
|
|
192
|
+
function buildRefreshSession(ttlOf, table = "auth_users") {
|
|
193
|
+
return mutation(async (ctx) => {
|
|
194
|
+
const userId = requireUserId(ctx);
|
|
195
|
+
const rows = await ctx.db.exec(`SELECT username, roles, active FROM ${table} WHERE username = ? LIMIT 1`, userId);
|
|
196
|
+
const u = rows[0];
|
|
197
|
+
// Gone or deactivated ⇒ no fresh token (mirrors login). The Worker denylist already
|
|
198
|
+
// fails a deactivated user's outstanding token closed; this ensures refresh can't
|
|
199
|
+
// launder a revoked session into a new, longer-lived one either.
|
|
200
|
+
if (!u || !isActive(u.active))
|
|
201
|
+
throw new Unauthorized("session is no longer valid");
|
|
202
|
+
const roles = JSON.parse(String(u.roles));
|
|
203
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: ttlOf(ctx) });
|
|
204
|
+
return { token, user: { username: String(u.username), roles } };
|
|
205
|
+
}, { auth: "authenticated" });
|
|
206
|
+
}
|
|
207
|
+
/** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
|
|
208
|
+
* — the client never picks its own roles. Spread into your handler map. */
|
|
151
209
|
export const authHandlers = {
|
|
152
210
|
// NOTE on username enumeration: signup returns a distinct "username is taken" error,
|
|
153
211
|
// which is an enumeration oracle. This is INHERENT to systems where the username is a
|
|
@@ -198,11 +256,28 @@ export const authHandlers = {
|
|
|
198
256
|
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
199
257
|
if (!isActive(u.active))
|
|
200
258
|
throw new Unauthorized("account is deactivated");
|
|
259
|
+
// Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
|
|
260
|
+
// moment the plaintext is in hand for a user whose hash predates pramen, so rehash
|
|
261
|
+
// to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
|
|
262
|
+
// deactivated account is never rewritten. Best-effort: a failed upgrade must not
|
|
263
|
+
// fail an otherwise valid login — the row simply upgrades on a later attempt.
|
|
264
|
+
if (isForeignHash(String(u.passwordHash))) {
|
|
265
|
+
try {
|
|
266
|
+
await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(input.password), String(u.username));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* keep the legacy hash; the next login retries */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
201
272
|
const roles = JSON.parse(String(u.roles));
|
|
202
273
|
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
203
274
|
return { token, user: { username: String(u.username), roles } };
|
|
204
275
|
}, { input: parseCreds }),
|
|
205
276
|
me: query((ctx) => ctx.identity),
|
|
277
|
+
// Re-read roles/active for the caller and reissue a token at the env-configured session
|
|
278
|
+
// TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
|
|
279
|
+
// the user out, and picks up role grants without re-login. See buildRefreshSession.
|
|
280
|
+
refreshSession: buildRefreshSession(sessionTtlOf),
|
|
206
281
|
};
|
|
207
282
|
// --- magic link (passwordless) login ---------------------------------------
|
|
208
283
|
//
|
|
@@ -291,6 +366,10 @@ export function createMagicLinkAuth(opts) {
|
|
|
291
366
|
const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
|
|
292
367
|
const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
|
|
293
368
|
const handlers = {
|
|
369
|
+
// Silent token refresh for magic-link users (same table, keyed on username). Reissues
|
|
370
|
+
// at this factory's configured session TTL. Shared implementation with authHandlers —
|
|
371
|
+
// when both are spread into one app, either definition serves either user.
|
|
372
|
+
refreshSession: buildRefreshSession(() => sessionTtl),
|
|
294
373
|
/** Admin-only: create a passwordless user with the given roles (defaults if omitted)
|
|
295
374
|
* and email them a fresh magic link. Idempotent — inviting an existing user just
|
|
296
375
|
* resends the link and leaves their roles alone (admin uses setUserRoles for changes).
|
|
@@ -381,12 +460,19 @@ export function createMagicLinkAuth(opts) {
|
|
|
381
460
|
// roles (admin manages everyone; the authenticated user manages only itself). Because
|
|
382
461
|
// the admin read policy restricts `fields`, `passwordHash` is never projected back.
|
|
383
462
|
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
463
|
+
// Roles are baked into the JWT at login, so the core is stateless verify-only. Two
|
|
464
|
+
// mechanisms close the gap that leaves, without a session store:
|
|
465
|
+
// - `refreshSession` (authHandlers / createMagicLinkAuth): an authenticated caller
|
|
466
|
+
// re-reads roles/active and gets a fresh token. A client refreshing at ~half-TTL lets
|
|
467
|
+
// AUTH_SESSION_TTL_SECONDS (default 3600) stay short — bounding how long a stale
|
|
468
|
+
// setUserRoles/setUserActive lingers — and picks up a role GRANT immediately (no re-login).
|
|
469
|
+
// - KV denylist (HARD revocation, independent of TTL): setUserActive(false) and deleteUser
|
|
470
|
+
// write an `authDenied:<username>` entry via `denySession(ctx.kv, …)`; the core Worker
|
|
471
|
+
// checks `isSessionDenied` right after resolving identity and fails a revoked token closed
|
|
472
|
+
// (401) — so a deactivate/delete takes effect on the NEXT request, not the next login. The
|
|
473
|
+
// entry self-expires at the session TTL (the list never grows); reactivation lifts it
|
|
474
|
+
// (`allowSession`). `denySession`/`allowSession`/`isSessionDenied` are exported from
|
|
475
|
+
// @pramen/server so an app can revoke on its own compromise signals too.
|
|
390
476
|
/** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
|
|
391
477
|
function isActive(v) {
|
|
392
478
|
return v == null || Number(v) !== 0;
|
|
@@ -435,8 +521,11 @@ export function createUserHandlers(opts = {}) {
|
|
|
435
521
|
throw new BadRequest("user not found"); // (or out of the caller's update scope)
|
|
436
522
|
return updated;
|
|
437
523
|
}),
|
|
438
|
-
/** Admin: activate / deactivate a user. Deactivating blocks future logins
|
|
439
|
-
*
|
|
524
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins AND
|
|
525
|
+
* refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
|
|
526
|
+
* Worker fails them closed) — so revocation no longer waits out the token TTL. The
|
|
527
|
+
* denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
|
|
528
|
+
* username-scoped, so a stale entry would otherwise lock out even a fresh login). */
|
|
440
529
|
setUserActive: mutation(async (ctx, input) => {
|
|
441
530
|
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
442
531
|
throw new BadRequest("username is required");
|
|
@@ -448,6 +537,11 @@ export function createUserHandlers(opts = {}) {
|
|
|
448
537
|
const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
|
|
449
538
|
if (!updated)
|
|
450
539
|
throw new BadRequest("user not found");
|
|
540
|
+
// KV is not part of the mutation's transaction — do it after the update succeeds.
|
|
541
|
+
if (input.active === false)
|
|
542
|
+
await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
|
|
543
|
+
else
|
|
544
|
+
await allowSession(ctx.kv, input.username);
|
|
451
545
|
return updated;
|
|
452
546
|
}),
|
|
453
547
|
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
@@ -460,6 +554,8 @@ export function createUserHandlers(opts = {}) {
|
|
|
460
554
|
const deleted = await usersDb(ctx).delete(table, input.username);
|
|
461
555
|
if (!deleted)
|
|
462
556
|
throw new BadRequest("user not found");
|
|
557
|
+
// Revoke any outstanding tokens for the now-deleted user (self-expires at the TTL).
|
|
558
|
+
await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
|
|
463
559
|
return { ok: true };
|
|
464
560
|
}),
|
|
465
561
|
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.39",
|
|
4
4
|
"description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pramen/server": "0.0.
|
|
37
|
+
"@pramen/server": "0.0.39"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
|
|
17
17
|
// the token lifecycle. See createMagicLinkAuth below.
|
|
18
18
|
|
|
19
|
-
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
|
|
19
|
+
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized, denySession, allowSession } from "@pramen/server";
|
|
20
20
|
import type { AppTaskMap, HandlerContext, HandlerMap, Policy } from "@pramen/server";
|
|
21
21
|
|
|
22
22
|
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
@@ -94,7 +94,64 @@ function parseStoredHash(stored: string): { iterations: number; hash: string; sa
|
|
|
94
94
|
return { iterations, hash, saltB64, hashB64 };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// --- foreign hash schemes (opt-in, for migrating in from another system) -----
|
|
98
|
+
//
|
|
99
|
+
// Importing users from an existing app means importing hashes that are NOT PBKDF2 —
|
|
100
|
+
// bcrypt from Contember/Rails, `pbkdf2_sha256$` from Django, and so on. Those cannot be
|
|
101
|
+
// converted (that needs the plaintext), so the only alternative would be forcing every
|
|
102
|
+
// user to reset their password.
|
|
103
|
+
//
|
|
104
|
+
// Instead: store the foreign hash under its own scheme prefix (`bcrypt$<payload>`),
|
|
105
|
+
// register a verifier for that scheme, and let login UPGRADE it. On the one successful
|
|
106
|
+
// login where the plaintext is briefly in hand, the row is rehashed to PBKDF2. The
|
|
107
|
+
// scheme deletes itself as users return; nothing has to be migrated ahead of time.
|
|
108
|
+
//
|
|
109
|
+
// pramen deliberately does NOT bundle an implementation. bcrypt needs a pure-JS library
|
|
110
|
+
// (WebCrypto has none) which is real bundle weight, and it is useless to the apps that
|
|
111
|
+
// never import anything — so the app supplies it:
|
|
112
|
+
//
|
|
113
|
+
// import bcrypt from "bcryptjs";
|
|
114
|
+
// registerPasswordVerifier("bcrypt", (password, payload) => bcrypt.compare(password, payload));
|
|
115
|
+
//
|
|
116
|
+
// then import rows with `passwordHash = "bcrypt$" + row.password_hash` (a bcrypt hash is
|
|
117
|
+
// itself `$2b$10$...`, so the stored value reads `bcrypt$$2b$10$...`).
|
|
118
|
+
|
|
119
|
+
/** Verify `password` against a foreign hash `payload` (the stored value with its
|
|
120
|
+
* `<scheme>$` prefix already stripped). May be sync or async; throwing counts as a
|
|
121
|
+
* failed verification, never a 500. */
|
|
122
|
+
export type PasswordVerifier = (password: string, payload: string) => boolean | Promise<boolean>;
|
|
123
|
+
|
|
124
|
+
const foreignVerifiers = new Map<string, PasswordVerifier>();
|
|
125
|
+
|
|
126
|
+
/** Register a verifier for an imported hash scheme. Call once at module scope, before
|
|
127
|
+
* any login can run. `pbkdf2` is built in and cannot be overridden. */
|
|
128
|
+
export function registerPasswordVerifier(scheme: string, verify: PasswordVerifier): void {
|
|
129
|
+
if (!scheme || scheme.includes("$")) throw new Error(`invalid hash scheme '${scheme}' (must be non-empty and contain no '$')`);
|
|
130
|
+
if (scheme === "pbkdf2") throw new Error("'pbkdf2' is built in and cannot be overridden");
|
|
131
|
+
foreignVerifiers.set(scheme, verify);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
|
|
135
|
+
* after a successful verify. Also true for an unparseable value, which never verifies. */
|
|
136
|
+
export function isForeignHash(stored: string): boolean {
|
|
137
|
+
return stored.split("$")[0] !== "pbkdf2";
|
|
138
|
+
}
|
|
139
|
+
|
|
97
140
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
141
|
+
const scheme = stored.split("$")[0];
|
|
142
|
+
if (scheme !== "pbkdf2") {
|
|
143
|
+
const verify = foreignVerifiers.get(scheme);
|
|
144
|
+
// Unknown scheme (including the empty passwordHash of a passwordless user) never
|
|
145
|
+
// verifies — same as before this feature existed.
|
|
146
|
+
if (!verify) return false;
|
|
147
|
+
try {
|
|
148
|
+
return await verify(password, stored.slice(scheme.length + 1));
|
|
149
|
+
} catch {
|
|
150
|
+
// A broken/garbage payload must fail closed, not surface as a 500 that
|
|
151
|
+
// distinguishes it from a wrong password.
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
98
155
|
const parsed = parseStoredHash(stored);
|
|
99
156
|
if (!parsed) return false;
|
|
100
157
|
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
@@ -169,8 +226,38 @@ function parseCreds(raw: unknown): { username: string; password: string; email?:
|
|
|
169
226
|
return { username: o.username, password: o.password, email };
|
|
170
227
|
}
|
|
171
228
|
|
|
172
|
-
/**
|
|
173
|
-
*
|
|
229
|
+
/** Build the `refreshSession` handler: an AUTHENTICATED mutation that re-reads the caller's
|
|
230
|
+
* `roles` + `active` from the users table (keyed on `username` = the JWT `sub`) and reissues a
|
|
231
|
+
* fresh token with the configured session TTL — the same `{ token, user }` shape as `login`.
|
|
232
|
+
* Throws Unauthorized if the row is gone or the account is deactivated (the `isActive` helper).
|
|
233
|
+
*
|
|
234
|
+
* Why it exists: the core is stateless verify-only, so roles/active are baked into a token at
|
|
235
|
+
* login. refreshSession lets a client (1) silently refresh at ~half-TTL, so AUTH_SESSION_TTL_SECONDS
|
|
236
|
+
* can be kept SHORT (bounded revocation lag) without logging the user out; and (2) pick up a role
|
|
237
|
+
* GRANT immediately — e.g. right after a subscription checkout flips the role — with no re-login.
|
|
238
|
+
* Shared by password users (`authHandlers`) and magic-link users (`createMagicLinkAuth`): both
|
|
239
|
+
* store the row in the same authSchema-shaped table keyed on the immutable `username`. `ttlOf`
|
|
240
|
+
* supplies each factory's own configured TTL. */
|
|
241
|
+
function buildRefreshSession(ttlOf: (ctx: HandlerContext) => number, table = "auth_users") {
|
|
242
|
+
return mutation(
|
|
243
|
+
async (ctx) => {
|
|
244
|
+
const userId = requireUserId(ctx);
|
|
245
|
+
const rows = await ctx.db.exec(`SELECT username, roles, active FROM ${table} WHERE username = ? LIMIT 1`, userId);
|
|
246
|
+
const u = rows[0];
|
|
247
|
+
// Gone or deactivated ⇒ no fresh token (mirrors login). The Worker denylist already
|
|
248
|
+
// fails a deactivated user's outstanding token closed; this ensures refresh can't
|
|
249
|
+
// launder a revoked session into a new, longer-lived one either.
|
|
250
|
+
if (!u || !isActive(u.active)) throw new Unauthorized("session is no longer valid");
|
|
251
|
+
const roles = JSON.parse(String(u.roles)) as string[];
|
|
252
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: ttlOf(ctx) });
|
|
253
|
+
return { token, user: { username: String(u.username), roles } };
|
|
254
|
+
},
|
|
255
|
+
{ auth: "authenticated" },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
|
|
260
|
+
* — the client never picks its own roles. Spread into your handler map. */
|
|
174
261
|
export const authHandlers = {
|
|
175
262
|
// NOTE on username enumeration: signup returns a distinct "username is taken" error,
|
|
176
263
|
// which is an enumeration oracle. This is INHERENT to systems where the username is a
|
|
@@ -234,6 +321,22 @@ export const authHandlers = {
|
|
|
234
321
|
// Only after the password verifies (so this can't enumerate accounts): a
|
|
235
322
|
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
236
323
|
if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
|
|
324
|
+
// Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
|
|
325
|
+
// moment the plaintext is in hand for a user whose hash predates pramen, so rehash
|
|
326
|
+
// to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
|
|
327
|
+
// deactivated account is never rewritten. Best-effort: a failed upgrade must not
|
|
328
|
+
// fail an otherwise valid login — the row simply upgrades on a later attempt.
|
|
329
|
+
if (isForeignHash(String(u.passwordHash))) {
|
|
330
|
+
try {
|
|
331
|
+
await ctx.db.exec(
|
|
332
|
+
"UPDATE auth_users SET passwordHash = ? WHERE username = ?",
|
|
333
|
+
await hashPassword(input.password),
|
|
334
|
+
String(u.username),
|
|
335
|
+
);
|
|
336
|
+
} catch {
|
|
337
|
+
/* keep the legacy hash; the next login retries */
|
|
338
|
+
}
|
|
339
|
+
}
|
|
237
340
|
const roles = JSON.parse(String(u.roles)) as string[];
|
|
238
341
|
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
239
342
|
return { token, user: { username: String(u.username), roles } };
|
|
@@ -242,6 +345,11 @@ export const authHandlers = {
|
|
|
242
345
|
),
|
|
243
346
|
|
|
244
347
|
me: query((ctx) => ctx.identity),
|
|
348
|
+
|
|
349
|
+
// Re-read roles/active for the caller and reissue a token at the env-configured session
|
|
350
|
+
// TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
|
|
351
|
+
// the user out, and picks up role grants without re-login. See buildRefreshSession.
|
|
352
|
+
refreshSession: buildRefreshSession(sessionTtlOf),
|
|
245
353
|
};
|
|
246
354
|
|
|
247
355
|
// --- magic link (passwordless) login ---------------------------------------
|
|
@@ -357,6 +465,11 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
|
|
|
357
465
|
const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
|
|
358
466
|
|
|
359
467
|
const handlers: HandlerMap = {
|
|
468
|
+
// Silent token refresh for magic-link users (same table, keyed on username). Reissues
|
|
469
|
+
// at this factory's configured session TTL. Shared implementation with authHandlers —
|
|
470
|
+
// when both are spread into one app, either definition serves either user.
|
|
471
|
+
refreshSession: buildRefreshSession(() => sessionTtl),
|
|
472
|
+
|
|
360
473
|
/** Admin-only: create a passwordless user with the given roles (defaults if omitted)
|
|
361
474
|
* and email them a fresh magic link. Idempotent — inviting an existing user just
|
|
362
475
|
* resends the link and leaves their roles alone (admin uses setUserRoles for changes).
|
|
@@ -489,12 +602,19 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
|
|
|
489
602
|
// roles (admin manages everyone; the authenticated user manages only itself). Because
|
|
490
603
|
// the admin read policy restricts `fields`, `passwordHash` is never projected back.
|
|
491
604
|
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
605
|
+
// Roles are baked into the JWT at login, so the core is stateless verify-only. Two
|
|
606
|
+
// mechanisms close the gap that leaves, without a session store:
|
|
607
|
+
// - `refreshSession` (authHandlers / createMagicLinkAuth): an authenticated caller
|
|
608
|
+
// re-reads roles/active and gets a fresh token. A client refreshing at ~half-TTL lets
|
|
609
|
+
// AUTH_SESSION_TTL_SECONDS (default 3600) stay short — bounding how long a stale
|
|
610
|
+
// setUserRoles/setUserActive lingers — and picks up a role GRANT immediately (no re-login).
|
|
611
|
+
// - KV denylist (HARD revocation, independent of TTL): setUserActive(false) and deleteUser
|
|
612
|
+
// write an `authDenied:<username>` entry via `denySession(ctx.kv, …)`; the core Worker
|
|
613
|
+
// checks `isSessionDenied` right after resolving identity and fails a revoked token closed
|
|
614
|
+
// (401) — so a deactivate/delete takes effect on the NEXT request, not the next login. The
|
|
615
|
+
// entry self-expires at the session TTL (the list never grows); reactivation lifts it
|
|
616
|
+
// (`allowSession`). `denySession`/`allowSession`/`isSessionDenied` are exported from
|
|
617
|
+
// @pramen/server so an app can revoke on its own compromise signals too.
|
|
498
618
|
|
|
499
619
|
/** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
|
|
500
620
|
function isActive(v: unknown): boolean {
|
|
@@ -553,8 +673,11 @@ export function createUserHandlers(opts: { table?: string } = {}) {
|
|
|
553
673
|
return updated;
|
|
554
674
|
}),
|
|
555
675
|
|
|
556
|
-
/** Admin: activate / deactivate a user. Deactivating blocks future logins
|
|
557
|
-
*
|
|
676
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins AND
|
|
677
|
+
* refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
|
|
678
|
+
* Worker fails them closed) — so revocation no longer waits out the token TTL. The
|
|
679
|
+
* denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
|
|
680
|
+
* username-scoped, so a stale entry would otherwise lock out even a fresh login). */
|
|
558
681
|
setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
|
|
559
682
|
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
560
683
|
if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
|
|
@@ -563,6 +686,9 @@ export function createUserHandlers(opts: { table?: string } = {}) {
|
|
|
563
686
|
}
|
|
564
687
|
const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
|
|
565
688
|
if (!updated) throw new BadRequest("user not found");
|
|
689
|
+
// KV is not part of the mutation's transaction — do it after the update succeeds.
|
|
690
|
+
if (input.active === false) await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
|
|
691
|
+
else await allowSession(ctx.kv, input.username);
|
|
566
692
|
return updated;
|
|
567
693
|
}),
|
|
568
694
|
|
|
@@ -573,6 +699,8 @@ export function createUserHandlers(opts: { table?: string } = {}) {
|
|
|
573
699
|
if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
|
|
574
700
|
const deleted = await usersDb(ctx).delete(table, input.username);
|
|
575
701
|
if (!deleted) throw new BadRequest("user not found");
|
|
702
|
+
// Revoke any outstanding tokens for the now-deleted user (self-expires at the TTL).
|
|
703
|
+
await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
|
|
576
704
|
return { ok: true };
|
|
577
705
|
}),
|
|
578
706
|
|