@aooth/auth 0.1.7 → 0.1.8
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/atscript-db.cjs +36 -25
- package/dist/atscript-db.d.cts +28 -22
- package/dist/atscript-db.d.mts +28 -22
- package/dist/atscript-db.mjs +36 -25
- package/dist/authz.cjs +168 -0
- package/dist/authz.d.cts +252 -0
- package/dist/authz.d.mts +252 -0
- package/dist/authz.mjs +161 -0
- package/dist/client.cjs +59 -0
- package/dist/client.d.cts +64 -0
- package/dist/client.d.mts +64 -0
- package/dist/client.mjs +58 -0
- package/dist/clock-Bdsep_1j.mjs +4 -0
- package/dist/clock-BjXa0LXb.d.cts +14 -0
- package/dist/clock-BjXa0LXb.d.mts +14 -0
- package/dist/clock-Bl-H3eqE.cjs +9 -0
- package/dist/index.cjs +294 -65
- package/dist/index.d.cts +166 -44
- package/dist/index.d.mts +166 -44
- package/dist/index.mjs +289 -60
- package/dist/payload-BJjvj8AH.cjs +32 -0
- package/dist/payload-D-DzH5-J.mjs +27 -0
- package/dist/redis.cjs +9 -0
- package/dist/redis.d.cts +8 -7
- package/dist/redis.d.mts +8 -7
- package/dist/redis.mjs +9 -0
- package/dist/store-BG6m6oSJ.d.cts +263 -0
- package/dist/store-BG6m6oSJ.d.mts +263 -0
- package/package.json +28 -10
- package/src/atscript-db/auth-credential.as +16 -10
- package/src/atscript-db/auth-credential.as.d.ts +68 -0
- package/dist/store-B1t8KkfA.d.mts +0 -124
- package/dist/store-untAtWQz.d.cts +0 -124
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_clock = require("./clock-Bl-H3eqE.cjs");
|
|
3
|
+
const require_payload = require("./payload-BJjvj8AH.cjs");
|
|
2
4
|
let node_crypto = require("node:crypto");
|
|
3
5
|
let jose = require("jose");
|
|
4
6
|
//#region src/errors.ts
|
|
@@ -12,6 +14,8 @@ const defaultMessages = {
|
|
|
12
14
|
INVALID_CONFIG: "Invalid auth configuration"
|
|
13
15
|
};
|
|
14
16
|
var AuthError = class extends Error {
|
|
17
|
+
type;
|
|
18
|
+
details;
|
|
15
19
|
name = "AuthError";
|
|
16
20
|
constructor(type, message, details) {
|
|
17
21
|
super(message ?? defaultMessages[type]);
|
|
@@ -20,9 +24,6 @@ var AuthError = class extends Error {
|
|
|
20
24
|
}
|
|
21
25
|
};
|
|
22
26
|
//#endregion
|
|
23
|
-
//#region src/utils/clock.ts
|
|
24
|
-
const defaultClock = { now: () => Date.now() };
|
|
25
|
-
//#endregion
|
|
26
27
|
//#region src/stores/memory.ts
|
|
27
28
|
/**
|
|
28
29
|
* In-memory implementation of CredentialStore using random opaque token IDs.
|
|
@@ -37,7 +38,7 @@ var CredentialStoreMemory = class {
|
|
|
37
38
|
byUser = /* @__PURE__ */ new Map();
|
|
38
39
|
clock;
|
|
39
40
|
constructor(opts) {
|
|
40
|
-
this.clock = opts?.clock ?? defaultClock;
|
|
41
|
+
this.clock = opts?.clock ?? require_clock.defaultClock;
|
|
41
42
|
}
|
|
42
43
|
async persist(state, ttl) {
|
|
43
44
|
const token = (0, node_crypto.randomUUID)();
|
|
@@ -79,6 +80,11 @@ var CredentialStoreMemory = class {
|
|
|
79
80
|
this.states.delete(token);
|
|
80
81
|
this.indexRemove(state.userId, token);
|
|
81
82
|
}
|
|
83
|
+
async touch(token, at) {
|
|
84
|
+
const state = this.states.get(token);
|
|
85
|
+
if (!state) return;
|
|
86
|
+
state.lastSeenAt = at;
|
|
87
|
+
}
|
|
82
88
|
async revokeAllForUser(userId) {
|
|
83
89
|
const tokens = this.byUser.get(userId);
|
|
84
90
|
if (!tokens || tokens.size === 0) return 0;
|
|
@@ -144,7 +150,7 @@ var DenylistStoreMemory = class {
|
|
|
144
150
|
entries = /* @__PURE__ */ new Map();
|
|
145
151
|
clock;
|
|
146
152
|
constructor(opts) {
|
|
147
|
-
this.clock = opts?.clock ?? defaultClock;
|
|
153
|
+
this.clock = opts?.clock ?? require_clock.defaultClock;
|
|
148
154
|
}
|
|
149
155
|
async add(jti, expiresAt) {
|
|
150
156
|
this.entries.set(jti, expiresAt);
|
|
@@ -169,6 +175,13 @@ var DenylistStoreMemory = class {
|
|
|
169
175
|
}
|
|
170
176
|
};
|
|
171
177
|
//#endregion
|
|
178
|
+
//#region src/stores/derive-subkey.ts
|
|
179
|
+
const SUBKEY_SALT = Buffer.from("aooth/credential-store/subkey-v1");
|
|
180
|
+
/** HKDF-SHA256 a stable 32-byte subkey from symmetric key material + a label. */
|
|
181
|
+
function hkdfSubkey(ikm, label) {
|
|
182
|
+
return Buffer.from((0, node_crypto.hkdfSync)("sha256", ikm, SUBKEY_SALT, Buffer.from(label, "utf8"), 32));
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
172
185
|
//#region src/stores/jwt.ts
|
|
173
186
|
const HS_ALGS = new Set([
|
|
174
187
|
"HS256",
|
|
@@ -206,7 +219,7 @@ var CredentialStoreJwt = class {
|
|
|
206
219
|
this.issuer = opts.issuer;
|
|
207
220
|
this.audience = opts.audience;
|
|
208
221
|
this.denylist = opts.denylist;
|
|
209
|
-
this.clock = opts.clock ?? defaultClock;
|
|
222
|
+
this.clock = opts.clock ?? require_clock.defaultClock;
|
|
210
223
|
if (HS_ALGS.has(this.algorithm)) {
|
|
211
224
|
if (opts.secret == null) throw new AuthError("INVALID_CONFIG", `Algorithm ${this.algorithm} requires a 'secret' option`);
|
|
212
225
|
const key = normalizeSecret(opts.secret);
|
|
@@ -227,7 +240,8 @@ var CredentialStoreJwt = class {
|
|
|
227
240
|
expMs: expiresAtMs
|
|
228
241
|
};
|
|
229
242
|
if (state.kind !== void 0) stateClaim.kind = state.kind;
|
|
230
|
-
|
|
243
|
+
const payload = require_payload.credentialPayloadOf(state);
|
|
244
|
+
if (Object.keys(payload).length > 0) stateClaim.payload = payload;
|
|
231
245
|
if (state.metadata !== void 0) stateClaim.metadata = state.metadata;
|
|
232
246
|
if (state.parentCredentialId !== void 0) stateClaim.parentCredentialId = state.parentCredentialId;
|
|
233
247
|
if (state.rotatedAt !== void 0) stateClaim.rotatedAt = state.rotatedAt;
|
|
@@ -273,6 +287,10 @@ var CredentialStoreJwt = class {
|
|
|
273
287
|
this.epochs.set(userId, this.clock.now());
|
|
274
288
|
return 1;
|
|
275
289
|
}
|
|
290
|
+
deriveSubkey(label) {
|
|
291
|
+
if (this.signingKey instanceof Uint8Array) return hkdfSubkey(this.signingKey, label);
|
|
292
|
+
throw new AuthError("INVALID_CONFIG", "deriveSubkey requires a symmetric (HS*) secret; this JWT store uses an asymmetric key — provide an explicit wfStateSecret");
|
|
293
|
+
}
|
|
276
294
|
requireDenylist(op) {
|
|
277
295
|
if (!this.denylist) throw new AuthError("STATELESS_OPERATION_UNSUPPORTED", `${op} requires a denylist on stateless JWT store`);
|
|
278
296
|
return this.denylist;
|
|
@@ -318,10 +336,10 @@ var CredentialStoreJwt = class {
|
|
|
318
336
|
expiresAt
|
|
319
337
|
};
|
|
320
338
|
if (stateClaim.kind !== void 0) out.kind = stateClaim.kind;
|
|
321
|
-
if (stateClaim.claims !== void 0) out.claims = stateClaim.claims;
|
|
322
339
|
if (stateClaim.metadata !== void 0) out.metadata = stateClaim.metadata;
|
|
323
340
|
if (stateClaim.parentCredentialId !== void 0) out.parentCredentialId = stateClaim.parentCredentialId;
|
|
324
341
|
if (stateClaim.rotatedAt !== void 0) out.rotatedAt = stateClaim.rotatedAt;
|
|
342
|
+
if (stateClaim.payload && typeof stateClaim.payload === "object") Object.assign(out, stateClaim.payload);
|
|
325
343
|
return out;
|
|
326
344
|
}
|
|
327
345
|
};
|
|
@@ -377,7 +395,7 @@ var CredentialStoreEncapsulated = class {
|
|
|
377
395
|
if (opts.secret == null) throw new AuthError("INVALID_CONFIG", "CredentialStoreEncapsulated requires a 'secret'");
|
|
378
396
|
this.key = deriveKey(opts.secret);
|
|
379
397
|
this.denylist = opts.denylist;
|
|
380
|
-
this.clock = opts.clock ?? defaultClock;
|
|
398
|
+
this.clock = opts.clock ?? require_clock.defaultClock;
|
|
381
399
|
}
|
|
382
400
|
async persist(state, ttl) {
|
|
383
401
|
const jti = (0, node_crypto.randomUUID)();
|
|
@@ -432,6 +450,9 @@ var CredentialStoreEncapsulated = class {
|
|
|
432
450
|
this.epochs.set(userId, this.clock.now());
|
|
433
451
|
return 1;
|
|
434
452
|
}
|
|
453
|
+
deriveSubkey(label) {
|
|
454
|
+
return hkdfSubkey(this.key, label);
|
|
455
|
+
}
|
|
435
456
|
/**
|
|
436
457
|
* Reject decrypted payloads whose `issuedAt` predates the user's revocation
|
|
437
458
|
* epoch. Same-ms mints (`issuedAt === epoch`) are accepted so a workflow can
|
|
@@ -449,7 +470,7 @@ var CredentialStoreEncapsulated = class {
|
|
|
449
470
|
decrypt(token) {
|
|
450
471
|
try {
|
|
451
472
|
const blob = Buffer.from(token, "base64url");
|
|
452
|
-
if (blob.length <
|
|
473
|
+
if (blob.length < 29) return null;
|
|
453
474
|
const iv = blob.subarray(0, IV_LEN);
|
|
454
475
|
const authTag = blob.subarray(blob.length - TAG_LEN);
|
|
455
476
|
const ciphertext = blob.subarray(IV_LEN, blob.length - TAG_LEN);
|
|
@@ -489,6 +510,30 @@ function fingerprint(token) {
|
|
|
489
510
|
return (0, node_crypto.createHash)("sha256").update(token).digest("hex");
|
|
490
511
|
}
|
|
491
512
|
/**
|
|
513
|
+
* Merge a credential's typed payload under the fixed {@link CredentialState}
|
|
514
|
+
* envelope, asserting the `CredentialState & TPayload` shape in ONE place. The
|
|
515
|
+
* payload is spread first so an envelope field always wins a name clash. Every
|
|
516
|
+
* issue / rotation state builder routes through here so the spread-order
|
|
517
|
+
* invariant and the (irreducible, generic-spread) boundary cast live once.
|
|
518
|
+
*/
|
|
519
|
+
function stateWithPayload(payload, envelope) {
|
|
520
|
+
return {
|
|
521
|
+
...payload,
|
|
522
|
+
...envelope
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Match a session family's semantic kind against a `listSessions({ kind })`
|
|
527
|
+
* filter. No filter ⇒ only ordinary interactive sessions (`"session"`), keeping
|
|
528
|
+
* `cli-session` / `pat` credentials out of the default "active sessions" view.
|
|
529
|
+
* A `"*"` value (a bare string or anywhere in the array) matches every kind.
|
|
530
|
+
*/
|
|
531
|
+
function matchesSessionKind(kind, filter) {
|
|
532
|
+
if (filter === void 0) return kind === "session";
|
|
533
|
+
const wanted = Array.isArray(filter) ? filter : [filter];
|
|
534
|
+
return wanted.includes("*") || wanted.includes(kind);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
492
537
|
* Orchestrates credential issuance, validation, refresh, and revocation
|
|
493
538
|
* on top of a pluggable {@link CredentialStore}.
|
|
494
539
|
*
|
|
@@ -501,9 +546,10 @@ function fingerprint(token) {
|
|
|
501
546
|
* tokens stored side-by-side in the same store.
|
|
502
547
|
* - `listForUser` and `maxConcurrent` consider only access-kind credentials,
|
|
503
548
|
* matching how callers typically display "active sessions".
|
|
504
|
-
* - On detected refresh-reuse
|
|
505
|
-
*
|
|
506
|
-
*
|
|
549
|
+
* - On detected refresh-reuse, the orchestrator best-effort revokes the
|
|
550
|
+
* compromised token family (the OAuth-best-practice theft response). Set
|
|
551
|
+
* `refresh.reuseResponse: 'user'` to escalate to revoking ALL of the user's
|
|
552
|
+
* sessions. See {@link RefreshConfig.reuseResponse}.
|
|
507
553
|
*/
|
|
508
554
|
var AuthCredential = class {
|
|
509
555
|
store;
|
|
@@ -513,6 +559,7 @@ var AuthCredential = class {
|
|
|
513
559
|
denylist;
|
|
514
560
|
maxConcurrent;
|
|
515
561
|
onLimit;
|
|
562
|
+
trackLastSeen;
|
|
516
563
|
clock;
|
|
517
564
|
/**
|
|
518
565
|
* Recently-consumed refresh tokens, keyed by the raw refresh token string.
|
|
@@ -531,40 +578,51 @@ var AuthCredential = class {
|
|
|
531
578
|
this.denylist = opts.denylist;
|
|
532
579
|
this.maxConcurrent = opts.maxConcurrent;
|
|
533
580
|
this.onLimit = opts.onLimit ?? "reject";
|
|
534
|
-
this.
|
|
581
|
+
this.trackLastSeen = opts.trackLastSeen ?? false;
|
|
582
|
+
this.clock = opts.clock ?? require_clock.defaultClock;
|
|
535
583
|
if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
|
|
536
584
|
if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
|
|
537
585
|
}
|
|
538
586
|
async issue(userId, options) {
|
|
539
587
|
if (this.maxConcurrent !== void 0 && this.store.listForUser) await this.enforceConcurrencyLimit(userId);
|
|
540
588
|
const now = this.clock.now();
|
|
541
|
-
const
|
|
589
|
+
const { metadata, sessionId: providedSessionId, ttl, expiresAt, kind, ...payload } = options ?? {};
|
|
590
|
+
const effectiveMetadata = kind !== void 0 ? {
|
|
591
|
+
...metadata,
|
|
592
|
+
credentialKind: kind
|
|
593
|
+
} : metadata;
|
|
594
|
+
if (ttl !== void 0 && expiresAt !== void 0) throw new AuthError("INVALID_CONFIG", "issue: pass either `ttl` or `expiresAt`, not both");
|
|
595
|
+
if (ttl !== void 0 && ttl <= 0) throw new AuthError("INVALID_CONFIG", `issue: ttl must be > 0 (got ${ttl})`);
|
|
596
|
+
const accessExpiresAt = expiresAt ?? now + (ttl ?? this.accessTtl);
|
|
597
|
+
const accessStoreTtl = accessExpiresAt - now;
|
|
598
|
+
const sessionId = providedSessionId ?? (0, node_crypto.randomUUID)();
|
|
599
|
+
const accessState = stateWithPayload(payload, {
|
|
542
600
|
userId,
|
|
543
601
|
issuedAt: now,
|
|
544
|
-
expiresAt:
|
|
602
|
+
expiresAt: accessExpiresAt,
|
|
545
603
|
kind: "access",
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
};
|
|
549
|
-
const accessToken = await this.store.persist(accessState,
|
|
604
|
+
metadata: effectiveMetadata,
|
|
605
|
+
sessionId
|
|
606
|
+
});
|
|
607
|
+
const accessToken = await this.store.persist(accessState, accessStoreTtl);
|
|
550
608
|
let refreshToken;
|
|
551
609
|
let refreshExpiresAt;
|
|
552
610
|
if (this.refreshConfig) {
|
|
553
|
-
const refreshState = {
|
|
611
|
+
const refreshState = stateWithPayload(payload, {
|
|
554
612
|
userId,
|
|
555
613
|
issuedAt: now,
|
|
556
614
|
expiresAt: now + this.refreshConfig.ttl,
|
|
557
615
|
kind: "refresh",
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
};
|
|
616
|
+
metadata: effectiveMetadata,
|
|
617
|
+
sessionId
|
|
618
|
+
});
|
|
561
619
|
refreshToken = await this.store.persist(refreshState, this.refreshConfig.ttl);
|
|
562
620
|
refreshExpiresAt = now + this.refreshConfig.ttl;
|
|
563
621
|
}
|
|
564
622
|
return {
|
|
565
623
|
accessToken,
|
|
566
624
|
refreshToken,
|
|
567
|
-
accessExpiresAt
|
|
625
|
+
accessExpiresAt,
|
|
568
626
|
refreshExpiresAt
|
|
569
627
|
};
|
|
570
628
|
}
|
|
@@ -574,12 +632,14 @@ var AuthCredential = class {
|
|
|
574
632
|
if (!state) return null;
|
|
575
633
|
if (state.expiresAt <= this.clock.now()) return null;
|
|
576
634
|
if (state.kind === "refresh") return null;
|
|
635
|
+
if (this.trackLastSeen === "validate" && this.store.touch) await this.store.touch(accessToken, this.clock.now());
|
|
577
636
|
return {
|
|
637
|
+
...require_payload.credentialPayloadOf(state),
|
|
578
638
|
userId: state.userId,
|
|
579
639
|
method: this.method,
|
|
580
640
|
credentialId: fingerprint(accessToken),
|
|
581
|
-
|
|
582
|
-
|
|
641
|
+
sessionId: this.sessionIdOf(state.sessionId, accessToken),
|
|
642
|
+
expiresAt: state.expiresAt
|
|
583
643
|
};
|
|
584
644
|
}
|
|
585
645
|
async refresh(refreshToken) {
|
|
@@ -595,14 +655,7 @@ var AuthCredential = class {
|
|
|
595
655
|
if (oldState.kind !== "refresh") throw new AuthError("INVALID_TOKEN", "Token is not a refresh credential");
|
|
596
656
|
switch (rotation) {
|
|
597
657
|
case "none": return await this.refreshNone(oldState, refreshToken, now);
|
|
598
|
-
case "always":
|
|
599
|
-
await this.store.consume(refreshToken);
|
|
600
|
-
this.consumedRefreshes.set(refreshToken, {
|
|
601
|
-
userId: oldState.userId,
|
|
602
|
-
iat: oldState.issuedAt,
|
|
603
|
-
exp: oldState.expiresAt
|
|
604
|
-
});
|
|
605
|
-
return await this.issueRotatedPair(oldState, refreshToken, false, now);
|
|
658
|
+
case "always": return await this.refreshAlways(oldState, refreshToken, now);
|
|
606
659
|
case "sliding": return await this.refreshSliding(oldState, refreshToken, now);
|
|
607
660
|
default: throw new AuthError("INVALID_CONFIG", `Unknown rotation: ${String(rotation)}`);
|
|
608
661
|
}
|
|
@@ -616,19 +669,57 @@ var AuthCredential = class {
|
|
|
616
669
|
refreshExpiresAt: oldState.expiresAt
|
|
617
670
|
};
|
|
618
671
|
}
|
|
672
|
+
/**
|
|
673
|
+
* `sliding` rotation: rotate on every use and slide the refresh expiry
|
|
674
|
+
* forward (rolling session). Grace-tolerant via the shared store-backed
|
|
675
|
+
* window.
|
|
676
|
+
*/
|
|
619
677
|
async refreshSliding(oldState, refreshToken, now) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
678
|
+
return await this.rotateWithGrace(oldState, refreshToken, now, false);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* `always` rotation: rotate on every use but keep a FIXED session ceiling —
|
|
682
|
+
* each rotated token inherits the family's original `expiresAt` (no sliding).
|
|
683
|
+
*
|
|
684
|
+
* On a stateful store this reuses the same store-backed grace window as
|
|
685
|
+
* `sliding` (so a benign concurrent refresh within grace is NOT mistaken for
|
|
686
|
+
* theft, even across instances). On a stateless store the old token cannot be
|
|
687
|
+
* kept valid (`update` re-issues), so it falls back to single-use semantics
|
|
688
|
+
* with a process-local reuse signal — the only mechanism possible there.
|
|
689
|
+
*/
|
|
690
|
+
async refreshAlways(oldState, refreshToken, now) {
|
|
691
|
+
if (!this.store.listForUser) {
|
|
692
|
+
await this.store.consume(refreshToken);
|
|
693
|
+
this.consumedRefreshes.set(refreshToken, {
|
|
627
694
|
userId: oldState.userId,
|
|
628
|
-
|
|
695
|
+
iat: oldState.issuedAt,
|
|
696
|
+
exp: oldState.expiresAt,
|
|
697
|
+
...oldState.sessionId !== void 0 && { sessionId: oldState.sessionId }
|
|
629
698
|
});
|
|
699
|
+
return await this.issueRotatedPair(oldState, refreshToken, false, now, true);
|
|
630
700
|
}
|
|
631
|
-
return await this.
|
|
701
|
+
return await this.rotateWithGrace(oldState, refreshToken, now, true);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
|
|
705
|
+
* stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
|
|
706
|
+
* within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
|
|
707
|
+
* re-rotating (replay-tolerant); beyond grace it treats the re-presentation
|
|
708
|
+
* as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
|
|
709
|
+
* (`sliding`) expiry for the new refresh token.
|
|
710
|
+
*/
|
|
711
|
+
async rotateWithGrace(oldState, refreshToken, now, preserveExpiry) {
|
|
712
|
+
if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
|
|
713
|
+
const graceMs = this.refreshConfig.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
|
|
714
|
+
if (typeof oldState.rotatedAt !== "number") return await this.issueRotatedPair(oldState, refreshToken, true, now, preserveExpiry);
|
|
715
|
+
if (now - oldState.rotatedAt > graceMs) await this.respondToRefreshReuse({
|
|
716
|
+
userId: oldState.userId,
|
|
717
|
+
sessionId: oldState.sessionId,
|
|
718
|
+
issuedAt: oldState.issuedAt,
|
|
719
|
+
expiresAt: oldState.expiresAt,
|
|
720
|
+
rotatedAt: oldState.rotatedAt
|
|
721
|
+
});
|
|
722
|
+
return await this.issueRotatedPair(oldState, refreshToken, false, now, preserveExpiry);
|
|
632
723
|
}
|
|
633
724
|
async revoke(token) {
|
|
634
725
|
await this.store.revoke(token);
|
|
@@ -639,13 +730,117 @@ var AuthCredential = class {
|
|
|
639
730
|
async listForUser(userId) {
|
|
640
731
|
if (!this.store.listForUser) return [];
|
|
641
732
|
return (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh").map((entry) => ({
|
|
733
|
+
...require_payload.credentialPayloadOf(entry),
|
|
642
734
|
userId: entry.userId,
|
|
643
735
|
method: this.method,
|
|
644
736
|
credentialId: fingerprint(entry.token),
|
|
645
|
-
|
|
646
|
-
|
|
737
|
+
sessionId: this.sessionKeyOf(entry),
|
|
738
|
+
expiresAt: entry.expiresAt
|
|
647
739
|
}));
|
|
648
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* The session id for a credential: its stored `sessionId`, or the token
|
|
743
|
+
* fingerprint for legacy rows predating sessionId (so they surface as
|
|
744
|
+
* singleton sessions). The ONE place this fallback rule lives — shared by
|
|
745
|
+
* `validate()`, `listForUser()`, and the session-family methods so "this
|
|
746
|
+
* device" matching stays consistent across all of them.
|
|
747
|
+
*/
|
|
748
|
+
sessionIdOf(sessionId, token) {
|
|
749
|
+
return sessionId ?? fingerprint(token);
|
|
750
|
+
}
|
|
751
|
+
/** Session-grouping key for a stored credential entry (token attached). */
|
|
752
|
+
sessionKeyOf(entry) {
|
|
753
|
+
return this.sessionIdOf(entry.sessionId, entry.token);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* List the user's active sessions, one row per token family (access +
|
|
757
|
+
* refresh + every rotation collapsed by `sessionId`). Newest first by
|
|
758
|
+
* `lastSeenAt` (or `createdAt` when activity isn't tracked). Returns `[]` for
|
|
759
|
+
* stores that can't enumerate (stateless JWT/encapsulated). Pass `enrich` to
|
|
760
|
+
* map each row through a {@link SessionEnricher} (device/location labels).
|
|
761
|
+
*/
|
|
762
|
+
async listSessions(userId, opts) {
|
|
763
|
+
if (!this.store.listForUser) return [];
|
|
764
|
+
const all = await this.store.listForUser(userId);
|
|
765
|
+
const families = /* @__PURE__ */ new Map();
|
|
766
|
+
for (const entry of all) {
|
|
767
|
+
const key = this.sessionKeyOf(entry);
|
|
768
|
+
const bucket = families.get(key);
|
|
769
|
+
if (bucket) bucket.push(entry);
|
|
770
|
+
else families.set(key, [entry]);
|
|
771
|
+
}
|
|
772
|
+
const sessions = [];
|
|
773
|
+
for (const [sessionId, entries] of families) {
|
|
774
|
+
let createdAt = Infinity;
|
|
775
|
+
let lastSeenAt;
|
|
776
|
+
let refreshExpiresAt;
|
|
777
|
+
let accessExpiresAt;
|
|
778
|
+
let metadata;
|
|
779
|
+
for (const e of entries) {
|
|
780
|
+
if (e.issuedAt < createdAt) createdAt = e.issuedAt;
|
|
781
|
+
if (typeof e.lastSeenAt === "number" && (lastSeenAt === void 0 || e.lastSeenAt > lastSeenAt)) lastSeenAt = e.lastSeenAt;
|
|
782
|
+
if (e.kind === "refresh") {
|
|
783
|
+
if (refreshExpiresAt === void 0 || e.expiresAt > refreshExpiresAt) refreshExpiresAt = e.expiresAt;
|
|
784
|
+
} else if (accessExpiresAt === void 0 || e.expiresAt > accessExpiresAt) accessExpiresAt = e.expiresAt;
|
|
785
|
+
if (!metadata && e.metadata) metadata = e.metadata;
|
|
786
|
+
}
|
|
787
|
+
const credentialKind = metadata?.credentialKind;
|
|
788
|
+
if (!matchesSessionKind(credentialKind ?? "session", opts?.kind)) continue;
|
|
789
|
+
sessions.push({
|
|
790
|
+
sessionId,
|
|
791
|
+
userId,
|
|
792
|
+
createdAt: createdAt === Infinity ? this.clock.now() : createdAt,
|
|
793
|
+
expiresAt: refreshExpiresAt ?? accessExpiresAt ?? 0,
|
|
794
|
+
...lastSeenAt !== void 0 && { lastSeenAt },
|
|
795
|
+
...metadata && { metadata },
|
|
796
|
+
...credentialKind !== void 0 && { kind: credentialKind }
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
sessions.sort((a, b) => (b.lastSeenAt ?? b.createdAt) - (a.lastSeenAt ?? a.createdAt));
|
|
800
|
+
if (!opts?.enrich) return sessions;
|
|
801
|
+
const enrich = opts.enrich;
|
|
802
|
+
return Promise.all(sessions.map((s) => Promise.resolve(enrich(s))));
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Revoke a single session — every token in its family (access + refresh +
|
|
806
|
+
* rotations). No-op for stores that can't enumerate. Other sessions keep
|
|
807
|
+
* validating.
|
|
808
|
+
*/
|
|
809
|
+
async revokeSession(userId, sessionId) {
|
|
810
|
+
if (!this.store.listForUser) return;
|
|
811
|
+
const all = await this.store.listForUser(userId);
|
|
812
|
+
await Promise.all(all.filter((entry) => this.sessionKeyOf(entry) === sessionId).map((entry) => this.store.revoke(entry.token)));
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Revoke every session for the user EXCEPT `keepSessionId` ("log out
|
|
816
|
+
* everywhere else"). Returns the number of distinct sessions revoked. No-op
|
|
817
|
+
* (returns 0) for stores that can't enumerate.
|
|
818
|
+
*/
|
|
819
|
+
async revokeOtherSessions(userId, keepSessionId) {
|
|
820
|
+
if (!this.store.listForUser) return 0;
|
|
821
|
+
const all = await this.store.listForUser(userId);
|
|
822
|
+
const revokedSessions = /* @__PURE__ */ new Set();
|
|
823
|
+
const revokes = [];
|
|
824
|
+
for (const entry of all) {
|
|
825
|
+
const key = this.sessionKeyOf(entry);
|
|
826
|
+
if (key === keepSessionId) continue;
|
|
827
|
+
revokedSessions.add(key);
|
|
828
|
+
revokes.push(this.store.revoke(entry.token));
|
|
829
|
+
}
|
|
830
|
+
await Promise.all(revokes);
|
|
831
|
+
return revokedSessions.size;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Derive a stable 32-byte key from this credential's underlying store secret,
|
|
835
|
+
* domain-separated by `label`. Lets adjacent subsystems (e.g. workflow-state
|
|
836
|
+
* encryption) reuse the auth secret without managing a second one. Throws if
|
|
837
|
+
* the store has no reusable symmetric secret (stateful/asymmetric) — callers
|
|
838
|
+
* should then require an explicit secret.
|
|
839
|
+
*/
|
|
840
|
+
deriveStateKey(label = "wf-state") {
|
|
841
|
+
if (!this.store.deriveSubkey) throw new AuthError("INVALID_CONFIG", "credential store has no reusable secret; provide an explicit wfStateSecret");
|
|
842
|
+
return this.store.deriveSubkey(label);
|
|
843
|
+
}
|
|
649
844
|
async enforceConcurrencyLimit(userId) {
|
|
650
845
|
if (!this.store.listForUser || this.maxConcurrent === void 0) return;
|
|
651
846
|
const accessOnly = (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh");
|
|
@@ -660,32 +855,43 @@ var AuthCredential = class {
|
|
|
660
855
|
for (let i = 0; i < toEvict && i < sorted.length; i++) await this.store.revoke(sorted[i].token);
|
|
661
856
|
}
|
|
662
857
|
async issueAccessFromRefresh(refreshState, now) {
|
|
663
|
-
const accessState = {
|
|
858
|
+
const accessState = stateWithPayload(require_payload.credentialPayloadOf(refreshState), {
|
|
664
859
|
userId: refreshState.userId,
|
|
665
860
|
issuedAt: now,
|
|
666
861
|
expiresAt: now + this.accessTtl,
|
|
667
862
|
kind: "access",
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
863
|
+
metadata: refreshState.metadata,
|
|
864
|
+
sessionId: refreshState.sessionId,
|
|
865
|
+
...this.trackLastSeen === "refresh" && { lastSeenAt: now }
|
|
866
|
+
});
|
|
671
867
|
return {
|
|
672
868
|
token: await this.store.persist(accessState, this.accessTtl),
|
|
673
869
|
expiresAt: now + this.accessTtl
|
|
674
870
|
};
|
|
675
871
|
}
|
|
676
|
-
|
|
872
|
+
/**
|
|
873
|
+
* Issue a fresh access + refresh pair off an existing refresh credential.
|
|
874
|
+
* `preserveExpiry` keeps the family's original refresh `expiresAt` (a fixed
|
|
875
|
+
* session ceiling, used by `always`); otherwise the new refresh slides to
|
|
876
|
+
* `now + ttl` (used by `sliding`). `rotateOld` stamps the old refresh as
|
|
877
|
+
* rotated (keeping it valid through the grace window) instead of consuming it.
|
|
878
|
+
*/
|
|
879
|
+
async issueRotatedPair(oldRefreshState, oldRefreshToken, rotateOld, now, preserveExpiry = false) {
|
|
677
880
|
if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
|
|
678
881
|
const access = await this.issueAccessFromRefresh(oldRefreshState, now);
|
|
679
|
-
const
|
|
882
|
+
const refreshExpiresAt = preserveExpiry ? oldRefreshState.expiresAt : now + this.refreshConfig.ttl;
|
|
883
|
+
const refreshTtl = preserveExpiry ? Math.max(0, oldRefreshState.expiresAt - now) : this.refreshConfig.ttl;
|
|
884
|
+
const newRefreshState = stateWithPayload(require_payload.credentialPayloadOf(oldRefreshState), {
|
|
680
885
|
userId: oldRefreshState.userId,
|
|
681
886
|
issuedAt: now,
|
|
682
|
-
expiresAt:
|
|
887
|
+
expiresAt: refreshExpiresAt,
|
|
683
888
|
kind: "refresh",
|
|
684
|
-
claims: oldRefreshState.claims,
|
|
685
889
|
metadata: oldRefreshState.metadata,
|
|
686
|
-
parentCredentialId: oldRefreshToken
|
|
687
|
-
|
|
688
|
-
|
|
890
|
+
parentCredentialId: oldRefreshToken,
|
|
891
|
+
sessionId: oldRefreshState.sessionId,
|
|
892
|
+
...this.trackLastSeen === "refresh" && { lastSeenAt: now }
|
|
893
|
+
});
|
|
894
|
+
const newRefreshToken = await this.store.persist(newRefreshState, refreshTtl);
|
|
689
895
|
if (rotateOld) {
|
|
690
896
|
const rotatedState = {
|
|
691
897
|
...oldRefreshState,
|
|
@@ -697,7 +903,7 @@ var AuthCredential = class {
|
|
|
697
903
|
accessToken: access.token,
|
|
698
904
|
accessExpiresAt: access.expiresAt,
|
|
699
905
|
refreshToken: newRefreshToken,
|
|
700
|
-
refreshExpiresAt
|
|
906
|
+
refreshExpiresAt
|
|
701
907
|
};
|
|
702
908
|
}
|
|
703
909
|
lookupConsumedRefresh(token) {
|
|
@@ -710,15 +916,38 @@ var AuthCredential = class {
|
|
|
710
916
|
return entry;
|
|
711
917
|
}
|
|
712
918
|
async fireRefreshReuseTheftResponse(consumed, refreshToken) {
|
|
713
|
-
this.
|
|
919
|
+
this.consumedRefreshes.delete(refreshToken);
|
|
920
|
+
await this.respondToRefreshReuse({
|
|
714
921
|
userId: consumed.userId,
|
|
922
|
+
sessionId: consumed.sessionId,
|
|
715
923
|
issuedAt: consumed.iat,
|
|
716
|
-
expiresAt: consumed.exp
|
|
717
|
-
|
|
924
|
+
expiresAt: consumed.exp
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Best-effort theft response for a detected refresh-token reuse. Fires the
|
|
929
|
+
* `onRotationReuse` hook, then revokes per {@link RefreshConfig.reuseResponse}:
|
|
930
|
+
* the compromised token family (`'session'`, default) or every session for
|
|
931
|
+
* the user (`'user'`). Falls back to user-wide revocation when the session
|
|
932
|
+
* can't be targeted (no `sessionId`, or a store that can't enumerate
|
|
933
|
+
* sessions). Always throws `REFRESH_REUSE_DETECTED`.
|
|
934
|
+
*/
|
|
935
|
+
async respondToRefreshReuse(reuse) {
|
|
936
|
+
this.refreshConfig?.onRotationReuse?.({
|
|
937
|
+
userId: reuse.userId,
|
|
938
|
+
issuedAt: reuse.issuedAt,
|
|
939
|
+
expiresAt: reuse.expiresAt,
|
|
940
|
+
kind: "refresh",
|
|
941
|
+
...reuse.sessionId !== void 0 && { sessionId: reuse.sessionId },
|
|
942
|
+
...reuse.rotatedAt !== void 0 && { rotatedAt: reuse.rotatedAt }
|
|
943
|
+
});
|
|
944
|
+
if ((this.refreshConfig?.reuseResponse ?? "session") === "session" && reuse.sessionId !== void 0 && this.store.listForUser) await this.revokeSession(reuse.userId, reuse.sessionId);
|
|
945
|
+
else await this.store.revokeAllForUser(reuse.userId);
|
|
946
|
+
throw new AuthError("REFRESH_REUSE_DETECTED", void 0, {
|
|
947
|
+
userId: reuse.userId,
|
|
948
|
+
...reuse.sessionId !== void 0 && { sessionId: reuse.sessionId },
|
|
949
|
+
...reuse.rotatedAt !== void 0 && { rotatedAt: reuse.rotatedAt }
|
|
718
950
|
});
|
|
719
|
-
await this.store.revokeAllForUser(consumed.userId);
|
|
720
|
-
this.consumedRefreshes.delete(refreshToken);
|
|
721
|
-
throw new AuthError("REFRESH_REUSE_DETECTED", void 0, { userId: consumed.userId });
|
|
722
951
|
}
|
|
723
952
|
};
|
|
724
953
|
//#endregion
|
|
@@ -737,5 +966,5 @@ exports.CredentialStoreEncapsulated = CredentialStoreEncapsulated;
|
|
|
737
966
|
exports.CredentialStoreJwt = CredentialStoreJwt;
|
|
738
967
|
exports.CredentialStoreMemory = CredentialStoreMemory;
|
|
739
968
|
exports.DenylistStoreMemory = DenylistStoreMemory;
|
|
740
|
-
exports.defaultClock = defaultClock;
|
|
969
|
+
exports.defaultClock = require_clock.defaultClock;
|
|
741
970
|
exports.generateMagicLinkToken = generateMagicLinkToken;
|