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