@pramen/server 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/auth.js +4 -0
- package/dist/durable-object.d.ts +7 -0
- package/dist/durable-object.js +44 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/runtime/kv.d.ts +11 -0
- package/dist/runtime/kv.js +37 -0
- package/dist/sdk/acl.d.ts +5 -0
- package/dist/worker.js +12 -1
- package/package.json +1 -1
- package/src/auth.ts +3 -0
- package/src/durable-object.ts +46 -4
- package/src/index.ts +3 -0
- package/src/runtime/kv.ts +43 -0
- package/src/sdk/acl.ts +5 -0
- package/src/worker.ts +13 -1
package/dist/auth.js
CHANGED
|
@@ -175,6 +175,10 @@ function toIdentity(claims) {
|
|
|
175
175
|
? [claims.role]
|
|
176
176
|
: [];
|
|
177
177
|
const identity = { roles, userId: (claims.sub ?? claims.userId) };
|
|
178
|
+
// Carry `exp` (a STANDARD claim, so the passthrough loop skips it) so a WebSocket can
|
|
179
|
+
// re-check expiry per message — its identity is fixed at upgrade and never re-verified.
|
|
180
|
+
if (typeof claims.exp === "number")
|
|
181
|
+
identity.exp = claims.exp;
|
|
178
182
|
for (const [k, v] of Object.entries(claims)) {
|
|
179
183
|
if (!STANDARD_CLAIMS.has(k))
|
|
180
184
|
identity[k] = v; // carry custom claims (tier, …)
|
package/dist/durable-object.d.ts
CHANGED
|
@@ -79,6 +79,13 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
79
79
|
private get envBag();
|
|
80
80
|
private filesFor;
|
|
81
81
|
private identityOf;
|
|
82
|
+
/** Has this socket's token expired? `exp` (epoch seconds) rides the socket attachment
|
|
83
|
+
* from the upgrade-time identity. A synthetic / non-expiring identity has no `exp` and is
|
|
84
|
+
* never expired, so callPrivileged and admin sockets keep working. */
|
|
85
|
+
private isExpired;
|
|
86
|
+
/** Reject a message from an expired socket: send the protocol's auth-failure error frame,
|
|
87
|
+
* then close 4401 (the client should re-authenticate and reconnect). */
|
|
88
|
+
private rejectExpired;
|
|
82
89
|
/** The durable per-socket auth/routing state (identity + tenant + partition), read
|
|
83
90
|
* from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
|
|
84
91
|
private getAttachment;
|
package/dist/durable-object.js
CHANGED
|
@@ -24,13 +24,16 @@ import { Db } from "./runtime/db";
|
|
|
24
24
|
import { digest } from "./runtime/digest";
|
|
25
25
|
import { compileAcl } from "./runtime/acl";
|
|
26
26
|
import { DoSqliteDriver } from "./runtime/driver";
|
|
27
|
-
import { BadRequest, toResponse, toWsError } from "./runtime/errors";
|
|
27
|
+
import { BadRequest, Unauthorized, toResponse, toWsError } from "./runtime/errors";
|
|
28
28
|
import { Kv } from "./runtime/kv";
|
|
29
29
|
import { registryKey } from "./runtime/registry";
|
|
30
30
|
import { DEFAULT_PARTITION } from "./sdk/schema";
|
|
31
31
|
import { createFiles, R2Adapter } from "./runtime/storage";
|
|
32
32
|
/** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
|
|
33
33
|
const MAX_SUBSCRIPTIONS = 64;
|
|
34
|
+
/** WebSocket close code for an auth failure (RFC 6455 leaves 4000-4999 to the app;
|
|
35
|
+
* 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
|
|
36
|
+
const WS_CLOSE_UNAUTHORIZED = 4401;
|
|
34
37
|
export class PramenDOBase extends DurableObject {
|
|
35
38
|
app;
|
|
36
39
|
acl;
|
|
@@ -276,9 +279,15 @@ export class PramenDOBase extends DurableObject {
|
|
|
276
279
|
// socket's (tenant, partition) — fixed at connect time, survives via the attachment
|
|
277
280
|
// — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
|
|
278
281
|
// (the `migrated` flag), so a no-op after the first call.
|
|
279
|
-
const
|
|
280
|
-
this.tenant = tenant;
|
|
281
|
-
this.partition = partition;
|
|
282
|
+
const att = this.getAttachment(ws);
|
|
283
|
+
this.tenant = att.tenant;
|
|
284
|
+
this.partition = att.partition;
|
|
285
|
+
// The token was verified ONCE at upgrade; a live/hibernating socket can outlive its
|
|
286
|
+
// TTL. Re-check `exp` per message so an expired session can't keep calling/subscribing
|
|
287
|
+
// (role changes + the denylist only bite on reconnect, which this forces). Fail the
|
|
288
|
+
// frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
|
|
289
|
+
if (this.isExpired(att.identity))
|
|
290
|
+
return this.rejectExpired(ws, msg.id);
|
|
282
291
|
await this.ensureMigrated();
|
|
283
292
|
switch (msg.type) {
|
|
284
293
|
case "subscribe":
|
|
@@ -355,6 +364,19 @@ export class PramenDOBase extends DurableObject {
|
|
|
355
364
|
for (const ws of this.ctx.getWebSockets()) {
|
|
356
365
|
try {
|
|
357
366
|
const att = this.getAttachment(ws);
|
|
367
|
+
// A live socket whose token expired while connected must not keep receiving pushes.
|
|
368
|
+
// Close it 4401 and drop its subscriptions instead of pushing (an expired hibernating
|
|
369
|
+
// socket already has no in-memory subs, so this only bites still-live ones).
|
|
370
|
+
if (this.isExpired(att.identity)) {
|
|
371
|
+
try {
|
|
372
|
+
ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
/* already closing */
|
|
376
|
+
}
|
|
377
|
+
this.subsBySocket.delete(ws);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
358
380
|
const subs = this.getSubs(ws);
|
|
359
381
|
let dirty = false;
|
|
360
382
|
for (const sub of subs) {
|
|
@@ -537,6 +559,24 @@ export class PramenDOBase extends DurableObject {
|
|
|
537
559
|
return null;
|
|
538
560
|
}
|
|
539
561
|
}
|
|
562
|
+
/** Has this socket's token expired? `exp` (epoch seconds) rides the socket attachment
|
|
563
|
+
* from the upgrade-time identity. A synthetic / non-expiring identity has no `exp` and is
|
|
564
|
+
* never expired, so callPrivileged and admin sockets keep working. */
|
|
565
|
+
isExpired(identity) {
|
|
566
|
+
const exp = identity?.exp;
|
|
567
|
+
return typeof exp === "number" && Date.now() / 1000 >= exp;
|
|
568
|
+
}
|
|
569
|
+
/** Reject a message from an expired socket: send the protocol's auth-failure error frame,
|
|
570
|
+
* then close 4401 (the client should re-authenticate and reconnect). */
|
|
571
|
+
rejectExpired(ws, id) {
|
|
572
|
+
this.send(ws, toWsError(id, new Unauthorized("session expired")));
|
|
573
|
+
try {
|
|
574
|
+
ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
/* already closing */
|
|
578
|
+
}
|
|
579
|
+
}
|
|
540
580
|
/** The durable per-socket auth/routing state (identity + tenant + partition), read
|
|
541
581
|
* from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
|
|
542
582
|
getAttachment(ws) {
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Han
|
|
|
8
8
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
9
9
|
export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
|
|
10
10
|
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
|
|
11
|
+
export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
|
|
11
12
|
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
12
13
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
13
14
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,8 @@ export { createApp } from "./sdk/app";
|
|
|
14
14
|
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
15
15
|
// --- ACL ---
|
|
16
16
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
17
|
+
// --- kv (ctx.kv) + session denylist (hard token revocation) ---
|
|
18
|
+
export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
|
|
17
19
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
18
20
|
// --- mail (ctx.mail) ---
|
|
19
21
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
package/dist/runtime/kv.d.ts
CHANGED
|
@@ -21,3 +21,14 @@ export declare class Kv {
|
|
|
21
21
|
cursor: string | null;
|
|
22
22
|
}>;
|
|
23
23
|
}
|
|
24
|
+
/** Revoke every outstanding token for `username` for up to `ttlSeconds` (the session
|
|
25
|
+
* TTL). Called when an account is deactivated / deleted. The entry self-expires, so the
|
|
26
|
+
* denylist never accumulates beyond the current revocation window. */
|
|
27
|
+
export declare function denySession(kv: Kv, username: string, ttlSeconds: number): Promise<void>;
|
|
28
|
+
/** Lift a prior `denySession` (e.g. on reactivation). The key is username-scoped, so
|
|
29
|
+
* without this a reactivated account would stay locked out — even for a fresh login —
|
|
30
|
+
* until the denylist entry expired on its own. */
|
|
31
|
+
export declare function allowSession(kv: Kv, username: string): Promise<void>;
|
|
32
|
+
/** Is there a live denylist entry for `username`? The Worker consults this per
|
|
33
|
+
* authenticated request (only when a `sub` is present) to fail a revoked token closed. */
|
|
34
|
+
export declare function isSessionDenied(kv: Kv, username: string): Promise<boolean>;
|
package/dist/runtime/kv.js
CHANGED
|
@@ -39,3 +39,40 @@ export class Kv {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
// --- session denylist (hard token revocation) -------------------------------
|
|
43
|
+
//
|
|
44
|
+
// The core is stateless verify-only: roles/active are baked into a token at login and
|
|
45
|
+
// there is no per-request DB lookup. To revoke a token BEFORE its `exp` (deactivate /
|
|
46
|
+
// delete / compromise), we keep a tiny KV denylist keyed by username (= the JWT `sub`).
|
|
47
|
+
// The Worker checks it right after resolving identity — a denied `sub` fails closed
|
|
48
|
+
// (401), never silently downgrades to anonymous.
|
|
49
|
+
//
|
|
50
|
+
// The entry carries an `expirationTtl` equal to the session TTL, so it self-expires
|
|
51
|
+
// exactly when the last token that could have been outstanding at revocation time does:
|
|
52
|
+
// the list can only ever hold recently-revoked users and never grows unbounded. The key
|
|
53
|
+
// is username-scoped (not per-token), so it blocks EVERY token for that user — including
|
|
54
|
+
// a fresh login — which is why reactivation must lift it (see `allowSession`).
|
|
55
|
+
/** App-relative key (the `Kv` facade adds its own `app:` prefix). Writer (auth) and
|
|
56
|
+
* reader (Worker) both route through these helpers so the namespacing always agrees. */
|
|
57
|
+
const denyKey = (username) => `authDenied:${username}`;
|
|
58
|
+
/** Cloudflare KV rejects an `expirationTtl` below 60s — clamp so a short session TTL
|
|
59
|
+
* still produces a valid (if slightly over-long) denylist entry. */
|
|
60
|
+
const MIN_KV_TTL_SECONDS = 60;
|
|
61
|
+
/** Revoke every outstanding token for `username` for up to `ttlSeconds` (the session
|
|
62
|
+
* TTL). Called when an account is deactivated / deleted. The entry self-expires, so the
|
|
63
|
+
* denylist never accumulates beyond the current revocation window. */
|
|
64
|
+
export async function denySession(kv, username, ttlSeconds) {
|
|
65
|
+
const ttl = Math.max(MIN_KV_TTL_SECONDS, Math.trunc(ttlSeconds) || MIN_KV_TTL_SECONDS);
|
|
66
|
+
await kv.put(denyKey(username), "1", { expirationTtl: ttl });
|
|
67
|
+
}
|
|
68
|
+
/** Lift a prior `denySession` (e.g. on reactivation). The key is username-scoped, so
|
|
69
|
+
* without this a reactivated account would stay locked out — even for a fresh login —
|
|
70
|
+
* until the denylist entry expired on its own. */
|
|
71
|
+
export async function allowSession(kv, username) {
|
|
72
|
+
await kv.delete(denyKey(username));
|
|
73
|
+
}
|
|
74
|
+
/** Is there a live denylist entry for `username`? The Worker consults this per
|
|
75
|
+
* authenticated request (only when a `sub` is present) to fail a revoked token closed. */
|
|
76
|
+
export async function isSessionDenied(kv, username) {
|
|
77
|
+
return (await kv.get(denyKey(username))) != null;
|
|
78
|
+
}
|
package/dist/sdk/acl.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ export type Action = "read" | "create" | "update" | "delete";
|
|
|
3
3
|
export interface Identity {
|
|
4
4
|
role?: string;
|
|
5
5
|
roles?: string[];
|
|
6
|
+
/** The verified token's `exp` (epoch seconds), when present. Carried so a long-lived
|
|
7
|
+
* WebSocket — whose identity is fixed at upgrade and never re-verified — can enforce
|
|
8
|
+
* expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
|
|
9
|
+
* (callPrivileged) identities, which are therefore never treated as expired. */
|
|
10
|
+
exp?: number;
|
|
6
11
|
[key: string]: unknown;
|
|
7
12
|
}
|
|
8
13
|
declare const IDENTITY_MARKER: unique symbol;
|
package/dist/worker.js
CHANGED
|
@@ -14,7 +14,7 @@ import { compileAcl } from "./runtime/acl";
|
|
|
14
14
|
import { Db } from "./runtime/db";
|
|
15
15
|
import { D1Driver } from "./runtime/driver";
|
|
16
16
|
import { toResponse } from "./runtime/errors";
|
|
17
|
-
import { Kv } from "./runtime/kv";
|
|
17
|
+
import { Kv, isSessionDenied } from "./runtime/kv";
|
|
18
18
|
import { listDOs, partitionDoName } from "./runtime/registry";
|
|
19
19
|
import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
|
|
20
20
|
import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
|
|
@@ -245,6 +245,17 @@ export function makeWorker(app) {
|
|
|
245
245
|
req = new Request(request, { headers: h });
|
|
246
246
|
}
|
|
247
247
|
const identity = await resolveIdentity(req, strategyFor(env));
|
|
248
|
+
// Hard revocation (deactivate / delete / compromise), independent of token TTL: a
|
|
249
|
+
// revoked `sub` is on the KV denylist (written by @pramen/auth's setUserActive(false)
|
|
250
|
+
// / deleteUser, self-expiring at the session TTL). Check it HERE, at identity-resolution
|
|
251
|
+
// time — before the DO proxy AND the D1 store path, and before the admin routes — so it
|
|
252
|
+
// covers HTTP and the WebSocket upgrade alike. A denied token fails CLOSED (401); we do
|
|
253
|
+
// NOT silently downgrade to anonymous — a revoked user should see a clear auth failure.
|
|
254
|
+
// Synthetic identities (callPrivileged) never pass through here, so they're unaffected.
|
|
255
|
+
const sub = typeof identity?.userId === "string" ? identity.userId : undefined;
|
|
256
|
+
if (sub && (await isSessionDenied(new Kv(env.KV), sub))) {
|
|
257
|
+
return withCors(json({ ok: false, error: "session revoked", code: "unauthorized" }, 401), cors);
|
|
258
|
+
}
|
|
248
259
|
// --- admin: list known (tenant, partition) DOs from the registry ---
|
|
249
260
|
if (url.pathname === "/tenants") {
|
|
250
261
|
if (!isAdmin(identity))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.39",
|
|
4
4
|
"description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/auth.ts
CHANGED
|
@@ -226,6 +226,9 @@ function toIdentity(claims: Record<string, unknown>): Identity {
|
|
|
226
226
|
? [claims.role]
|
|
227
227
|
: [];
|
|
228
228
|
const identity: Identity = { roles, userId: (claims.sub ?? claims.userId) as string | undefined };
|
|
229
|
+
// Carry `exp` (a STANDARD claim, so the passthrough loop skips it) so a WebSocket can
|
|
230
|
+
// re-check expiry per message — its identity is fixed at upgrade and never re-verified.
|
|
231
|
+
if (typeof claims.exp === "number") identity.exp = claims.exp;
|
|
229
232
|
for (const [k, v] of Object.entries(claims)) {
|
|
230
233
|
if (!STANDARD_CLAIMS.has(k)) identity[k] = v; // carry custom claims (tier, …)
|
|
231
234
|
}
|
package/src/durable-object.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { Db } from "./runtime/db";
|
|
|
25
25
|
import { digest } from "./runtime/digest";
|
|
26
26
|
import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
|
|
27
27
|
import { DoSqliteDriver, type Driver } from "./runtime/driver";
|
|
28
|
-
import { BadRequest, toResponse, toWsError } from "./runtime/errors";
|
|
28
|
+
import { BadRequest, Unauthorized, toResponse, toWsError } from "./runtime/errors";
|
|
29
29
|
import { Kv } from "./runtime/kv";
|
|
30
30
|
import { registryKey } from "./runtime/registry";
|
|
31
31
|
import { DEFAULT_PARTITION } from "./sdk/schema";
|
|
@@ -65,6 +65,10 @@ export interface DoEnv {
|
|
|
65
65
|
/** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
|
|
66
66
|
const MAX_SUBSCRIPTIONS = 64;
|
|
67
67
|
|
|
68
|
+
/** WebSocket close code for an auth failure (RFC 6455 leaves 4000-4999 to the app;
|
|
69
|
+
* 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
|
|
70
|
+
const WS_CLOSE_UNAUTHORIZED = 4401;
|
|
71
|
+
|
|
68
72
|
export class PramenDOBase extends DurableObject<DoEnv> {
|
|
69
73
|
private readonly app: PramenApp;
|
|
70
74
|
private readonly acl: CompiledAcl;
|
|
@@ -336,9 +340,16 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
336
340
|
// socket's (tenant, partition) — fixed at connect time, survives via the attachment
|
|
337
341
|
// — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
|
|
338
342
|
// (the `migrated` flag), so a no-op after the first call.
|
|
339
|
-
const
|
|
340
|
-
this.tenant = tenant;
|
|
341
|
-
this.partition = partition;
|
|
343
|
+
const att = this.getAttachment(ws);
|
|
344
|
+
this.tenant = att.tenant;
|
|
345
|
+
this.partition = att.partition;
|
|
346
|
+
|
|
347
|
+
// The token was verified ONCE at upgrade; a live/hibernating socket can outlive its
|
|
348
|
+
// TTL. Re-check `exp` per message so an expired session can't keep calling/subscribing
|
|
349
|
+
// (role changes + the denylist only bite on reconnect, which this forces). Fail the
|
|
350
|
+
// frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
|
|
351
|
+
if (this.isExpired(att.identity)) return this.rejectExpired(ws, msg.id);
|
|
352
|
+
|
|
342
353
|
await this.ensureMigrated();
|
|
343
354
|
|
|
344
355
|
switch (msg.type) {
|
|
@@ -417,6 +428,18 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
417
428
|
for (const ws of this.ctx.getWebSockets()) {
|
|
418
429
|
try {
|
|
419
430
|
const att = this.getAttachment(ws);
|
|
431
|
+
// A live socket whose token expired while connected must not keep receiving pushes.
|
|
432
|
+
// Close it 4401 and drop its subscriptions instead of pushing (an expired hibernating
|
|
433
|
+
// socket already has no in-memory subs, so this only bites still-live ones).
|
|
434
|
+
if (this.isExpired(att.identity)) {
|
|
435
|
+
try {
|
|
436
|
+
ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
|
|
437
|
+
} catch {
|
|
438
|
+
/* already closing */
|
|
439
|
+
}
|
|
440
|
+
this.subsBySocket.delete(ws);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
420
443
|
const subs = this.getSubs(ws);
|
|
421
444
|
let dirty = false;
|
|
422
445
|
for (const sub of subs) {
|
|
@@ -604,6 +627,25 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
604
627
|
}
|
|
605
628
|
}
|
|
606
629
|
|
|
630
|
+
/** Has this socket's token expired? `exp` (epoch seconds) rides the socket attachment
|
|
631
|
+
* from the upgrade-time identity. A synthetic / non-expiring identity has no `exp` and is
|
|
632
|
+
* never expired, so callPrivileged and admin sockets keep working. */
|
|
633
|
+
private isExpired(identity: Identity | null): boolean {
|
|
634
|
+
const exp = identity?.exp;
|
|
635
|
+
return typeof exp === "number" && Date.now() / 1000 >= exp;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Reject a message from an expired socket: send the protocol's auth-failure error frame,
|
|
639
|
+
* then close 4401 (the client should re-authenticate and reconnect). */
|
|
640
|
+
private rejectExpired(ws: WebSocket, id: string): void {
|
|
641
|
+
this.send(ws, toWsError(id, new Unauthorized("session expired")));
|
|
642
|
+
try {
|
|
643
|
+
ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
|
|
644
|
+
} catch {
|
|
645
|
+
/* already closing */
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
607
649
|
/** The durable per-socket auth/routing state (identity + tenant + partition), read
|
|
608
650
|
* from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
|
|
609
651
|
private getAttachment(ws: WebSocket): SocketAttachment {
|
package/src/index.ts
CHANGED
|
@@ -71,6 +71,9 @@ export type {
|
|
|
71
71
|
WhereOps,
|
|
72
72
|
} from "./sdk/infer";
|
|
73
73
|
|
|
74
|
+
// --- kv (ctx.kv) + session denylist (hard token revocation) ---
|
|
75
|
+
export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
|
|
76
|
+
|
|
74
77
|
// --- files ---
|
|
75
78
|
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
76
79
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
package/src/runtime/kv.ts
CHANGED
|
@@ -45,3 +45,46 @@ export class Kv {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
// --- session denylist (hard token revocation) -------------------------------
|
|
50
|
+
//
|
|
51
|
+
// The core is stateless verify-only: roles/active are baked into a token at login and
|
|
52
|
+
// there is no per-request DB lookup. To revoke a token BEFORE its `exp` (deactivate /
|
|
53
|
+
// delete / compromise), we keep a tiny KV denylist keyed by username (= the JWT `sub`).
|
|
54
|
+
// The Worker checks it right after resolving identity — a denied `sub` fails closed
|
|
55
|
+
// (401), never silently downgrades to anonymous.
|
|
56
|
+
//
|
|
57
|
+
// The entry carries an `expirationTtl` equal to the session TTL, so it self-expires
|
|
58
|
+
// exactly when the last token that could have been outstanding at revocation time does:
|
|
59
|
+
// the list can only ever hold recently-revoked users and never grows unbounded. The key
|
|
60
|
+
// is username-scoped (not per-token), so it blocks EVERY token for that user — including
|
|
61
|
+
// a fresh login — which is why reactivation must lift it (see `allowSession`).
|
|
62
|
+
|
|
63
|
+
/** App-relative key (the `Kv` facade adds its own `app:` prefix). Writer (auth) and
|
|
64
|
+
* reader (Worker) both route through these helpers so the namespacing always agrees. */
|
|
65
|
+
const denyKey = (username: string): string => `authDenied:${username}`;
|
|
66
|
+
|
|
67
|
+
/** Cloudflare KV rejects an `expirationTtl` below 60s — clamp so a short session TTL
|
|
68
|
+
* still produces a valid (if slightly over-long) denylist entry. */
|
|
69
|
+
const MIN_KV_TTL_SECONDS = 60;
|
|
70
|
+
|
|
71
|
+
/** Revoke every outstanding token for `username` for up to `ttlSeconds` (the session
|
|
72
|
+
* TTL). Called when an account is deactivated / deleted. The entry self-expires, so the
|
|
73
|
+
* denylist never accumulates beyond the current revocation window. */
|
|
74
|
+
export async function denySession(kv: Kv, username: string, ttlSeconds: number): Promise<void> {
|
|
75
|
+
const ttl = Math.max(MIN_KV_TTL_SECONDS, Math.trunc(ttlSeconds) || MIN_KV_TTL_SECONDS);
|
|
76
|
+
await kv.put(denyKey(username), "1", { expirationTtl: ttl });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Lift a prior `denySession` (e.g. on reactivation). The key is username-scoped, so
|
|
80
|
+
* without this a reactivated account would stay locked out — even for a fresh login —
|
|
81
|
+
* until the denylist entry expired on its own. */
|
|
82
|
+
export async function allowSession(kv: Kv, username: string): Promise<void> {
|
|
83
|
+
await kv.delete(denyKey(username));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Is there a live denylist entry for `username`? The Worker consults this per
|
|
87
|
+
* authenticated request (only when a `sub` is present) to fail a revoked token closed. */
|
|
88
|
+
export async function isSessionDenied(kv: Kv, username: string): Promise<boolean> {
|
|
89
|
+
return (await kv.get(denyKey(username))) != null;
|
|
90
|
+
}
|
package/src/sdk/acl.ts
CHANGED
|
@@ -12,6 +12,11 @@ export type Action = "read" | "create" | "update" | "delete";
|
|
|
12
12
|
export interface Identity {
|
|
13
13
|
role?: string;
|
|
14
14
|
roles?: string[];
|
|
15
|
+
/** The verified token's `exp` (epoch seconds), when present. Carried so a long-lived
|
|
16
|
+
* WebSocket — whose identity is fixed at upgrade and never re-verified — can enforce
|
|
17
|
+
* expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
|
|
18
|
+
* (callPrivileged) identities, which are therefore never treated as expired. */
|
|
19
|
+
exp?: number;
|
|
15
20
|
[key: string]: unknown;
|
|
16
21
|
}
|
|
17
22
|
|
package/src/worker.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { compileAcl } from "./runtime/acl";
|
|
|
15
15
|
import { Db } from "./runtime/db";
|
|
16
16
|
import { D1Driver, type D1SessionStart, type Driver } from "./runtime/driver";
|
|
17
17
|
import { toResponse } from "./runtime/errors";
|
|
18
|
-
import { Kv } from "./runtime/kv";
|
|
18
|
+
import { Kv, isSessionDenied } from "./runtime/kv";
|
|
19
19
|
import { listDOs, partitionDoName } from "./runtime/registry";
|
|
20
20
|
import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
|
|
21
21
|
import type { Identity } from "./sdk/acl";
|
|
@@ -312,6 +312,18 @@ export function makeWorker(app: PramenApp) {
|
|
|
312
312
|
|
|
313
313
|
const identity = await resolveIdentity(req, strategyFor(env));
|
|
314
314
|
|
|
315
|
+
// Hard revocation (deactivate / delete / compromise), independent of token TTL: a
|
|
316
|
+
// revoked `sub` is on the KV denylist (written by @pramen/auth's setUserActive(false)
|
|
317
|
+
// / deleteUser, self-expiring at the session TTL). Check it HERE, at identity-resolution
|
|
318
|
+
// time — before the DO proxy AND the D1 store path, and before the admin routes — so it
|
|
319
|
+
// covers HTTP and the WebSocket upgrade alike. A denied token fails CLOSED (401); we do
|
|
320
|
+
// NOT silently downgrade to anonymous — a revoked user should see a clear auth failure.
|
|
321
|
+
// Synthetic identities (callPrivileged) never pass through here, so they're unaffected.
|
|
322
|
+
const sub = typeof identity?.userId === "string" ? identity.userId : undefined;
|
|
323
|
+
if (sub && (await isSessionDenied(new Kv(env.KV), sub))) {
|
|
324
|
+
return withCors(json({ ok: false, error: "session revoked", code: "unauthorized" }, 401), cors);
|
|
325
|
+
}
|
|
326
|
+
|
|
315
327
|
// --- admin: list known (tenant, partition) DOs from the registry ---
|
|
316
328
|
if (url.pathname === "/tenants") {
|
|
317
329
|
if (!isAdmin(identity)) return withCors(forbidden("tenants"), cors);
|