@aooth/auth 0.1.46 → 0.1.48
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 +3 -1
- package/dist/atscript-db.d.cts +4 -3
- package/dist/atscript-db.d.mts +4 -3
- package/dist/atscript-db.mjs +3 -1
- package/dist/auth-credential-C3qebKuy.d.mts +245 -0
- package/dist/auth-credential-CEijxoGU.d.cts +245 -0
- package/dist/authz.cjs +124 -35
- package/dist/authz.d.cts +78 -24
- package/dist/authz.d.mts +78 -24
- package/dist/authz.mjs +121 -37
- package/dist/{store-BG6m6oSJ.d.mts → clock-DJ_eroHW.d.cts} +50 -1
- package/dist/{store-BG6m6oSJ.d.cts → clock-DJ_eroHW.d.mts} +50 -1
- package/dist/{dynamic-client-store-DLRfntHr.mjs → dynamic-client-store-B1JwFHdP.mjs} +1 -0
- package/dist/{dynamic-client-store-DzqdUwnw.d.cts → dynamic-client-store-DJgtMjPw.d.mts} +58 -9
- package/dist/{dynamic-client-store-oHEsQaJg.d.mts → dynamic-client-store-DbiSLOj0.d.cts} +58 -9
- package/dist/{dynamic-client-store-DAStgv2j.cjs → dynamic-client-store-DykM9QOT.cjs} +1 -0
- package/dist/index.cjs +39 -26
- package/dist/index.d.cts +12 -218
- package/dist/index.d.mts +12 -218
- package/dist/index.mjs +39 -27
- package/dist/opaque-token-BaMOrC1A.mjs +13 -0
- package/dist/opaque-token-CaZgD2i6.cjs +18 -0
- package/dist/redis.d.cts +2 -2
- package/dist/redis.d.mts +2 -2
- package/dist/{store-N9daSmHS.d.cts → store-BYnpA7VK.d.cts} +1 -1
- package/dist/{store-jFgby4bK.d.mts → store-pKh0ZaGG.d.mts} +1 -1
- package/package.json +8 -8
- package/src/atscript-db/auth-credential.as +16 -0
- package/src/atscript-db/auth-credential.as.d.ts +4 -1
- package/src/atscript-db/dynamic-client.as +11 -1
- package/src/atscript-db/dynamic-client.as.d.ts +3 -0
- package/dist/clock-BjXa0LXb.d.cts +0 -14
- package/dist/clock-BjXa0LXb.d.mts +0 -14
package/dist/atscript-db.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_clock = require("./clock-Bl-H3eqE.cjs");
|
|
3
3
|
const require_payload = require("./payload-BJjvj8AH.cjs");
|
|
4
|
-
const require_dynamic_client_store = require("./dynamic-client-store-
|
|
4
|
+
const require_dynamic_client_store = require("./dynamic-client-store-DykM9QOT.cjs");
|
|
5
5
|
let node_crypto = require("node:crypto");
|
|
6
6
|
//#region src/atscript-db/authz-stores.ts
|
|
7
7
|
/**
|
|
@@ -170,6 +170,7 @@ var DynamicClientStoreAtscriptDb = class extends require_dynamic_client_store.Dy
|
|
|
170
170
|
responseTypes: JSON.stringify(rec.responseTypes),
|
|
171
171
|
createdAt: this.clock.now(),
|
|
172
172
|
...rec.clientName !== void 0 && { clientName: rec.clientName },
|
|
173
|
+
...rec.clientSecretHash !== void 0 && { clientSecretHash: rec.clientSecretHash },
|
|
173
174
|
...rec.scope !== void 0 && { scope: rec.scope }
|
|
174
175
|
};
|
|
175
176
|
await this.table.insertOne(row);
|
|
@@ -212,6 +213,7 @@ function rowToDynamicClient(row) {
|
|
|
212
213
|
createdAt: row.createdAt
|
|
213
214
|
};
|
|
214
215
|
if (row.clientName != null) out.clientName = row.clientName;
|
|
216
|
+
if (row.clientSecretHash != null) out.clientSecretHash = row.clientSecretHash;
|
|
215
217
|
if (row.scope != null) out.scope = row.scope;
|
|
216
218
|
if (row.lastUsedAt != null) out.lastUsedAt = row.lastUsedAt;
|
|
217
219
|
return out;
|
package/dist/atscript-db.d.cts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as
|
|
3
|
-
import { a as NewDynamicClient, f as NewPendingAuthorization, m as PendingAuthorizationStore, n as DynamicClientStore, o as AuthCode, p as PendingAuthorization, s as AuthCodeStore, t as DynamicClient, u as NewAuthCode } from "./dynamic-client-store-DzqdUwnw.cjs";
|
|
1
|
+
import { r as CredentialStore, s as CredentialState, t as Clock } from "./clock-DJ_eroHW.cjs";
|
|
2
|
+
import { c as AuthCodeStore, d as NewAuthCode, h as PendingAuthorizationStore, m as PendingAuthorization, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient } from "./dynamic-client-store-DbiSLOj0.cjs";
|
|
4
3
|
|
|
5
4
|
//#region src/atscript-db/authz-stores.d.ts
|
|
6
5
|
/**
|
|
@@ -145,6 +144,8 @@ interface DynamicClientRow {
|
|
|
145
144
|
/** `JSON.stringify(string[])`. */
|
|
146
145
|
redirectUris: string;
|
|
147
146
|
tokenEndpointAuthMethod: string;
|
|
147
|
+
/** SHA-256 hex digest of the minted secret (confidential clients only). */
|
|
148
|
+
clientSecretHash?: string;
|
|
148
149
|
/** `JSON.stringify(string[])`. */
|
|
149
150
|
grantTypes: string;
|
|
150
151
|
/** `JSON.stringify(string[])`. */
|
package/dist/atscript-db.d.mts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as
|
|
3
|
-
import { a as NewDynamicClient, f as NewPendingAuthorization, m as PendingAuthorizationStore, n as DynamicClientStore, o as AuthCode, p as PendingAuthorization, s as AuthCodeStore, t as DynamicClient, u as NewAuthCode } from "./dynamic-client-store-oHEsQaJg.mjs";
|
|
1
|
+
import { r as CredentialStore, s as CredentialState, t as Clock } from "./clock-DJ_eroHW.mjs";
|
|
2
|
+
import { c as AuthCodeStore, d as NewAuthCode, h as PendingAuthorizationStore, m as PendingAuthorization, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient } from "./dynamic-client-store-DJgtMjPw.mjs";
|
|
4
3
|
|
|
5
4
|
//#region src/atscript-db/authz-stores.d.ts
|
|
6
5
|
/**
|
|
@@ -145,6 +144,8 @@ interface DynamicClientRow {
|
|
|
145
144
|
/** `JSON.stringify(string[])`. */
|
|
146
145
|
redirectUris: string;
|
|
147
146
|
tokenEndpointAuthMethod: string;
|
|
147
|
+
/** SHA-256 hex digest of the minted secret (confidential clients only). */
|
|
148
|
+
clientSecretHash?: string;
|
|
148
149
|
/** `JSON.stringify(string[])`. */
|
|
149
150
|
grantTypes: string;
|
|
150
151
|
/** `JSON.stringify(string[])`. */
|
package/dist/atscript-db.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as defaultClock } from "./clock-Bdsep_1j.mjs";
|
|
2
2
|
import { t as credentialPayloadOf } from "./payload-D-DzH5-J.mjs";
|
|
3
|
-
import { r as AuthCodeStore, s as PendingAuthorizationStore, t as DynamicClientStore } from "./dynamic-client-store-
|
|
3
|
+
import { r as AuthCodeStore, s as PendingAuthorizationStore, t as DynamicClientStore } from "./dynamic-client-store-B1JwFHdP.mjs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
//#region src/atscript-db/authz-stores.ts
|
|
6
6
|
/**
|
|
@@ -169,6 +169,7 @@ var DynamicClientStoreAtscriptDb = class extends DynamicClientStore {
|
|
|
169
169
|
responseTypes: JSON.stringify(rec.responseTypes),
|
|
170
170
|
createdAt: this.clock.now(),
|
|
171
171
|
...rec.clientName !== void 0 && { clientName: rec.clientName },
|
|
172
|
+
...rec.clientSecretHash !== void 0 && { clientSecretHash: rec.clientSecretHash },
|
|
172
173
|
...rec.scope !== void 0 && { scope: rec.scope }
|
|
173
174
|
};
|
|
174
175
|
await this.table.insertOne(row);
|
|
@@ -211,6 +212,7 @@ function rowToDynamicClient(row) {
|
|
|
211
212
|
createdAt: row.createdAt
|
|
212
213
|
};
|
|
213
214
|
if (row.clientName != null) out.clientName = row.clientName;
|
|
215
|
+
if (row.clientSecretHash != null) out.clientSecretHash = row.clientSecretHash;
|
|
214
216
|
if (row.scope != null) out.scope = row.scope;
|
|
215
217
|
if (row.lastUsedAt != null) out.lastUsedAt = row.lastUsedAt;
|
|
216
218
|
return out;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { a as AuthContext, c as EnrichedSession, d as RefreshResult, f as SessionEnricher, i as DenylistStore, l as IssueResult, o as CredentialMetadata, p as SessionInfo, r as CredentialStore, s as CredentialState, t as Clock, u as RefreshConfig } from "./clock-DJ_eroHW.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/credential/auth-credential.d.ts
|
|
4
|
+
interface AuthCredentialOptions<TPayload extends object = object> {
|
|
5
|
+
/** Pluggable credential store (Memory, JWT, Encapsulated, ...). */
|
|
6
|
+
store: CredentialStore<TPayload>;
|
|
7
|
+
/** Default 'token' — distinguishes session-style from token-style use. */
|
|
8
|
+
method?: "session" | "token";
|
|
9
|
+
/** Access token TTL in milliseconds. Defaults to 1 hour. Must be > 0. */
|
|
10
|
+
accessTtl?: number;
|
|
11
|
+
/** If provided, refresh tokens are enabled. */
|
|
12
|
+
refresh?: RefreshConfig;
|
|
13
|
+
/**
|
|
14
|
+
* Optional denylist consulted by `validate` keyed on the raw token.
|
|
15
|
+
*
|
|
16
|
+
* Note: stateless stores (JWT, Encapsulated) maintain their own denylist
|
|
17
|
+
* keyed on `jti` for `revoke`/`update`/`consume`. Sharing a single
|
|
18
|
+
* `DenylistStore` instance across both is safe (the keyspaces are disjoint:
|
|
19
|
+
* raw tokens vs UUID jti) but conceptually they serve different purposes.
|
|
20
|
+
*/
|
|
21
|
+
denylist?: DenylistStore;
|
|
22
|
+
/** Maximum concurrent active access credentials per user. */
|
|
23
|
+
maxConcurrent?: number;
|
|
24
|
+
/** Behavior when limit reached: 'reject' (default) or 'evict-oldest'. */
|
|
25
|
+
onLimit?: "reject" | "evict-oldest";
|
|
26
|
+
/**
|
|
27
|
+
* Track per-session activity time (`lastSeenAt`). Default `false` — no extra
|
|
28
|
+
* writes; `listSessions` falls back to `createdAt`.
|
|
29
|
+
* - `'refresh'` (cheap): stamp `lastSeenAt` on the newly-minted credentials
|
|
30
|
+
* during refresh — piggybacks the rotation write, no extra round-trip.
|
|
31
|
+
* - `'validate'` (accurate, costly): `store.touch(token, now)` on every
|
|
32
|
+
* successful `validate()` — one write per authenticated request. Requires a
|
|
33
|
+
* store that implements `touch`; a no-op on stores that don't.
|
|
34
|
+
*/
|
|
35
|
+
trackLastSeen?: "refresh" | "validate" | false;
|
|
36
|
+
/** Optional clock for testability. */
|
|
37
|
+
clock?: Clock;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Options for {@link AuthCredential.issue}. The credential's typed payload
|
|
41
|
+
* `TPayload` (the root fields a consumer added to their credential model — e.g.
|
|
42
|
+
* `@arbac.attenuate.*`-annotated fields) is spread flat alongside the
|
|
43
|
+
* framework-level hints below. Reserved keys `metadata`, `sessionId`, `ttl`,
|
|
44
|
+
* `expiresAt`, `kind`, `refresh` (and the {@link CredentialState} envelope keys)
|
|
45
|
+
* must not be reused as payload field names.
|
|
46
|
+
*/
|
|
47
|
+
type IssueOptions<TPayload extends object = object> = TPayload & {
|
|
48
|
+
metadata?: CredentialMetadata;
|
|
49
|
+
/**
|
|
50
|
+
* Pre-supply the session id. Omit to let `issue()` mint a random opaque one.
|
|
51
|
+
* Both the access and refresh tokens of this login share it.
|
|
52
|
+
*/
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Per-mint access-token lifetime in **milliseconds**, overriding the
|
|
56
|
+
* instance-level `accessTtl` for THIS credential only — so one `AuthCredential`
|
|
57
|
+
* can mint, say, a 30-minute browser session AND a long-lived PAT/CLI token
|
|
58
|
+
* without a second instance/posture. Must be `> 0`. Mutually exclusive with
|
|
59
|
+
* {@link expiresAt}. The refresh token (if any) is unaffected — it keeps
|
|
60
|
+
* `refresh.ttl`.
|
|
61
|
+
*/
|
|
62
|
+
ttl?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Absolute access-token expiry instant (ms since epoch), overriding both
|
|
65
|
+
* `ttl` and the instance `accessTtl`. Mutually exclusive with {@link ttl}.
|
|
66
|
+
* Use when the caller already holds the exact instant.
|
|
67
|
+
*/
|
|
68
|
+
expiresAt?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Semantic credential kind for THIS mint — e.g. `"cli-session"` / `"pat"`.
|
|
71
|
+
* Distinct from the internal access/refresh discriminator
|
|
72
|
+
* ({@link CredentialState.kind}): it is stored in `metadata.credentialKind`
|
|
73
|
+
* and carried forward across rotation, so the whole session family is
|
|
74
|
+
* labelled. Surfaced as {@link SessionInfo.kind} and consumed by the
|
|
75
|
+
* `listSessions({ kind })` filter to keep non-browser credentials out of the
|
|
76
|
+
* default "active sessions" view. Omit for an ordinary interactive session.
|
|
77
|
+
*/
|
|
78
|
+
kind?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Per-mint refresh-token control, overriding the instance-level
|
|
81
|
+
* `AuthCredentialOptions.refresh` for THIS credential only:
|
|
82
|
+
* - omitted — instance default: a refresh token is minted iff the instance
|
|
83
|
+
* `refresh` config exists (today's behavior).
|
|
84
|
+
* - `false` — mint NO refresh token even when the instance config exists
|
|
85
|
+
* (e.g. the authz token endpoint suppressing the paired refresh for a
|
|
86
|
+
* policy that didn't opt in — no orphaned refresh row).
|
|
87
|
+
* - `{ ttl? }` — mint a refresh token for this credential; `ttl` (ms)
|
|
88
|
+
* overrides the instance `refresh.ttl`. Works WITHOUT an instance config
|
|
89
|
+
* (then `ttl` is required). Per-mint families ALWAYS redeem with
|
|
90
|
+
* fixed-ceiling (`'always'`) rotation semantics — the family is stamped
|
|
91
|
+
* `metadata.refreshRotation: "always"` at mint, which `refresh()` honors
|
|
92
|
+
* over the instance rotation, so rotation never extends the family's
|
|
93
|
+
* lifetime even on an instance whose sessions rotate `'sliding'`.
|
|
94
|
+
*/
|
|
95
|
+
refresh?: false | {
|
|
96
|
+
ttl?: number;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
/** Options for {@link AuthCredential.refresh}. */
|
|
100
|
+
interface RefreshCallOptions<TPayload extends object = object> {
|
|
101
|
+
/**
|
|
102
|
+
* Pre-rotation gate: invoked with the stored refresh credential AFTER it
|
|
103
|
+
* resolved to a live refresh-kind row but BEFORE any rotation or state
|
|
104
|
+
* change. Throwing aborts the refresh with nothing consumed or rotated —
|
|
105
|
+
* the seam for caller-level binding checks (e.g. the OAuth token endpoint
|
|
106
|
+
* verifying `metadata.authzClientId` against the presenting client).
|
|
107
|
+
*/
|
|
108
|
+
guard?: (state: CredentialState & TPayload) => void | Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Orchestrates credential issuance, validation, refresh, and revocation
|
|
112
|
+
* on top of a pluggable {@link CredentialStore}.
|
|
113
|
+
*
|
|
114
|
+
* Design notes:
|
|
115
|
+
* - `credentialId` returned in {@link AuthContext} is a SHA-256 fingerprint of
|
|
116
|
+
* the access token, never the token itself. The fingerprint is stable
|
|
117
|
+
* per-token, safe to log/persist, and cannot be replayed against the API.
|
|
118
|
+
* M3 will switch to a `jti` claim for JWT/encapsulated stores.
|
|
119
|
+
* - `kind: 'access' | 'refresh'` on {@link CredentialState} discriminates
|
|
120
|
+
* tokens stored side-by-side in the same store.
|
|
121
|
+
* - `listForUser` and `maxConcurrent` consider only access-kind credentials,
|
|
122
|
+
* matching how callers typically display "active sessions".
|
|
123
|
+
* - On detected refresh-reuse, the orchestrator best-effort revokes the
|
|
124
|
+
* compromised token family (the OAuth-best-practice theft response). Set
|
|
125
|
+
* `refresh.reuseResponse: 'user'` to escalate to revoking ALL of the user's
|
|
126
|
+
* sessions. See {@link RefreshConfig.reuseResponse}.
|
|
127
|
+
*/
|
|
128
|
+
declare class AuthCredential<TPayload extends object = object> {
|
|
129
|
+
private readonly store;
|
|
130
|
+
private readonly method;
|
|
131
|
+
private readonly accessTtl;
|
|
132
|
+
private readonly refreshConfig?;
|
|
133
|
+
private readonly denylist?;
|
|
134
|
+
private readonly maxConcurrent?;
|
|
135
|
+
private readonly onLimit;
|
|
136
|
+
private readonly trackLastSeen;
|
|
137
|
+
private readonly clock;
|
|
138
|
+
/**
|
|
139
|
+
* Recently-consumed refresh tokens, keyed by the raw refresh token string.
|
|
140
|
+
* Lets `'always'` rotation detect reuse: stateful stores forget the token
|
|
141
|
+
* after `consume`, and stateless (JWT) stores hide it behind a denylist hit
|
|
142
|
+
* on `retrieve`. Without this map the orchestrator can no longer distinguish
|
|
143
|
+
* "fake token" from "previously valid token replayed". Pruned lazily on
|
|
144
|
+
* access; bounded by refresh TTL.
|
|
145
|
+
*/
|
|
146
|
+
private readonly consumedRefreshes;
|
|
147
|
+
constructor(opts: AuthCredentialOptions<TPayload>);
|
|
148
|
+
issue(userId: string, options?: IssueOptions<TPayload>): Promise<IssueResult>;
|
|
149
|
+
validate(accessToken: string): Promise<AuthContext<TPayload> | null>;
|
|
150
|
+
refresh(refreshToken: string, opts?: RefreshCallOptions<TPayload>): Promise<RefreshResult>;
|
|
151
|
+
private refreshNone;
|
|
152
|
+
/**
|
|
153
|
+
* `sliding` rotation: rotate on every use and slide the refresh expiry
|
|
154
|
+
* forward (rolling session). Grace-tolerant via the shared store-backed
|
|
155
|
+
* window.
|
|
156
|
+
*/
|
|
157
|
+
private refreshSliding;
|
|
158
|
+
/**
|
|
159
|
+
* `always` rotation: rotate on every use but keep a FIXED session ceiling —
|
|
160
|
+
* each rotated token inherits the family's original `expiresAt` (no sliding).
|
|
161
|
+
*
|
|
162
|
+
* On a stateful store this reuses the same store-backed grace window as
|
|
163
|
+
* `sliding` (so a benign concurrent refresh within grace is NOT mistaken for
|
|
164
|
+
* theft, even across instances). On a stateless store the old token cannot be
|
|
165
|
+
* kept valid (`update` re-issues), so it falls back to single-use semantics
|
|
166
|
+
* with a process-local reuse signal — the only mechanism possible there.
|
|
167
|
+
*/
|
|
168
|
+
private refreshAlways;
|
|
169
|
+
/**
|
|
170
|
+
* Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
|
|
171
|
+
* stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
|
|
172
|
+
* within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
|
|
173
|
+
* re-rotating (replay-tolerant); beyond grace it treats the re-presentation
|
|
174
|
+
* as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
|
|
175
|
+
* (`sliding`) expiry for the new refresh token.
|
|
176
|
+
*/
|
|
177
|
+
private rotateWithGrace;
|
|
178
|
+
revoke(token: string): Promise<void>;
|
|
179
|
+
revokeAllForUser(userId: string): Promise<number>;
|
|
180
|
+
listForUser(userId: string): Promise<Array<AuthContext<TPayload>>>;
|
|
181
|
+
/**
|
|
182
|
+
* The session id for a credential: its stored `sessionId`, or the token
|
|
183
|
+
* fingerprint for legacy rows predating sessionId (so they surface as
|
|
184
|
+
* singleton sessions). The ONE place this fallback rule lives — shared by
|
|
185
|
+
* `validate()`, `listForUser()`, and the session-family methods so "this
|
|
186
|
+
* device" matching stays consistent across all of them.
|
|
187
|
+
*/
|
|
188
|
+
private sessionIdOf;
|
|
189
|
+
/** Session-grouping key for a stored credential entry (token attached). */
|
|
190
|
+
private sessionKeyOf;
|
|
191
|
+
/**
|
|
192
|
+
* List the user's active sessions, one row per token family (access +
|
|
193
|
+
* refresh + every rotation collapsed by `sessionId`). Newest first by
|
|
194
|
+
* `lastSeenAt` (or `createdAt` when activity isn't tracked). Returns `[]` for
|
|
195
|
+
* stores that can't enumerate (stateless JWT/encapsulated). Pass `enrich` to
|
|
196
|
+
* map each row through a {@link SessionEnricher} (device/location labels).
|
|
197
|
+
*/
|
|
198
|
+
listSessions(userId: string, opts?: {
|
|
199
|
+
enrich?: SessionEnricher;
|
|
200
|
+
kind?: string | string[];
|
|
201
|
+
}): Promise<SessionInfo[] | EnrichedSession[]>;
|
|
202
|
+
/**
|
|
203
|
+
* Revoke a single session — every token in its family (access + refresh +
|
|
204
|
+
* rotations). No-op for stores that can't enumerate. Other sessions keep
|
|
205
|
+
* validating.
|
|
206
|
+
*/
|
|
207
|
+
revokeSession(userId: string, sessionId: string): Promise<void>;
|
|
208
|
+
/**
|
|
209
|
+
* Revoke every session for the user EXCEPT `keepSessionId` ("log out
|
|
210
|
+
* everywhere else"). Returns the number of distinct sessions revoked. No-op
|
|
211
|
+
* (returns 0) for stores that can't enumerate.
|
|
212
|
+
*/
|
|
213
|
+
revokeOtherSessions(userId: string, keepSessionId: string): Promise<number>;
|
|
214
|
+
/**
|
|
215
|
+
* Derive a stable 32-byte key from this credential's underlying store secret,
|
|
216
|
+
* domain-separated by `label`. Lets adjacent subsystems (e.g. workflow-state
|
|
217
|
+
* encryption) reuse the auth secret without managing a second one. Throws if
|
|
218
|
+
* the store has no reusable symmetric secret (stateful/asymmetric) — callers
|
|
219
|
+
* should then require an explicit secret.
|
|
220
|
+
*/
|
|
221
|
+
deriveStateKey(label?: string): Buffer;
|
|
222
|
+
private enforceConcurrencyLimit;
|
|
223
|
+
private issueAccessFromRefresh;
|
|
224
|
+
/**
|
|
225
|
+
* Issue a fresh access + refresh pair off an existing refresh credential.
|
|
226
|
+
* `preserveExpiry` keeps the family's original refresh `expiresAt` (a fixed
|
|
227
|
+
* session ceiling, used by `always`); otherwise the new refresh slides to
|
|
228
|
+
* `now + ttl` (used by `sliding`). `rotateOld` stamps the old refresh as
|
|
229
|
+
* rotated (keeping it valid through the grace window) instead of consuming it.
|
|
230
|
+
*/
|
|
231
|
+
private issueRotatedPair;
|
|
232
|
+
private lookupConsumedRefresh;
|
|
233
|
+
private fireRefreshReuseTheftResponse;
|
|
234
|
+
/**
|
|
235
|
+
* Best-effort theft response for a detected refresh-token reuse. Fires the
|
|
236
|
+
* `onRotationReuse` hook, then revokes per {@link RefreshConfig.reuseResponse}:
|
|
237
|
+
* the compromised token family (`'session'`, default) or every session for
|
|
238
|
+
* the user (`'user'`). Falls back to user-wide revocation when the session
|
|
239
|
+
* can't be targeted (no `sessionId`, or a store that can't enumerate
|
|
240
|
+
* sessions). Always throws `REFRESH_REUSE_DETECTED`.
|
|
241
|
+
*/
|
|
242
|
+
private respondToRefreshReuse;
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
export { RefreshCallOptions as i, AuthCredentialOptions as n, IssueOptions as r, AuthCredential as t };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { a as AuthContext, c as EnrichedSession, d as RefreshResult, f as SessionEnricher, i as DenylistStore, l as IssueResult, o as CredentialMetadata, p as SessionInfo, r as CredentialStore, s as CredentialState, t as Clock, u as RefreshConfig } from "./clock-DJ_eroHW.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/credential/auth-credential.d.ts
|
|
4
|
+
interface AuthCredentialOptions<TPayload extends object = object> {
|
|
5
|
+
/** Pluggable credential store (Memory, JWT, Encapsulated, ...). */
|
|
6
|
+
store: CredentialStore<TPayload>;
|
|
7
|
+
/** Default 'token' — distinguishes session-style from token-style use. */
|
|
8
|
+
method?: "session" | "token";
|
|
9
|
+
/** Access token TTL in milliseconds. Defaults to 1 hour. Must be > 0. */
|
|
10
|
+
accessTtl?: number;
|
|
11
|
+
/** If provided, refresh tokens are enabled. */
|
|
12
|
+
refresh?: RefreshConfig;
|
|
13
|
+
/**
|
|
14
|
+
* Optional denylist consulted by `validate` keyed on the raw token.
|
|
15
|
+
*
|
|
16
|
+
* Note: stateless stores (JWT, Encapsulated) maintain their own denylist
|
|
17
|
+
* keyed on `jti` for `revoke`/`update`/`consume`. Sharing a single
|
|
18
|
+
* `DenylistStore` instance across both is safe (the keyspaces are disjoint:
|
|
19
|
+
* raw tokens vs UUID jti) but conceptually they serve different purposes.
|
|
20
|
+
*/
|
|
21
|
+
denylist?: DenylistStore;
|
|
22
|
+
/** Maximum concurrent active access credentials per user. */
|
|
23
|
+
maxConcurrent?: number;
|
|
24
|
+
/** Behavior when limit reached: 'reject' (default) or 'evict-oldest'. */
|
|
25
|
+
onLimit?: "reject" | "evict-oldest";
|
|
26
|
+
/**
|
|
27
|
+
* Track per-session activity time (`lastSeenAt`). Default `false` — no extra
|
|
28
|
+
* writes; `listSessions` falls back to `createdAt`.
|
|
29
|
+
* - `'refresh'` (cheap): stamp `lastSeenAt` on the newly-minted credentials
|
|
30
|
+
* during refresh — piggybacks the rotation write, no extra round-trip.
|
|
31
|
+
* - `'validate'` (accurate, costly): `store.touch(token, now)` on every
|
|
32
|
+
* successful `validate()` — one write per authenticated request. Requires a
|
|
33
|
+
* store that implements `touch`; a no-op on stores that don't.
|
|
34
|
+
*/
|
|
35
|
+
trackLastSeen?: "refresh" | "validate" | false;
|
|
36
|
+
/** Optional clock for testability. */
|
|
37
|
+
clock?: Clock;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Options for {@link AuthCredential.issue}. The credential's typed payload
|
|
41
|
+
* `TPayload` (the root fields a consumer added to their credential model — e.g.
|
|
42
|
+
* `@arbac.attenuate.*`-annotated fields) is spread flat alongside the
|
|
43
|
+
* framework-level hints below. Reserved keys `metadata`, `sessionId`, `ttl`,
|
|
44
|
+
* `expiresAt`, `kind`, `refresh` (and the {@link CredentialState} envelope keys)
|
|
45
|
+
* must not be reused as payload field names.
|
|
46
|
+
*/
|
|
47
|
+
type IssueOptions<TPayload extends object = object> = TPayload & {
|
|
48
|
+
metadata?: CredentialMetadata;
|
|
49
|
+
/**
|
|
50
|
+
* Pre-supply the session id. Omit to let `issue()` mint a random opaque one.
|
|
51
|
+
* Both the access and refresh tokens of this login share it.
|
|
52
|
+
*/
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Per-mint access-token lifetime in **milliseconds**, overriding the
|
|
56
|
+
* instance-level `accessTtl` for THIS credential only — so one `AuthCredential`
|
|
57
|
+
* can mint, say, a 30-minute browser session AND a long-lived PAT/CLI token
|
|
58
|
+
* without a second instance/posture. Must be `> 0`. Mutually exclusive with
|
|
59
|
+
* {@link expiresAt}. The refresh token (if any) is unaffected — it keeps
|
|
60
|
+
* `refresh.ttl`.
|
|
61
|
+
*/
|
|
62
|
+
ttl?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Absolute access-token expiry instant (ms since epoch), overriding both
|
|
65
|
+
* `ttl` and the instance `accessTtl`. Mutually exclusive with {@link ttl}.
|
|
66
|
+
* Use when the caller already holds the exact instant.
|
|
67
|
+
*/
|
|
68
|
+
expiresAt?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Semantic credential kind for THIS mint — e.g. `"cli-session"` / `"pat"`.
|
|
71
|
+
* Distinct from the internal access/refresh discriminator
|
|
72
|
+
* ({@link CredentialState.kind}): it is stored in `metadata.credentialKind`
|
|
73
|
+
* and carried forward across rotation, so the whole session family is
|
|
74
|
+
* labelled. Surfaced as {@link SessionInfo.kind} and consumed by the
|
|
75
|
+
* `listSessions({ kind })` filter to keep non-browser credentials out of the
|
|
76
|
+
* default "active sessions" view. Omit for an ordinary interactive session.
|
|
77
|
+
*/
|
|
78
|
+
kind?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Per-mint refresh-token control, overriding the instance-level
|
|
81
|
+
* `AuthCredentialOptions.refresh` for THIS credential only:
|
|
82
|
+
* - omitted — instance default: a refresh token is minted iff the instance
|
|
83
|
+
* `refresh` config exists (today's behavior).
|
|
84
|
+
* - `false` — mint NO refresh token even when the instance config exists
|
|
85
|
+
* (e.g. the authz token endpoint suppressing the paired refresh for a
|
|
86
|
+
* policy that didn't opt in — no orphaned refresh row).
|
|
87
|
+
* - `{ ttl? }` — mint a refresh token for this credential; `ttl` (ms)
|
|
88
|
+
* overrides the instance `refresh.ttl`. Works WITHOUT an instance config
|
|
89
|
+
* (then `ttl` is required). Per-mint families ALWAYS redeem with
|
|
90
|
+
* fixed-ceiling (`'always'`) rotation semantics — the family is stamped
|
|
91
|
+
* `metadata.refreshRotation: "always"` at mint, which `refresh()` honors
|
|
92
|
+
* over the instance rotation, so rotation never extends the family's
|
|
93
|
+
* lifetime even on an instance whose sessions rotate `'sliding'`.
|
|
94
|
+
*/
|
|
95
|
+
refresh?: false | {
|
|
96
|
+
ttl?: number;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
/** Options for {@link AuthCredential.refresh}. */
|
|
100
|
+
interface RefreshCallOptions<TPayload extends object = object> {
|
|
101
|
+
/**
|
|
102
|
+
* Pre-rotation gate: invoked with the stored refresh credential AFTER it
|
|
103
|
+
* resolved to a live refresh-kind row but BEFORE any rotation or state
|
|
104
|
+
* change. Throwing aborts the refresh with nothing consumed or rotated —
|
|
105
|
+
* the seam for caller-level binding checks (e.g. the OAuth token endpoint
|
|
106
|
+
* verifying `metadata.authzClientId` against the presenting client).
|
|
107
|
+
*/
|
|
108
|
+
guard?: (state: CredentialState & TPayload) => void | Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Orchestrates credential issuance, validation, refresh, and revocation
|
|
112
|
+
* on top of a pluggable {@link CredentialStore}.
|
|
113
|
+
*
|
|
114
|
+
* Design notes:
|
|
115
|
+
* - `credentialId` returned in {@link AuthContext} is a SHA-256 fingerprint of
|
|
116
|
+
* the access token, never the token itself. The fingerprint is stable
|
|
117
|
+
* per-token, safe to log/persist, and cannot be replayed against the API.
|
|
118
|
+
* M3 will switch to a `jti` claim for JWT/encapsulated stores.
|
|
119
|
+
* - `kind: 'access' | 'refresh'` on {@link CredentialState} discriminates
|
|
120
|
+
* tokens stored side-by-side in the same store.
|
|
121
|
+
* - `listForUser` and `maxConcurrent` consider only access-kind credentials,
|
|
122
|
+
* matching how callers typically display "active sessions".
|
|
123
|
+
* - On detected refresh-reuse, the orchestrator best-effort revokes the
|
|
124
|
+
* compromised token family (the OAuth-best-practice theft response). Set
|
|
125
|
+
* `refresh.reuseResponse: 'user'` to escalate to revoking ALL of the user's
|
|
126
|
+
* sessions. See {@link RefreshConfig.reuseResponse}.
|
|
127
|
+
*/
|
|
128
|
+
declare class AuthCredential<TPayload extends object = object> {
|
|
129
|
+
private readonly store;
|
|
130
|
+
private readonly method;
|
|
131
|
+
private readonly accessTtl;
|
|
132
|
+
private readonly refreshConfig?;
|
|
133
|
+
private readonly denylist?;
|
|
134
|
+
private readonly maxConcurrent?;
|
|
135
|
+
private readonly onLimit;
|
|
136
|
+
private readonly trackLastSeen;
|
|
137
|
+
private readonly clock;
|
|
138
|
+
/**
|
|
139
|
+
* Recently-consumed refresh tokens, keyed by the raw refresh token string.
|
|
140
|
+
* Lets `'always'` rotation detect reuse: stateful stores forget the token
|
|
141
|
+
* after `consume`, and stateless (JWT) stores hide it behind a denylist hit
|
|
142
|
+
* on `retrieve`. Without this map the orchestrator can no longer distinguish
|
|
143
|
+
* "fake token" from "previously valid token replayed". Pruned lazily on
|
|
144
|
+
* access; bounded by refresh TTL.
|
|
145
|
+
*/
|
|
146
|
+
private readonly consumedRefreshes;
|
|
147
|
+
constructor(opts: AuthCredentialOptions<TPayload>);
|
|
148
|
+
issue(userId: string, options?: IssueOptions<TPayload>): Promise<IssueResult>;
|
|
149
|
+
validate(accessToken: string): Promise<AuthContext<TPayload> | null>;
|
|
150
|
+
refresh(refreshToken: string, opts?: RefreshCallOptions<TPayload>): Promise<RefreshResult>;
|
|
151
|
+
private refreshNone;
|
|
152
|
+
/**
|
|
153
|
+
* `sliding` rotation: rotate on every use and slide the refresh expiry
|
|
154
|
+
* forward (rolling session). Grace-tolerant via the shared store-backed
|
|
155
|
+
* window.
|
|
156
|
+
*/
|
|
157
|
+
private refreshSliding;
|
|
158
|
+
/**
|
|
159
|
+
* `always` rotation: rotate on every use but keep a FIXED session ceiling —
|
|
160
|
+
* each rotated token inherits the family's original `expiresAt` (no sliding).
|
|
161
|
+
*
|
|
162
|
+
* On a stateful store this reuses the same store-backed grace window as
|
|
163
|
+
* `sliding` (so a benign concurrent refresh within grace is NOT mistaken for
|
|
164
|
+
* theft, even across instances). On a stateless store the old token cannot be
|
|
165
|
+
* kept valid (`update` re-issues), so it falls back to single-use semantics
|
|
166
|
+
* with a process-local reuse signal — the only mechanism possible there.
|
|
167
|
+
*/
|
|
168
|
+
private refreshAlways;
|
|
169
|
+
/**
|
|
170
|
+
* Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
|
|
171
|
+
* stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
|
|
172
|
+
* within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
|
|
173
|
+
* re-rotating (replay-tolerant); beyond grace it treats the re-presentation
|
|
174
|
+
* as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
|
|
175
|
+
* (`sliding`) expiry for the new refresh token.
|
|
176
|
+
*/
|
|
177
|
+
private rotateWithGrace;
|
|
178
|
+
revoke(token: string): Promise<void>;
|
|
179
|
+
revokeAllForUser(userId: string): Promise<number>;
|
|
180
|
+
listForUser(userId: string): Promise<Array<AuthContext<TPayload>>>;
|
|
181
|
+
/**
|
|
182
|
+
* The session id for a credential: its stored `sessionId`, or the token
|
|
183
|
+
* fingerprint for legacy rows predating sessionId (so they surface as
|
|
184
|
+
* singleton sessions). The ONE place this fallback rule lives — shared by
|
|
185
|
+
* `validate()`, `listForUser()`, and the session-family methods so "this
|
|
186
|
+
* device" matching stays consistent across all of them.
|
|
187
|
+
*/
|
|
188
|
+
private sessionIdOf;
|
|
189
|
+
/** Session-grouping key for a stored credential entry (token attached). */
|
|
190
|
+
private sessionKeyOf;
|
|
191
|
+
/**
|
|
192
|
+
* List the user's active sessions, one row per token family (access +
|
|
193
|
+
* refresh + every rotation collapsed by `sessionId`). Newest first by
|
|
194
|
+
* `lastSeenAt` (or `createdAt` when activity isn't tracked). Returns `[]` for
|
|
195
|
+
* stores that can't enumerate (stateless JWT/encapsulated). Pass `enrich` to
|
|
196
|
+
* map each row through a {@link SessionEnricher} (device/location labels).
|
|
197
|
+
*/
|
|
198
|
+
listSessions(userId: string, opts?: {
|
|
199
|
+
enrich?: SessionEnricher;
|
|
200
|
+
kind?: string | string[];
|
|
201
|
+
}): Promise<SessionInfo[] | EnrichedSession[]>;
|
|
202
|
+
/**
|
|
203
|
+
* Revoke a single session — every token in its family (access + refresh +
|
|
204
|
+
* rotations). No-op for stores that can't enumerate. Other sessions keep
|
|
205
|
+
* validating.
|
|
206
|
+
*/
|
|
207
|
+
revokeSession(userId: string, sessionId: string): Promise<void>;
|
|
208
|
+
/**
|
|
209
|
+
* Revoke every session for the user EXCEPT `keepSessionId` ("log out
|
|
210
|
+
* everywhere else"). Returns the number of distinct sessions revoked. No-op
|
|
211
|
+
* (returns 0) for stores that can't enumerate.
|
|
212
|
+
*/
|
|
213
|
+
revokeOtherSessions(userId: string, keepSessionId: string): Promise<number>;
|
|
214
|
+
/**
|
|
215
|
+
* Derive a stable 32-byte key from this credential's underlying store secret,
|
|
216
|
+
* domain-separated by `label`. Lets adjacent subsystems (e.g. workflow-state
|
|
217
|
+
* encryption) reuse the auth secret without managing a second one. Throws if
|
|
218
|
+
* the store has no reusable symmetric secret (stateful/asymmetric) — callers
|
|
219
|
+
* should then require an explicit secret.
|
|
220
|
+
*/
|
|
221
|
+
deriveStateKey(label?: string): Buffer;
|
|
222
|
+
private enforceConcurrencyLimit;
|
|
223
|
+
private issueAccessFromRefresh;
|
|
224
|
+
/**
|
|
225
|
+
* Issue a fresh access + refresh pair off an existing refresh credential.
|
|
226
|
+
* `preserveExpiry` keeps the family's original refresh `expiresAt` (a fixed
|
|
227
|
+
* session ceiling, used by `always`); otherwise the new refresh slides to
|
|
228
|
+
* `now + ttl` (used by `sliding`). `rotateOld` stamps the old refresh as
|
|
229
|
+
* rotated (keeping it valid through the grace window) instead of consuming it.
|
|
230
|
+
*/
|
|
231
|
+
private issueRotatedPair;
|
|
232
|
+
private lookupConsumedRefresh;
|
|
233
|
+
private fireRefreshReuseTheftResponse;
|
|
234
|
+
/**
|
|
235
|
+
* Best-effort theft response for a detected refresh-token reuse. Fires the
|
|
236
|
+
* `onRotationReuse` hook, then revokes per {@link RefreshConfig.reuseResponse}:
|
|
237
|
+
* the compromised token family (`'session'`, default) or every session for
|
|
238
|
+
* the user (`'user'`). Falls back to user-wide revocation when the session
|
|
239
|
+
* can't be targeted (no `sessionId`, or a store that can't enumerate
|
|
240
|
+
* sessions). Always throws `REFRESH_REUSE_DETECTED`.
|
|
241
|
+
*/
|
|
242
|
+
private respondToRefreshReuse;
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
export { RefreshCallOptions as i, AuthCredentialOptions as n, IssueOptions as r, AuthCredential as t };
|