@aooth/auth 0.1.47 → 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.
Files changed (33) hide show
  1. package/dist/atscript-db.cjs +3 -1
  2. package/dist/atscript-db.d.cts +4 -3
  3. package/dist/atscript-db.d.mts +4 -3
  4. package/dist/atscript-db.mjs +3 -1
  5. package/dist/auth-credential-C3qebKuy.d.mts +245 -0
  6. package/dist/auth-credential-CEijxoGU.d.cts +245 -0
  7. package/dist/authz.cjs +124 -35
  8. package/dist/authz.d.cts +78 -24
  9. package/dist/authz.d.mts +78 -24
  10. package/dist/authz.mjs +121 -37
  11. package/dist/{store-BG6m6oSJ.d.mts → clock-DJ_eroHW.d.cts} +50 -1
  12. package/dist/{store-BG6m6oSJ.d.cts → clock-DJ_eroHW.d.mts} +50 -1
  13. package/dist/{dynamic-client-store-DLRfntHr.mjs → dynamic-client-store-B1JwFHdP.mjs} +1 -0
  14. package/dist/{dynamic-client-store-DzqdUwnw.d.cts → dynamic-client-store-DJgtMjPw.d.mts} +58 -9
  15. package/dist/{dynamic-client-store-oHEsQaJg.d.mts → dynamic-client-store-DbiSLOj0.d.cts} +58 -9
  16. package/dist/{dynamic-client-store-DAStgv2j.cjs → dynamic-client-store-DykM9QOT.cjs} +1 -0
  17. package/dist/index.cjs +39 -26
  18. package/dist/index.d.cts +12 -218
  19. package/dist/index.d.mts +12 -218
  20. package/dist/index.mjs +39 -27
  21. package/dist/opaque-token-BaMOrC1A.mjs +13 -0
  22. package/dist/opaque-token-CaZgD2i6.cjs +18 -0
  23. package/dist/redis.d.cts +2 -2
  24. package/dist/redis.d.mts +2 -2
  25. package/dist/{store-N9daSmHS.d.cts → store-BYnpA7VK.d.cts} +1 -1
  26. package/dist/{store-jFgby4bK.d.mts → store-pKh0ZaGG.d.mts} +1 -1
  27. package/package.json +2 -2
  28. package/src/atscript-db/auth-credential.as +16 -0
  29. package/src/atscript-db/auth-credential.as.d.ts +4 -1
  30. package/src/atscript-db/dynamic-client.as +11 -1
  31. package/src/atscript-db/dynamic-client.as.d.ts +3 -0
  32. package/dist/clock-BjXa0LXb.d.cts +0 -14
  33. package/dist/clock-BjXa0LXb.d.mts +0 -14
@@ -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-DAStgv2j.cjs");
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;
@@ -1,6 +1,5 @@
1
- import { a as CredentialState, t as CredentialStore } from "./store-BG6m6oSJ.cjs";
2
- import { t as Clock } from "./clock-BjXa0LXb.cjs";
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[])`. */
@@ -1,6 +1,5 @@
1
- import { a as CredentialState, t as CredentialStore } from "./store-BG6m6oSJ.mjs";
2
- import { t as Clock } from "./clock-BjXa0LXb.mjs";
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[])`. */
@@ -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-DLRfntHr.mjs";
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 };