@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/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- import { a as CredentialState, i as CredentialMetadata, n as DenylistStore, o as IssueResult, r as AuthContext, s as RefreshConfig, t as CredentialStore } from "./store-untAtWQz.cjs";
1
+ import { a as CredentialState, c as RefreshConfig, i as CredentialMetadata, l as SessionEnricher, n as DenylistStore, o as EnrichedSession, r as AuthContext, s as IssueResult, t as CredentialStore, u as SessionInfo } from "./store-BG6m6oSJ.cjs";
2
+ import { n as defaultClock, t as Clock } from "./clock-BjXa0LXb.cjs";
2
3
  import { CryptoKey } from "jose";
3
4
 
4
5
  //#region src/errors.d.ts
@@ -10,19 +11,6 @@ declare class AuthError extends Error {
10
11
  constructor(type: AuthErrorType, message?: string, details?: Record<string, unknown> | undefined);
11
12
  }
12
13
  //#endregion
13
- //#region src/utils/clock.d.ts
14
- /**
15
- * Minimal time abstraction shared across stores and orchestrators.
16
- *
17
- * Defaults to wall-clock; tests inject a fake clock to deterministically
18
- * advance time. Kept tiny (single `now()` method) so any timing primitive
19
- * — Date, performance.now-ish, monotonic, etc. — can be plugged in.
20
- */
21
- interface Clock {
22
- now(): number;
23
- }
24
- declare const defaultClock: Clock;
25
- //#endregion
26
14
  //#region src/stores/memory.d.ts
27
15
  /**
28
16
  * In-memory implementation of CredentialStore using random opaque token IDs.
@@ -32,20 +20,21 @@ declare const defaultClock: Clock;
32
20
  * `listForUser` without scanning the full primary map.
33
21
  * - Both maps are kept in sync on every mutation.
34
22
  */
35
- declare class CredentialStoreMemory<TClaims extends object = object> implements CredentialStore<TClaims> {
23
+ declare class CredentialStoreMemory<TPayload extends object = object> implements CredentialStore<TPayload> {
36
24
  private readonly states;
37
25
  private readonly byUser;
38
26
  private readonly clock;
39
27
  constructor(opts?: {
40
28
  clock?: Clock;
41
29
  });
42
- persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
43
- retrieve(token: string): Promise<CredentialState<TClaims> | null>;
44
- consume(token: string): Promise<CredentialState<TClaims> | null>;
45
- update(token: string, state: CredentialState<TClaims>): Promise<string>;
30
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
31
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
32
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
33
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
46
34
  revoke(token: string): Promise<void>;
35
+ touch(token: string, at: number): Promise<void>;
47
36
  revokeAllForUser(userId: string): Promise<number>;
48
- listForUser(userId: string): Promise<Array<CredentialState<TClaims> & {
37
+ listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
49
38
  token: string;
50
39
  }>>;
51
40
  private indexAdd;
@@ -105,7 +94,7 @@ interface CredentialStoreJwtOptions {
105
94
  * `denylist`; without one, `revoke`/`update`/`consume` throw
106
95
  * STATELESS_OPERATION_UNSUPPORTED.
107
96
  */
108
- declare class CredentialStoreJwt<TClaims extends object = object> implements CredentialStore<TClaims> {
97
+ declare class CredentialStoreJwt<TPayload extends object = object> implements CredentialStore<TPayload> {
109
98
  private readonly algorithm;
110
99
  private readonly signingKey;
111
100
  private readonly verifyingKey;
@@ -124,12 +113,13 @@ declare class CredentialStoreJwt<TClaims extends object = object> implements Cre
124
113
  */
125
114
  private readonly epochs;
126
115
  constructor(opts: CredentialStoreJwtOptions);
127
- persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
128
- retrieve(token: string): Promise<CredentialState<TClaims> | null>;
129
- consume(token: string): Promise<CredentialState<TClaims> | null>;
130
- update(token: string, state: CredentialState<TClaims>): Promise<string>;
116
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
117
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
118
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
119
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
131
120
  revoke(token: string): Promise<void>;
132
121
  revokeAllForUser(userId: string): Promise<number>;
122
+ deriveSubkey(label: string): Buffer;
133
123
  private requireDenylist;
134
124
  /** Convert payload `exp` (seconds) to ms; fall back to a 60s window. */
135
125
  private payloadExpMs;
@@ -181,7 +171,7 @@ interface CredentialStoreEncapsulatedOptions {
181
171
  * `CredentialStoreAtscriptDb` when you need native enumeration with durable
182
172
  * cross-instance cascade semantics.
183
173
  */
184
- declare class CredentialStoreEncapsulated<TClaims extends object = object> implements CredentialStore<TClaims> {
174
+ declare class CredentialStoreEncapsulated<TPayload extends object = object> implements CredentialStore<TPayload> {
185
175
  private readonly key;
186
176
  private readonly denylist?;
187
177
  private readonly clock;
@@ -195,12 +185,13 @@ declare class CredentialStoreEncapsulated<TClaims extends object = object> imple
195
185
  */
196
186
  private readonly epochs;
197
187
  constructor(opts: CredentialStoreEncapsulatedOptions);
198
- persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
199
- retrieve(token: string): Promise<CredentialState<TClaims> | null>;
200
- consume(token: string): Promise<CredentialState<TClaims> | null>;
201
- update(token: string, state: CredentialState<TClaims>): Promise<string>;
188
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
189
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
190
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
191
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
202
192
  revoke(token: string): Promise<void>;
203
193
  revokeAllForUser(userId: string): Promise<number>;
194
+ deriveSubkey(label: string): Buffer;
204
195
  /**
205
196
  * Reject decrypted payloads whose `issuedAt` predates the user's revocation
206
197
  * epoch. Same-ms mints (`issuedAt === epoch`) are accepted so a workflow can
@@ -212,9 +203,9 @@ declare class CredentialStoreEncapsulated<TClaims extends object = object> imple
212
203
  }
213
204
  //#endregion
214
205
  //#region src/credential/auth-credential.d.ts
215
- interface AuthCredentialOptions<TClaims extends object = object> {
206
+ interface AuthCredentialOptions<TPayload extends object = object> {
216
207
  /** Pluggable credential store (Memory, JWT, Encapsulated, ...). */
217
- store: CredentialStore<TClaims>;
208
+ store: CredentialStore<TPayload>;
218
209
  /** Default 'token' — distinguishes session-style from token-style use. */
219
210
  method?: "session" | "token";
220
211
  /** Access token TTL in milliseconds. Defaults to 1 hour. Must be > 0. */
@@ -234,13 +225,60 @@ interface AuthCredentialOptions<TClaims extends object = object> {
234
225
  maxConcurrent?: number;
235
226
  /** Behavior when limit reached: 'reject' (default) or 'evict-oldest'. */
236
227
  onLimit?: "reject" | "evict-oldest";
228
+ /**
229
+ * Track per-session activity time (`lastSeenAt`). Default `false` — no extra
230
+ * writes; `listSessions` falls back to `createdAt`.
231
+ * - `'refresh'` (cheap): stamp `lastSeenAt` on the newly-minted credentials
232
+ * during refresh — piggybacks the rotation write, no extra round-trip.
233
+ * - `'validate'` (accurate, costly): `store.touch(token, now)` on every
234
+ * successful `validate()` — one write per authenticated request. Requires a
235
+ * store that implements `touch`; a no-op on stores that don't.
236
+ */
237
+ trackLastSeen?: "refresh" | "validate" | false;
237
238
  /** Optional clock for testability. */
238
239
  clock?: Clock;
239
240
  }
240
- interface IssueOptions<TClaims extends object = object> {
241
- claims?: TClaims;
241
+ /**
242
+ * Options for {@link AuthCredential.issue}. The credential's typed payload
243
+ * `TPayload` (the root fields a consumer added to their credential model — e.g.
244
+ * `@arbac.attenuate.*`-annotated fields) is spread flat alongside the
245
+ * framework-level hints below. Reserved keys `metadata`, `sessionId`, `ttl`,
246
+ * `expiresAt`, `kind` (and the {@link CredentialState} envelope keys) must not be
247
+ * reused as payload field names.
248
+ */
249
+ type IssueOptions<TPayload extends object = object> = TPayload & {
242
250
  metadata?: CredentialMetadata;
243
- }
251
+ /**
252
+ * Pre-supply the session id. Omit to let `issue()` mint a random opaque one.
253
+ * Both the access and refresh tokens of this login share it.
254
+ */
255
+ sessionId?: string;
256
+ /**
257
+ * Per-mint access-token lifetime in **milliseconds**, overriding the
258
+ * instance-level `accessTtl` for THIS credential only — so one `AuthCredential`
259
+ * can mint, say, a 30-minute browser session AND a long-lived PAT/CLI token
260
+ * without a second instance/posture. Must be `> 0`. Mutually exclusive with
261
+ * {@link expiresAt}. The refresh token (if any) is unaffected — it keeps
262
+ * `refresh.ttl`.
263
+ */
264
+ ttl?: number;
265
+ /**
266
+ * Absolute access-token expiry instant (ms since epoch), overriding both
267
+ * `ttl` and the instance `accessTtl`. Mutually exclusive with {@link ttl}.
268
+ * Use when the caller already holds the exact instant.
269
+ */
270
+ expiresAt?: number;
271
+ /**
272
+ * Semantic credential kind for THIS mint — e.g. `"cli-session"` / `"pat"`.
273
+ * Distinct from the internal access/refresh discriminator
274
+ * ({@link CredentialState.kind}): it is stored in `metadata.credentialKind`
275
+ * and carried forward across rotation, so the whole session family is
276
+ * labelled. Surfaced as {@link SessionInfo.kind} and consumed by the
277
+ * `listSessions({ kind })` filter to keep non-browser credentials out of the
278
+ * default "active sessions" view. Omit for an ordinary interactive session.
279
+ */
280
+ kind?: string;
281
+ };
244
282
  /**
245
283
  * Orchestrates credential issuance, validation, refresh, and revocation
246
284
  * on top of a pluggable {@link CredentialStore}.
@@ -254,11 +292,12 @@ interface IssueOptions<TClaims extends object = object> {
254
292
  * tokens stored side-by-side in the same store.
255
293
  * - `listForUser` and `maxConcurrent` consider only access-kind credentials,
256
294
  * matching how callers typically display "active sessions".
257
- * - On detected refresh-reuse-after-grace, the orchestrator best-effort
258
- * revokes ALL credentials for the affected user (access + refresh).
259
- * This is OAuth-best-practice theft response; documented and intentional.
295
+ * - On detected refresh-reuse, the orchestrator best-effort revokes the
296
+ * compromised token family (the OAuth-best-practice theft response). Set
297
+ * `refresh.reuseResponse: 'user'` to escalate to revoking ALL of the user's
298
+ * sessions. See {@link RefreshConfig.reuseResponse}.
260
299
  */
261
- declare class AuthCredential<TClaims extends object = object> {
300
+ declare class AuthCredential<TPayload extends object = object> {
262
301
  private readonly store;
263
302
  private readonly method;
264
303
  private readonly accessTtl;
@@ -266,6 +305,7 @@ declare class AuthCredential<TClaims extends object = object> {
266
305
  private readonly denylist?;
267
306
  private readonly maxConcurrent?;
268
307
  private readonly onLimit;
308
+ private readonly trackLastSeen;
269
309
  private readonly clock;
270
310
  /**
271
311
  * Recently-consumed refresh tokens, keyed by the raw refresh token string.
@@ -276,20 +316,102 @@ declare class AuthCredential<TClaims extends object = object> {
276
316
  * access; bounded by refresh TTL.
277
317
  */
278
318
  private readonly consumedRefreshes;
279
- constructor(opts: AuthCredentialOptions<TClaims>);
280
- issue(userId: string, options?: IssueOptions<TClaims>): Promise<IssueResult>;
281
- validate(accessToken: string): Promise<AuthContext<TClaims> | null>;
319
+ constructor(opts: AuthCredentialOptions<TPayload>);
320
+ issue(userId: string, options?: IssueOptions<TPayload>): Promise<IssueResult>;
321
+ validate(accessToken: string): Promise<AuthContext<TPayload> | null>;
282
322
  refresh(refreshToken: string): Promise<IssueResult>;
283
323
  private refreshNone;
324
+ /**
325
+ * `sliding` rotation: rotate on every use and slide the refresh expiry
326
+ * forward (rolling session). Grace-tolerant via the shared store-backed
327
+ * window.
328
+ */
284
329
  private refreshSliding;
330
+ /**
331
+ * `always` rotation: rotate on every use but keep a FIXED session ceiling —
332
+ * each rotated token inherits the family's original `expiresAt` (no sliding).
333
+ *
334
+ * On a stateful store this reuses the same store-backed grace window as
335
+ * `sliding` (so a benign concurrent refresh within grace is NOT mistaken for
336
+ * theft, even across instances). On a stateless store the old token cannot be
337
+ * kept valid (`update` re-issues), so it falls back to single-use semantics
338
+ * with a process-local reuse signal — the only mechanism possible there.
339
+ */
340
+ private refreshAlways;
341
+ /**
342
+ * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
343
+ * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
344
+ * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
345
+ * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
346
+ * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
347
+ * (`sliding`) expiry for the new refresh token.
348
+ */
349
+ private rotateWithGrace;
285
350
  revoke(token: string): Promise<void>;
286
351
  revokeAllForUser(userId: string): Promise<number>;
287
- listForUser(userId: string): Promise<Array<AuthContext<TClaims>>>;
352
+ listForUser(userId: string): Promise<Array<AuthContext<TPayload>>>;
353
+ /**
354
+ * The session id for a credential: its stored `sessionId`, or the token
355
+ * fingerprint for legacy rows predating sessionId (so they surface as
356
+ * singleton sessions). The ONE place this fallback rule lives — shared by
357
+ * `validate()`, `listForUser()`, and the session-family methods so "this
358
+ * device" matching stays consistent across all of them.
359
+ */
360
+ private sessionIdOf;
361
+ /** Session-grouping key for a stored credential entry (token attached). */
362
+ private sessionKeyOf;
363
+ /**
364
+ * List the user's active sessions, one row per token family (access +
365
+ * refresh + every rotation collapsed by `sessionId`). Newest first by
366
+ * `lastSeenAt` (or `createdAt` when activity isn't tracked). Returns `[]` for
367
+ * stores that can't enumerate (stateless JWT/encapsulated). Pass `enrich` to
368
+ * map each row through a {@link SessionEnricher} (device/location labels).
369
+ */
370
+ listSessions(userId: string, opts?: {
371
+ enrich?: SessionEnricher;
372
+ kind?: string | string[];
373
+ }): Promise<SessionInfo[] | EnrichedSession[]>;
374
+ /**
375
+ * Revoke a single session — every token in its family (access + refresh +
376
+ * rotations). No-op for stores that can't enumerate. Other sessions keep
377
+ * validating.
378
+ */
379
+ revokeSession(userId: string, sessionId: string): Promise<void>;
380
+ /**
381
+ * Revoke every session for the user EXCEPT `keepSessionId` ("log out
382
+ * everywhere else"). Returns the number of distinct sessions revoked. No-op
383
+ * (returns 0) for stores that can't enumerate.
384
+ */
385
+ revokeOtherSessions(userId: string, keepSessionId: string): Promise<number>;
386
+ /**
387
+ * Derive a stable 32-byte key from this credential's underlying store secret,
388
+ * domain-separated by `label`. Lets adjacent subsystems (e.g. workflow-state
389
+ * encryption) reuse the auth secret without managing a second one. Throws if
390
+ * the store has no reusable symmetric secret (stateful/asymmetric) — callers
391
+ * should then require an explicit secret.
392
+ */
393
+ deriveStateKey(label?: string): Buffer;
288
394
  private enforceConcurrencyLimit;
289
395
  private issueAccessFromRefresh;
396
+ /**
397
+ * Issue a fresh access + refresh pair off an existing refresh credential.
398
+ * `preserveExpiry` keeps the family's original refresh `expiresAt` (a fixed
399
+ * session ceiling, used by `always`); otherwise the new refresh slides to
400
+ * `now + ttl` (used by `sliding`). `rotateOld` stamps the old refresh as
401
+ * rotated (keeping it valid through the grace window) instead of consuming it.
402
+ */
290
403
  private issueRotatedPair;
291
404
  private lookupConsumedRefresh;
292
405
  private fireRefreshReuseTheftResponse;
406
+ /**
407
+ * Best-effort theft response for a detected refresh-token reuse. Fires the
408
+ * `onRotationReuse` hook, then revokes per {@link RefreshConfig.reuseResponse}:
409
+ * the compromised token family (`'session'`, default) or every session for
410
+ * the user (`'user'`). Falls back to user-wide revocation when the session
411
+ * can't be targeted (no `sessionId`, or a store that can't enumerate
412
+ * sessions). Always throws `REFRESH_REUSE_DETECTED`.
413
+ */
414
+ private respondToRefreshReuse;
293
415
  }
294
416
  //#endregion
295
417
  //#region src/email.d.ts
@@ -391,4 +513,4 @@ type BuildMagicLinkUrl = (kind: AuthEmailKind, token: string, ctx?: {
391
513
  */
392
514
  declare function generateMagicLinkToken(): string;
393
515
  //#endregion
394
- export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SmsSender, defaultClock, generateMagicLinkToken };
516
+ export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, generateMagicLinkToken };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { a as CredentialState, i as CredentialMetadata, n as DenylistStore, o as IssueResult, r as AuthContext, s as RefreshConfig, t as CredentialStore } from "./store-B1t8KkfA.mjs";
1
+ import { a as CredentialState, c as RefreshConfig, i as CredentialMetadata, l as SessionEnricher, n as DenylistStore, o as EnrichedSession, r as AuthContext, s as IssueResult, t as CredentialStore, u as SessionInfo } from "./store-BG6m6oSJ.mjs";
2
+ import { n as defaultClock, t as Clock } from "./clock-BjXa0LXb.mjs";
2
3
  import { CryptoKey } from "jose";
3
4
 
4
5
  //#region src/errors.d.ts
@@ -10,19 +11,6 @@ declare class AuthError extends Error {
10
11
  constructor(type: AuthErrorType, message?: string, details?: Record<string, unknown> | undefined);
11
12
  }
12
13
  //#endregion
13
- //#region src/utils/clock.d.ts
14
- /**
15
- * Minimal time abstraction shared across stores and orchestrators.
16
- *
17
- * Defaults to wall-clock; tests inject a fake clock to deterministically
18
- * advance time. Kept tiny (single `now()` method) so any timing primitive
19
- * — Date, performance.now-ish, monotonic, etc. — can be plugged in.
20
- */
21
- interface Clock {
22
- now(): number;
23
- }
24
- declare const defaultClock: Clock;
25
- //#endregion
26
14
  //#region src/stores/memory.d.ts
27
15
  /**
28
16
  * In-memory implementation of CredentialStore using random opaque token IDs.
@@ -32,20 +20,21 @@ declare const defaultClock: Clock;
32
20
  * `listForUser` without scanning the full primary map.
33
21
  * - Both maps are kept in sync on every mutation.
34
22
  */
35
- declare class CredentialStoreMemory<TClaims extends object = object> implements CredentialStore<TClaims> {
23
+ declare class CredentialStoreMemory<TPayload extends object = object> implements CredentialStore<TPayload> {
36
24
  private readonly states;
37
25
  private readonly byUser;
38
26
  private readonly clock;
39
27
  constructor(opts?: {
40
28
  clock?: Clock;
41
29
  });
42
- persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
43
- retrieve(token: string): Promise<CredentialState<TClaims> | null>;
44
- consume(token: string): Promise<CredentialState<TClaims> | null>;
45
- update(token: string, state: CredentialState<TClaims>): Promise<string>;
30
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
31
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
32
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
33
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
46
34
  revoke(token: string): Promise<void>;
35
+ touch(token: string, at: number): Promise<void>;
47
36
  revokeAllForUser(userId: string): Promise<number>;
48
- listForUser(userId: string): Promise<Array<CredentialState<TClaims> & {
37
+ listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
49
38
  token: string;
50
39
  }>>;
51
40
  private indexAdd;
@@ -105,7 +94,7 @@ interface CredentialStoreJwtOptions {
105
94
  * `denylist`; without one, `revoke`/`update`/`consume` throw
106
95
  * STATELESS_OPERATION_UNSUPPORTED.
107
96
  */
108
- declare class CredentialStoreJwt<TClaims extends object = object> implements CredentialStore<TClaims> {
97
+ declare class CredentialStoreJwt<TPayload extends object = object> implements CredentialStore<TPayload> {
109
98
  private readonly algorithm;
110
99
  private readonly signingKey;
111
100
  private readonly verifyingKey;
@@ -124,12 +113,13 @@ declare class CredentialStoreJwt<TClaims extends object = object> implements Cre
124
113
  */
125
114
  private readonly epochs;
126
115
  constructor(opts: CredentialStoreJwtOptions);
127
- persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
128
- retrieve(token: string): Promise<CredentialState<TClaims> | null>;
129
- consume(token: string): Promise<CredentialState<TClaims> | null>;
130
- update(token: string, state: CredentialState<TClaims>): Promise<string>;
116
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
117
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
118
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
119
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
131
120
  revoke(token: string): Promise<void>;
132
121
  revokeAllForUser(userId: string): Promise<number>;
122
+ deriveSubkey(label: string): Buffer;
133
123
  private requireDenylist;
134
124
  /** Convert payload `exp` (seconds) to ms; fall back to a 60s window. */
135
125
  private payloadExpMs;
@@ -181,7 +171,7 @@ interface CredentialStoreEncapsulatedOptions {
181
171
  * `CredentialStoreAtscriptDb` when you need native enumeration with durable
182
172
  * cross-instance cascade semantics.
183
173
  */
184
- declare class CredentialStoreEncapsulated<TClaims extends object = object> implements CredentialStore<TClaims> {
174
+ declare class CredentialStoreEncapsulated<TPayload extends object = object> implements CredentialStore<TPayload> {
185
175
  private readonly key;
186
176
  private readonly denylist?;
187
177
  private readonly clock;
@@ -195,12 +185,13 @@ declare class CredentialStoreEncapsulated<TClaims extends object = object> imple
195
185
  */
196
186
  private readonly epochs;
197
187
  constructor(opts: CredentialStoreEncapsulatedOptions);
198
- persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
199
- retrieve(token: string): Promise<CredentialState<TClaims> | null>;
200
- consume(token: string): Promise<CredentialState<TClaims> | null>;
201
- update(token: string, state: CredentialState<TClaims>): Promise<string>;
188
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
189
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
190
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
191
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
202
192
  revoke(token: string): Promise<void>;
203
193
  revokeAllForUser(userId: string): Promise<number>;
194
+ deriveSubkey(label: string): Buffer;
204
195
  /**
205
196
  * Reject decrypted payloads whose `issuedAt` predates the user's revocation
206
197
  * epoch. Same-ms mints (`issuedAt === epoch`) are accepted so a workflow can
@@ -212,9 +203,9 @@ declare class CredentialStoreEncapsulated<TClaims extends object = object> imple
212
203
  }
213
204
  //#endregion
214
205
  //#region src/credential/auth-credential.d.ts
215
- interface AuthCredentialOptions<TClaims extends object = object> {
206
+ interface AuthCredentialOptions<TPayload extends object = object> {
216
207
  /** Pluggable credential store (Memory, JWT, Encapsulated, ...). */
217
- store: CredentialStore<TClaims>;
208
+ store: CredentialStore<TPayload>;
218
209
  /** Default 'token' — distinguishes session-style from token-style use. */
219
210
  method?: "session" | "token";
220
211
  /** Access token TTL in milliseconds. Defaults to 1 hour. Must be > 0. */
@@ -234,13 +225,60 @@ interface AuthCredentialOptions<TClaims extends object = object> {
234
225
  maxConcurrent?: number;
235
226
  /** Behavior when limit reached: 'reject' (default) or 'evict-oldest'. */
236
227
  onLimit?: "reject" | "evict-oldest";
228
+ /**
229
+ * Track per-session activity time (`lastSeenAt`). Default `false` — no extra
230
+ * writes; `listSessions` falls back to `createdAt`.
231
+ * - `'refresh'` (cheap): stamp `lastSeenAt` on the newly-minted credentials
232
+ * during refresh — piggybacks the rotation write, no extra round-trip.
233
+ * - `'validate'` (accurate, costly): `store.touch(token, now)` on every
234
+ * successful `validate()` — one write per authenticated request. Requires a
235
+ * store that implements `touch`; a no-op on stores that don't.
236
+ */
237
+ trackLastSeen?: "refresh" | "validate" | false;
237
238
  /** Optional clock for testability. */
238
239
  clock?: Clock;
239
240
  }
240
- interface IssueOptions<TClaims extends object = object> {
241
- claims?: TClaims;
241
+ /**
242
+ * Options for {@link AuthCredential.issue}. The credential's typed payload
243
+ * `TPayload` (the root fields a consumer added to their credential model — e.g.
244
+ * `@arbac.attenuate.*`-annotated fields) is spread flat alongside the
245
+ * framework-level hints below. Reserved keys `metadata`, `sessionId`, `ttl`,
246
+ * `expiresAt`, `kind` (and the {@link CredentialState} envelope keys) must not be
247
+ * reused as payload field names.
248
+ */
249
+ type IssueOptions<TPayload extends object = object> = TPayload & {
242
250
  metadata?: CredentialMetadata;
243
- }
251
+ /**
252
+ * Pre-supply the session id. Omit to let `issue()` mint a random opaque one.
253
+ * Both the access and refresh tokens of this login share it.
254
+ */
255
+ sessionId?: string;
256
+ /**
257
+ * Per-mint access-token lifetime in **milliseconds**, overriding the
258
+ * instance-level `accessTtl` for THIS credential only — so one `AuthCredential`
259
+ * can mint, say, a 30-minute browser session AND a long-lived PAT/CLI token
260
+ * without a second instance/posture. Must be `> 0`. Mutually exclusive with
261
+ * {@link expiresAt}. The refresh token (if any) is unaffected — it keeps
262
+ * `refresh.ttl`.
263
+ */
264
+ ttl?: number;
265
+ /**
266
+ * Absolute access-token expiry instant (ms since epoch), overriding both
267
+ * `ttl` and the instance `accessTtl`. Mutually exclusive with {@link ttl}.
268
+ * Use when the caller already holds the exact instant.
269
+ */
270
+ expiresAt?: number;
271
+ /**
272
+ * Semantic credential kind for THIS mint — e.g. `"cli-session"` / `"pat"`.
273
+ * Distinct from the internal access/refresh discriminator
274
+ * ({@link CredentialState.kind}): it is stored in `metadata.credentialKind`
275
+ * and carried forward across rotation, so the whole session family is
276
+ * labelled. Surfaced as {@link SessionInfo.kind} and consumed by the
277
+ * `listSessions({ kind })` filter to keep non-browser credentials out of the
278
+ * default "active sessions" view. Omit for an ordinary interactive session.
279
+ */
280
+ kind?: string;
281
+ };
244
282
  /**
245
283
  * Orchestrates credential issuance, validation, refresh, and revocation
246
284
  * on top of a pluggable {@link CredentialStore}.
@@ -254,11 +292,12 @@ interface IssueOptions<TClaims extends object = object> {
254
292
  * tokens stored side-by-side in the same store.
255
293
  * - `listForUser` and `maxConcurrent` consider only access-kind credentials,
256
294
  * matching how callers typically display "active sessions".
257
- * - On detected refresh-reuse-after-grace, the orchestrator best-effort
258
- * revokes ALL credentials for the affected user (access + refresh).
259
- * This is OAuth-best-practice theft response; documented and intentional.
295
+ * - On detected refresh-reuse, the orchestrator best-effort revokes the
296
+ * compromised token family (the OAuth-best-practice theft response). Set
297
+ * `refresh.reuseResponse: 'user'` to escalate to revoking ALL of the user's
298
+ * sessions. See {@link RefreshConfig.reuseResponse}.
260
299
  */
261
- declare class AuthCredential<TClaims extends object = object> {
300
+ declare class AuthCredential<TPayload extends object = object> {
262
301
  private readonly store;
263
302
  private readonly method;
264
303
  private readonly accessTtl;
@@ -266,6 +305,7 @@ declare class AuthCredential<TClaims extends object = object> {
266
305
  private readonly denylist?;
267
306
  private readonly maxConcurrent?;
268
307
  private readonly onLimit;
308
+ private readonly trackLastSeen;
269
309
  private readonly clock;
270
310
  /**
271
311
  * Recently-consumed refresh tokens, keyed by the raw refresh token string.
@@ -276,20 +316,102 @@ declare class AuthCredential<TClaims extends object = object> {
276
316
  * access; bounded by refresh TTL.
277
317
  */
278
318
  private readonly consumedRefreshes;
279
- constructor(opts: AuthCredentialOptions<TClaims>);
280
- issue(userId: string, options?: IssueOptions<TClaims>): Promise<IssueResult>;
281
- validate(accessToken: string): Promise<AuthContext<TClaims> | null>;
319
+ constructor(opts: AuthCredentialOptions<TPayload>);
320
+ issue(userId: string, options?: IssueOptions<TPayload>): Promise<IssueResult>;
321
+ validate(accessToken: string): Promise<AuthContext<TPayload> | null>;
282
322
  refresh(refreshToken: string): Promise<IssueResult>;
283
323
  private refreshNone;
324
+ /**
325
+ * `sliding` rotation: rotate on every use and slide the refresh expiry
326
+ * forward (rolling session). Grace-tolerant via the shared store-backed
327
+ * window.
328
+ */
284
329
  private refreshSliding;
330
+ /**
331
+ * `always` rotation: rotate on every use but keep a FIXED session ceiling —
332
+ * each rotated token inherits the family's original `expiresAt` (no sliding).
333
+ *
334
+ * On a stateful store this reuses the same store-backed grace window as
335
+ * `sliding` (so a benign concurrent refresh within grace is NOT mistaken for
336
+ * theft, even across instances). On a stateless store the old token cannot be
337
+ * kept valid (`update` re-issues), so it falls back to single-use semantics
338
+ * with a process-local reuse signal — the only mechanism possible there.
339
+ */
340
+ private refreshAlways;
341
+ /**
342
+ * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
343
+ * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
344
+ * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
345
+ * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
346
+ * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
347
+ * (`sliding`) expiry for the new refresh token.
348
+ */
349
+ private rotateWithGrace;
285
350
  revoke(token: string): Promise<void>;
286
351
  revokeAllForUser(userId: string): Promise<number>;
287
- listForUser(userId: string): Promise<Array<AuthContext<TClaims>>>;
352
+ listForUser(userId: string): Promise<Array<AuthContext<TPayload>>>;
353
+ /**
354
+ * The session id for a credential: its stored `sessionId`, or the token
355
+ * fingerprint for legacy rows predating sessionId (so they surface as
356
+ * singleton sessions). The ONE place this fallback rule lives — shared by
357
+ * `validate()`, `listForUser()`, and the session-family methods so "this
358
+ * device" matching stays consistent across all of them.
359
+ */
360
+ private sessionIdOf;
361
+ /** Session-grouping key for a stored credential entry (token attached). */
362
+ private sessionKeyOf;
363
+ /**
364
+ * List the user's active sessions, one row per token family (access +
365
+ * refresh + every rotation collapsed by `sessionId`). Newest first by
366
+ * `lastSeenAt` (or `createdAt` when activity isn't tracked). Returns `[]` for
367
+ * stores that can't enumerate (stateless JWT/encapsulated). Pass `enrich` to
368
+ * map each row through a {@link SessionEnricher} (device/location labels).
369
+ */
370
+ listSessions(userId: string, opts?: {
371
+ enrich?: SessionEnricher;
372
+ kind?: string | string[];
373
+ }): Promise<SessionInfo[] | EnrichedSession[]>;
374
+ /**
375
+ * Revoke a single session — every token in its family (access + refresh +
376
+ * rotations). No-op for stores that can't enumerate. Other sessions keep
377
+ * validating.
378
+ */
379
+ revokeSession(userId: string, sessionId: string): Promise<void>;
380
+ /**
381
+ * Revoke every session for the user EXCEPT `keepSessionId` ("log out
382
+ * everywhere else"). Returns the number of distinct sessions revoked. No-op
383
+ * (returns 0) for stores that can't enumerate.
384
+ */
385
+ revokeOtherSessions(userId: string, keepSessionId: string): Promise<number>;
386
+ /**
387
+ * Derive a stable 32-byte key from this credential's underlying store secret,
388
+ * domain-separated by `label`. Lets adjacent subsystems (e.g. workflow-state
389
+ * encryption) reuse the auth secret without managing a second one. Throws if
390
+ * the store has no reusable symmetric secret (stateful/asymmetric) — callers
391
+ * should then require an explicit secret.
392
+ */
393
+ deriveStateKey(label?: string): Buffer;
288
394
  private enforceConcurrencyLimit;
289
395
  private issueAccessFromRefresh;
396
+ /**
397
+ * Issue a fresh access + refresh pair off an existing refresh credential.
398
+ * `preserveExpiry` keeps the family's original refresh `expiresAt` (a fixed
399
+ * session ceiling, used by `always`); otherwise the new refresh slides to
400
+ * `now + ttl` (used by `sliding`). `rotateOld` stamps the old refresh as
401
+ * rotated (keeping it valid through the grace window) instead of consuming it.
402
+ */
290
403
  private issueRotatedPair;
291
404
  private lookupConsumedRefresh;
292
405
  private fireRefreshReuseTheftResponse;
406
+ /**
407
+ * Best-effort theft response for a detected refresh-token reuse. Fires the
408
+ * `onRotationReuse` hook, then revokes per {@link RefreshConfig.reuseResponse}:
409
+ * the compromised token family (`'session'`, default) or every session for
410
+ * the user (`'user'`). Falls back to user-wide revocation when the session
411
+ * can't be targeted (no `sessionId`, or a store that can't enumerate
412
+ * sessions). Always throws `REFRESH_REUSE_DETECTED`.
413
+ */
414
+ private respondToRefreshReuse;
293
415
  }
294
416
  //#endregion
295
417
  //#region src/email.d.ts
@@ -391,4 +513,4 @@ type BuildMagicLinkUrl = (kind: AuthEmailKind, token: string, ctx?: {
391
513
  */
392
514
  declare function generateMagicLinkToken(): string;
393
515
  //#endregion
394
- export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SmsSender, defaultClock, generateMagicLinkToken };
516
+ export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, generateMagicLinkToken };