@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.
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 +8 -8
  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
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
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";
3
- import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-jFgby4bK.mjs";
1
+ import { a as AuthContext, c as EnrichedSession, d as RefreshResult, f as SessionEnricher, i as DenylistStore, l as IssueResult, n as defaultClock, o as CredentialMetadata, p as SessionInfo, r as CredentialStore, s as CredentialState, t as Clock, u as RefreshConfig } from "./clock-DJ_eroHW.mjs";
2
+ import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-C3qebKuy.mjs";
3
+ import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-pKh0ZaGG.mjs";
4
4
  import { CryptoKey } from "jose";
5
5
 
6
6
  //#region src/errors.d.ts
@@ -203,218 +203,6 @@ declare class CredentialStoreEncapsulated<TPayload extends object = object> impl
203
203
  private decrypt;
204
204
  }
205
205
  //#endregion
206
- //#region src/credential/auth-credential.d.ts
207
- interface AuthCredentialOptions<TPayload extends object = object> {
208
- /** Pluggable credential store (Memory, JWT, Encapsulated, ...). */
209
- store: CredentialStore<TPayload>;
210
- /** Default 'token' — distinguishes session-style from token-style use. */
211
- method?: "session" | "token";
212
- /** Access token TTL in milliseconds. Defaults to 1 hour. Must be > 0. */
213
- accessTtl?: number;
214
- /** If provided, refresh tokens are enabled. */
215
- refresh?: RefreshConfig;
216
- /**
217
- * Optional denylist consulted by `validate` keyed on the raw token.
218
- *
219
- * Note: stateless stores (JWT, Encapsulated) maintain their own denylist
220
- * keyed on `jti` for `revoke`/`update`/`consume`. Sharing a single
221
- * `DenylistStore` instance across both is safe (the keyspaces are disjoint:
222
- * raw tokens vs UUID jti) but conceptually they serve different purposes.
223
- */
224
- denylist?: DenylistStore;
225
- /** Maximum concurrent active access credentials per user. */
226
- maxConcurrent?: number;
227
- /** Behavior when limit reached: 'reject' (default) or 'evict-oldest'. */
228
- onLimit?: "reject" | "evict-oldest";
229
- /**
230
- * Track per-session activity time (`lastSeenAt`). Default `false` — no extra
231
- * writes; `listSessions` falls back to `createdAt`.
232
- * - `'refresh'` (cheap): stamp `lastSeenAt` on the newly-minted credentials
233
- * during refresh — piggybacks the rotation write, no extra round-trip.
234
- * - `'validate'` (accurate, costly): `store.touch(token, now)` on every
235
- * successful `validate()` — one write per authenticated request. Requires a
236
- * store that implements `touch`; a no-op on stores that don't.
237
- */
238
- trackLastSeen?: "refresh" | "validate" | false;
239
- /** Optional clock for testability. */
240
- clock?: Clock;
241
- }
242
- /**
243
- * Options for {@link AuthCredential.issue}. The credential's typed payload
244
- * `TPayload` (the root fields a consumer added to their credential model — e.g.
245
- * `@arbac.attenuate.*`-annotated fields) is spread flat alongside the
246
- * framework-level hints below. Reserved keys `metadata`, `sessionId`, `ttl`,
247
- * `expiresAt`, `kind` (and the {@link CredentialState} envelope keys) must not be
248
- * reused as payload field names.
249
- */
250
- type IssueOptions<TPayload extends object = object> = TPayload & {
251
- metadata?: CredentialMetadata;
252
- /**
253
- * Pre-supply the session id. Omit to let `issue()` mint a random opaque one.
254
- * Both the access and refresh tokens of this login share it.
255
- */
256
- sessionId?: string;
257
- /**
258
- * Per-mint access-token lifetime in **milliseconds**, overriding the
259
- * instance-level `accessTtl` for THIS credential only — so one `AuthCredential`
260
- * can mint, say, a 30-minute browser session AND a long-lived PAT/CLI token
261
- * without a second instance/posture. Must be `> 0`. Mutually exclusive with
262
- * {@link expiresAt}. The refresh token (if any) is unaffected — it keeps
263
- * `refresh.ttl`.
264
- */
265
- ttl?: number;
266
- /**
267
- * Absolute access-token expiry instant (ms since epoch), overriding both
268
- * `ttl` and the instance `accessTtl`. Mutually exclusive with {@link ttl}.
269
- * Use when the caller already holds the exact instant.
270
- */
271
- expiresAt?: number;
272
- /**
273
- * Semantic credential kind for THIS mint — e.g. `"cli-session"` / `"pat"`.
274
- * Distinct from the internal access/refresh discriminator
275
- * ({@link CredentialState.kind}): it is stored in `metadata.credentialKind`
276
- * and carried forward across rotation, so the whole session family is
277
- * labelled. Surfaced as {@link SessionInfo.kind} and consumed by the
278
- * `listSessions({ kind })` filter to keep non-browser credentials out of the
279
- * default "active sessions" view. Omit for an ordinary interactive session.
280
- */
281
- kind?: string;
282
- };
283
- /**
284
- * Orchestrates credential issuance, validation, refresh, and revocation
285
- * on top of a pluggable {@link CredentialStore}.
286
- *
287
- * Design notes:
288
- * - `credentialId` returned in {@link AuthContext} is a SHA-256 fingerprint of
289
- * the access token, never the token itself. The fingerprint is stable
290
- * per-token, safe to log/persist, and cannot be replayed against the API.
291
- * M3 will switch to a `jti` claim for JWT/encapsulated stores.
292
- * - `kind: 'access' | 'refresh'` on {@link CredentialState} discriminates
293
- * tokens stored side-by-side in the same store.
294
- * - `listForUser` and `maxConcurrent` consider only access-kind credentials,
295
- * matching how callers typically display "active sessions".
296
- * - On detected refresh-reuse, the orchestrator best-effort revokes the
297
- * compromised token family (the OAuth-best-practice theft response). Set
298
- * `refresh.reuseResponse: 'user'` to escalate to revoking ALL of the user's
299
- * sessions. See {@link RefreshConfig.reuseResponse}.
300
- */
301
- declare class AuthCredential<TPayload extends object = object> {
302
- private readonly store;
303
- private readonly method;
304
- private readonly accessTtl;
305
- private readonly refreshConfig?;
306
- private readonly denylist?;
307
- private readonly maxConcurrent?;
308
- private readonly onLimit;
309
- private readonly trackLastSeen;
310
- private readonly clock;
311
- /**
312
- * Recently-consumed refresh tokens, keyed by the raw refresh token string.
313
- * Lets `'always'` rotation detect reuse: stateful stores forget the token
314
- * after `consume`, and stateless (JWT) stores hide it behind a denylist hit
315
- * on `retrieve`. Without this map the orchestrator can no longer distinguish
316
- * "fake token" from "previously valid token replayed". Pruned lazily on
317
- * access; bounded by refresh TTL.
318
- */
319
- private readonly consumedRefreshes;
320
- constructor(opts: AuthCredentialOptions<TPayload>);
321
- issue(userId: string, options?: IssueOptions<TPayload>): Promise<IssueResult>;
322
- validate(accessToken: string): Promise<AuthContext<TPayload> | null>;
323
- refresh(refreshToken: string): Promise<IssueResult>;
324
- private refreshNone;
325
- /**
326
- * `sliding` rotation: rotate on every use and slide the refresh expiry
327
- * forward (rolling session). Grace-tolerant via the shared store-backed
328
- * window.
329
- */
330
- private refreshSliding;
331
- /**
332
- * `always` rotation: rotate on every use but keep a FIXED session ceiling —
333
- * each rotated token inherits the family's original `expiresAt` (no sliding).
334
- *
335
- * On a stateful store this reuses the same store-backed grace window as
336
- * `sliding` (so a benign concurrent refresh within grace is NOT mistaken for
337
- * theft, even across instances). On a stateless store the old token cannot be
338
- * kept valid (`update` re-issues), so it falls back to single-use semantics
339
- * with a process-local reuse signal — the only mechanism possible there.
340
- */
341
- private refreshAlways;
342
- /**
343
- * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
344
- * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
345
- * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
346
- * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
347
- * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
348
- * (`sliding`) expiry for the new refresh token.
349
- */
350
- private rotateWithGrace;
351
- revoke(token: string): Promise<void>;
352
- revokeAllForUser(userId: string): Promise<number>;
353
- listForUser(userId: string): Promise<Array<AuthContext<TPayload>>>;
354
- /**
355
- * The session id for a credential: its stored `sessionId`, or the token
356
- * fingerprint for legacy rows predating sessionId (so they surface as
357
- * singleton sessions). The ONE place this fallback rule lives — shared by
358
- * `validate()`, `listForUser()`, and the session-family methods so "this
359
- * device" matching stays consistent across all of them.
360
- */
361
- private sessionIdOf;
362
- /** Session-grouping key for a stored credential entry (token attached). */
363
- private sessionKeyOf;
364
- /**
365
- * List the user's active sessions, one row per token family (access +
366
- * refresh + every rotation collapsed by `sessionId`). Newest first by
367
- * `lastSeenAt` (or `createdAt` when activity isn't tracked). Returns `[]` for
368
- * stores that can't enumerate (stateless JWT/encapsulated). Pass `enrich` to
369
- * map each row through a {@link SessionEnricher} (device/location labels).
370
- */
371
- listSessions(userId: string, opts?: {
372
- enrich?: SessionEnricher;
373
- kind?: string | string[];
374
- }): Promise<SessionInfo[] | EnrichedSession[]>;
375
- /**
376
- * Revoke a single session — every token in its family (access + refresh +
377
- * rotations). No-op for stores that can't enumerate. Other sessions keep
378
- * validating.
379
- */
380
- revokeSession(userId: string, sessionId: string): Promise<void>;
381
- /**
382
- * Revoke every session for the user EXCEPT `keepSessionId` ("log out
383
- * everywhere else"). Returns the number of distinct sessions revoked. No-op
384
- * (returns 0) for stores that can't enumerate.
385
- */
386
- revokeOtherSessions(userId: string, keepSessionId: string): Promise<number>;
387
- /**
388
- * Derive a stable 32-byte key from this credential's underlying store secret,
389
- * domain-separated by `label`. Lets adjacent subsystems (e.g. workflow-state
390
- * encryption) reuse the auth secret without managing a second one. Throws if
391
- * the store has no reusable symmetric secret (stateful/asymmetric) — callers
392
- * should then require an explicit secret.
393
- */
394
- deriveStateKey(label?: string): Buffer;
395
- private enforceConcurrencyLimit;
396
- private issueAccessFromRefresh;
397
- /**
398
- * Issue a fresh access + refresh pair off an existing refresh credential.
399
- * `preserveExpiry` keeps the family's original refresh `expiresAt` (a fixed
400
- * session ceiling, used by `always`); otherwise the new refresh slides to
401
- * `now + ttl` (used by `sliding`). `rotateOld` stamps the old refresh as
402
- * rotated (keeping it valid through the grace window) instead of consuming it.
403
- */
404
- private issueRotatedPair;
405
- private lookupConsumedRefresh;
406
- private fireRefreshReuseTheftResponse;
407
- /**
408
- * Best-effort theft response for a detected refresh-token reuse. Fires the
409
- * `onRotationReuse` hook, then revokes per {@link RefreshConfig.reuseResponse}:
410
- * the compromised token family (`'session'`, default) or every session for
411
- * the user (`'user'`). Falls back to user-wide revocation when the session
412
- * can't be targeted (no `sessionId`, or a store that can't enumerate
413
- * sessions). Always throws `REFRESH_REUSE_DETECTED`.
414
- */
415
- private respondToRefreshReuse;
416
- }
417
- //#endregion
418
206
  //#region src/rate-limit/rules.d.ts
419
207
  /**
420
208
  * Rate-limit rule model + the compact string grammar (RL.spec.md §4.1).
@@ -615,10 +403,16 @@ interface SmsSender {
615
403
  type BuildMagicLinkUrl = (kind: AuthEmailKind, token: string, ctx?: {
616
404
  userId?: string;
617
405
  }) => string;
406
+ /** A magic-link token is a plain {@link generateOpaqueToken} mint. */
407
+ declare function generateMagicLinkToken(): string;
408
+ //#endregion
409
+ //#region src/utils/opaque-token.d.ts
618
410
  /**
619
411
  * 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
620
- * URL-safe. Strong enough to survive short TTLs against online guessing.
412
+ * URL-safe. The single mint for every opaque bearer-style secret in the stack
413
+ * (magic-link tokens, dynamic-client secrets, authz browser bindings):
414
+ * unguessable online, cheap to generate, safe to place in a URL.
621
415
  */
622
- declare function generateMagicLinkToken(): string;
416
+ declare function generateOpaqueToken(): string;
623
417
  //#endregion
624
- 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, DEFAULT_RATE_LIMIT_MESSAGE, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RateLimitDecision, type RateLimitRule, type RateLimitRuleInput, type RateLimitStore, RateLimitStoreMemory, RateLimiter, type RateLimiterOptions, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
418
+ 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, DEFAULT_RATE_LIMIT_MESSAGE, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RateLimitDecision, type RateLimitRule, type RateLimitRuleInput, type RateLimitStore, RateLimitStoreMemory, RateLimiter, type RateLimiterOptions, type RefreshCallOptions, type RefreshConfig, type RefreshResult, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, formatDurationMs, generateMagicLinkToken, generateOpaqueToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
package/dist/index.mjs CHANGED
@@ -1,5 +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 { t as generateOpaqueToken } from "./opaque-token-BaMOrC1A.mjs";
3
4
  import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID, scryptSync } from "node:crypto";
4
5
  import { SignJWT, jwtVerify } from "jose";
5
6
  //#region src/errors.ts
@@ -585,10 +586,21 @@ var AuthCredential = class {
585
586
  async issue(userId, options) {
586
587
  if (this.maxConcurrent !== void 0 && this.store.listForUser) await this.enforceConcurrencyLimit(userId);
587
588
  const now = this.clock.now();
588
- const { metadata, sessionId: providedSessionId, ttl, expiresAt, kind, ...payload } = options ?? {};
589
- const effectiveMetadata = kind !== void 0 ? {
589
+ const { metadata, sessionId: providedSessionId, ttl, expiresAt, kind, refresh, ...payload } = options ?? {};
590
+ let refreshTtl;
591
+ if (refresh === void 0) refreshTtl = this.refreshConfig?.ttl;
592
+ else if (refresh !== false) {
593
+ refreshTtl = refresh.ttl ?? this.refreshConfig?.ttl;
594
+ if (refreshTtl === void 0) throw new AuthError("INVALID_CONFIG", "issue: per-mint refresh needs a ttl when no instance refresh config exists");
595
+ if (refreshTtl <= 0) throw new AuthError("INVALID_CONFIG", `issue: refresh.ttl must be > 0 (got ${refreshTtl})`);
596
+ }
597
+ const perMintRefresh = refresh !== void 0 && refresh !== false;
598
+ const stampAccessTtl = refreshTtl !== void 0 && ttl !== void 0;
599
+ const effectiveMetadata = kind !== void 0 || stampAccessTtl || perMintRefresh ? {
590
600
  ...metadata,
591
- credentialKind: kind
601
+ ...kind !== void 0 && { credentialKind: kind },
602
+ ...stampAccessTtl && { accessTtl: ttl },
603
+ ...perMintRefresh && { refreshRotation: "always" }
592
604
  } : metadata;
593
605
  if (ttl !== void 0 && expiresAt !== void 0) throw new AuthError("INVALID_CONFIG", "issue: pass either `ttl` or `expiresAt`, not both");
594
606
  if (ttl !== void 0 && ttl <= 0) throw new AuthError("INVALID_CONFIG", `issue: ttl must be > 0 (got ${ttl})`);
@@ -606,17 +618,17 @@ var AuthCredential = class {
606
618
  const accessToken = await this.store.persist(accessState, accessStoreTtl);
607
619
  let refreshToken;
608
620
  let refreshExpiresAt;
609
- if (this.refreshConfig) {
621
+ if (refreshTtl !== void 0) {
610
622
  const refreshState = stateWithPayload(payload, {
611
623
  userId,
612
624
  issuedAt: now,
613
- expiresAt: now + this.refreshConfig.ttl,
625
+ expiresAt: now + refreshTtl,
614
626
  kind: "refresh",
615
627
  metadata: effectiveMetadata,
616
628
  sessionId
617
629
  });
618
- refreshToken = await this.store.persist(refreshState, this.refreshConfig.ttl);
619
- refreshExpiresAt = now + this.refreshConfig.ttl;
630
+ refreshToken = await this.store.persist(refreshState, refreshTtl);
631
+ refreshExpiresAt = now + refreshTtl;
620
632
  }
621
633
  return {
622
634
  accessToken,
@@ -641,9 +653,7 @@ var AuthCredential = class {
641
653
  expiresAt: state.expiresAt
642
654
  };
643
655
  }
644
- async refresh(refreshToken) {
645
- if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
646
- const rotation = this.refreshConfig.rotation ?? "sliding";
656
+ async refresh(refreshToken, opts) {
647
657
  const now = this.clock.now();
648
658
  const oldState = await this.store.retrieve(refreshToken);
649
659
  if (!oldState) {
@@ -652,6 +662,8 @@ var AuthCredential = class {
652
662
  throw new AuthError("INVALID_TOKEN");
653
663
  }
654
664
  if (oldState.kind !== "refresh") throw new AuthError("INVALID_TOKEN", "Token is not a refresh credential");
665
+ await opts?.guard?.(oldState);
666
+ const rotation = oldState.metadata?.refreshRotation ?? (this.refreshConfig ? this.refreshConfig.rotation ?? "sliding" : "always");
655
667
  switch (rotation) {
656
668
  case "none": return await this.refreshNone(oldState, refreshToken, now);
657
669
  case "always": return await this.refreshAlways(oldState, refreshToken, now);
@@ -665,7 +677,8 @@ var AuthCredential = class {
665
677
  accessToken: newAccess.token,
666
678
  accessExpiresAt: newAccess.expiresAt,
667
679
  refreshToken,
668
- refreshExpiresAt: oldState.expiresAt
680
+ refreshExpiresAt: oldState.expiresAt,
681
+ userId: oldState.userId
669
682
  };
670
683
  }
671
684
  /**
@@ -708,8 +721,7 @@ var AuthCredential = class {
708
721
  * (`sliding`) expiry for the new refresh token.
709
722
  */
710
723
  async rotateWithGrace(oldState, refreshToken, now, preserveExpiry) {
711
- if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
712
- const graceMs = this.refreshConfig.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
724
+ const graceMs = this.refreshConfig?.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
713
725
  if (typeof oldState.rotatedAt !== "number") return await this.issueRotatedPair(oldState, refreshToken, true, now, preserveExpiry);
714
726
  if (now - oldState.rotatedAt > graceMs) await this.respondToRefreshReuse({
715
727
  userId: oldState.userId,
@@ -854,18 +866,19 @@ var AuthCredential = class {
854
866
  for (let i = 0; i < toEvict && i < sorted.length; i++) await this.store.revoke(sorted[i].token);
855
867
  }
856
868
  async issueAccessFromRefresh(refreshState, now) {
869
+ const accessTtl = refreshState.metadata?.accessTtl ?? this.accessTtl;
857
870
  const accessState = stateWithPayload(credentialPayloadOf(refreshState), {
858
871
  userId: refreshState.userId,
859
872
  issuedAt: now,
860
- expiresAt: now + this.accessTtl,
873
+ expiresAt: now + accessTtl,
861
874
  kind: "access",
862
875
  metadata: refreshState.metadata,
863
876
  sessionId: refreshState.sessionId,
864
877
  ...this.trackLastSeen === "refresh" && { lastSeenAt: now }
865
878
  });
866
879
  return {
867
- token: await this.store.persist(accessState, this.accessTtl),
868
- expiresAt: now + this.accessTtl
880
+ token: await this.store.persist(accessState, accessTtl),
881
+ expiresAt: now + accessTtl
869
882
  };
870
883
  }
871
884
  /**
@@ -876,10 +889,10 @@ var AuthCredential = class {
876
889
  * rotated (keeping it valid through the grace window) instead of consuming it.
877
890
  */
878
891
  async issueRotatedPair(oldRefreshState, oldRefreshToken, rotateOld, now, preserveExpiry = false) {
879
- if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
880
- const access = await this.issueAccessFromRefresh(oldRefreshState, now);
881
- const refreshExpiresAt = preserveExpiry ? oldRefreshState.expiresAt : now + this.refreshConfig.ttl;
882
- const refreshTtl = preserveExpiry ? Math.max(0, oldRefreshState.expiresAt - now) : this.refreshConfig.ttl;
892
+ const slidingTtl = this.refreshConfig?.ttl;
893
+ if (!preserveExpiry && slidingTtl === void 0) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
894
+ const refreshExpiresAt = preserveExpiry ? oldRefreshState.expiresAt : now + slidingTtl;
895
+ const refreshTtl = preserveExpiry ? Math.max(0, oldRefreshState.expiresAt - now) : slidingTtl;
883
896
  const newRefreshState = stateWithPayload(credentialPayloadOf(oldRefreshState), {
884
897
  userId: oldRefreshState.userId,
885
898
  issuedAt: now,
@@ -890,6 +903,7 @@ var AuthCredential = class {
890
903
  sessionId: oldRefreshState.sessionId,
891
904
  ...this.trackLastSeen === "refresh" && { lastSeenAt: now }
892
905
  });
906
+ const access = await this.issueAccessFromRefresh(oldRefreshState, now);
893
907
  const newRefreshToken = await this.store.persist(newRefreshState, refreshTtl);
894
908
  if (rotateOld) {
895
909
  const rotatedState = {
@@ -902,7 +916,8 @@ var AuthCredential = class {
902
916
  accessToken: access.token,
903
917
  accessExpiresAt: access.expiresAt,
904
918
  refreshToken: newRefreshToken,
905
- refreshExpiresAt
919
+ refreshExpiresAt,
920
+ userId: oldRefreshState.userId
906
921
  };
907
922
  }
908
923
  lookupConsumedRefresh(token) {
@@ -1135,12 +1150,9 @@ var RateLimiter = class {
1135
1150
  };
1136
1151
  //#endregion
1137
1152
  //#region src/magic-link.ts
1138
- /**
1139
- * 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
1140
- * URL-safe. Strong enough to survive short TTLs against online guessing.
1141
- */
1153
+ /** A magic-link token is a plain {@link generateOpaqueToken} mint. */
1142
1154
  function generateMagicLinkToken() {
1143
- return randomBytes(32).toString("base64url");
1155
+ return generateOpaqueToken();
1144
1156
  }
1145
1157
  //#endregion
1146
- export { AuthCredential, AuthError, CredentialStoreEncapsulated, CredentialStoreJwt, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, DenylistStoreMemory, RateLimitStoreMemory, RateLimiter, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
1158
+ export { AuthCredential, AuthError, CredentialStoreEncapsulated, CredentialStoreJwt, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, DenylistStoreMemory, RateLimitStoreMemory, RateLimiter, defaultClock, formatDurationMs, generateMagicLinkToken, generateOpaqueToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
@@ -0,0 +1,13 @@
1
+ import { randomBytes } from "node:crypto";
2
+ //#region src/utils/opaque-token.ts
3
+ /**
4
+ * 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
5
+ * URL-safe. The single mint for every opaque bearer-style secret in the stack
6
+ * (magic-link tokens, dynamic-client secrets, authz browser bindings):
7
+ * unguessable online, cheap to generate, safe to place in a URL.
8
+ */
9
+ function generateOpaqueToken() {
10
+ return randomBytes(32).toString("base64url");
11
+ }
12
+ //#endregion
13
+ export { generateOpaqueToken as t };
@@ -0,0 +1,18 @@
1
+ let node_crypto = require("node:crypto");
2
+ //#region src/utils/opaque-token.ts
3
+ /**
4
+ * 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
5
+ * URL-safe. The single mint for every opaque bearer-style secret in the stack
6
+ * (magic-link tokens, dynamic-client secrets, authz browser bindings):
7
+ * unguessable online, cheap to generate, safe to place in a URL.
8
+ */
9
+ function generateOpaqueToken() {
10
+ return (0, node_crypto.randomBytes)(32).toString("base64url");
11
+ }
12
+ //#endregion
13
+ Object.defineProperty(exports, "generateOpaqueToken", {
14
+ enumerable: true,
15
+ get: function() {
16
+ return generateOpaqueToken;
17
+ }
18
+ });
package/dist/redis.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-BG6m6oSJ.cjs";
2
- import { t as RateLimitStore } from "./store-N9daSmHS.cjs";
1
+ import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-DJ_eroHW.cjs";
2
+ import { t as RateLimitStore } from "./store-BYnpA7VK.cjs";
3
3
 
4
4
  //#region src/redis/index.d.ts
5
5
  /**
package/dist/redis.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-BG6m6oSJ.mjs";
2
- import { t as RateLimitStore } from "./store-jFgby4bK.mjs";
1
+ import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-DJ_eroHW.mjs";
2
+ import { t as RateLimitStore } from "./store-pKh0ZaGG.mjs";
3
3
 
4
4
  //#region src/redis/index.d.ts
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { t as Clock } from "./clock-BjXa0LXb.cjs";
1
+ import { t as Clock } from "./clock-DJ_eroHW.cjs";
2
2
 
3
3
  //#region src/rate-limit/store.d.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as Clock } from "./clock-BjXa0LXb.mjs";
1
+ import { t as Clock } from "./clock-DJ_eroHW.mjs";
2
2
 
3
3
  //#region src/rate-limit/store.d.ts
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Auth method layer for aoothjs (sessions, tokens, password reset, MFA primitives)",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -104,18 +104,18 @@
104
104
  },
105
105
  "dependencies": {
106
106
  "jose": "^6.2.3",
107
- "@aooth/user": "0.1.46"
107
+ "@aooth/user": "0.1.48"
108
108
  },
109
109
  "devDependencies": {
110
- "@atscript/core": "^0.1.81",
111
- "@atscript/db": "^0.1.114",
112
- "@atscript/db-sql-tools": "^0.1.114",
113
- "@atscript/db-sqlite": "^0.1.114",
114
- "@atscript/typescript": "^0.1.81",
110
+ "@atscript/core": "^0.1.82",
111
+ "@atscript/db": "^0.1.115",
112
+ "@atscript/db-sql-tools": "^0.1.115",
113
+ "@atscript/db-sqlite": "^0.1.115",
114
+ "@atscript/typescript": "^0.1.82",
115
115
  "@types/better-sqlite3": "^7.6.13",
116
116
  "better-sqlite3": "^12.6.2",
117
117
  "ioredis": "^5.11.1",
118
- "unplugin-atscript": "^0.1.81"
118
+ "unplugin-atscript": "^0.1.82"
119
119
  },
120
120
  "peerDependencies": {
121
121
  "@atscript/db": ">=0.1.79"
@@ -29,6 +29,22 @@ export type AoothCredentialMetadataBase = {
29
29
  * the internal access/refresh `kind` column on the row.
30
30
  */
31
31
  credentialKind?: string
32
+ /**
33
+ * OAuth client_id the authz token endpoint minted this family for — the
34
+ * refresh_token grant's client binding (written by @aooth/auth-moost).
35
+ */
36
+ authzClientId?: string
37
+ /**
38
+ * Per-family access-token ttl (ms) — stamped when a refresh token is
39
+ * minted with a per-mint ttl, so refreshes keep the mint-time authority.
40
+ */
41
+ accessTtl?: number
42
+ /**
43
+ * Per-family rotation semantics ("always" for per-mint refresh families)
44
+ * — honored over the instance rotation so a fixed-ceiling grant never
45
+ * slides. Stored as a plain string (same portability rule as `kind`).
46
+ */
47
+ refreshRotation?: string
32
48
  }
33
49
 
34
50
  @db.table 'aooth_credentials'
@@ -19,6 +19,9 @@ export type AoothCredentialMetadataBase = {
19
19
  fingerprint?: string
20
20
  label?: string
21
21
  credentialKind?: string
22
+ authzClientId?: string
23
+ accessTtl?: number
24
+ refreshRotation?: string
22
25
  }
23
26
  declare namespace AoothCredentialMetadataBase {
24
27
  const __is_atscript_annotated_type: true
@@ -33,7 +36,7 @@ declare namespace AoothCredentialMetadataBase {
33
36
 
34
37
  /**
35
38
  * Atscript interface **AoothAuthCredential**
36
- * @see {@link ./auth-credential.as:36:18}
39
+ * @see {@link ./auth-credential.as:52:18}
37
40
  */
38
41
  export declare class AoothAuthCredential {
39
42
  token: string
@@ -23,9 +23,19 @@ export interface AoothDynamicClient {
23
23
  */
24
24
  redirectUris: string
25
25
 
26
- /** v1 supports public clients only ("none") — PKCE is the binding. */
26
+ /**
27
+ * "none" (public — PKCE is the binding) or "client_secret_post"
28
+ * (confidential — a server-minted secret is checked at the token endpoint).
29
+ */
27
30
  tokenEndpointAuthMethod: string
28
31
 
32
+ /**
33
+ * SHA-256 hex digest of the minted client_secret — set iff
34
+ * tokenEndpointAuthMethod is "client_secret_post". The plaintext is
35
+ * returned once in the registration response and never stored.
36
+ */
37
+ clientSecretHash?: string
38
+
29
39
  /** Registered grant types (narrowed to supported), JSON string array. */
30
40
  grantTypes: string
31
41
  /** Registered response types (narrowed to supported), JSON string array. */
@@ -18,6 +18,7 @@ export declare class AoothDynamicClient {
18
18
  clientName?: string
19
19
  redirectUris: string
20
20
  tokenEndpointAuthMethod: string
21
+ clientSecretHash?: string
21
22
  grantTypes: string
22
23
  responseTypes: string
23
24
  scope?: string
@@ -36,6 +37,7 @@ export declare class AoothDynamicClient {
36
37
  "clientName"?: string
37
38
  "redirectUris": string
38
39
  "tokenEndpointAuthMethod": string
40
+ "clientSecretHash"?: string
39
41
  "grantTypes": string
40
42
  "responseTypes": string
41
43
  "scope"?: string
@@ -47,6 +49,7 @@ export declare class AoothDynamicClient {
47
49
  "clientName"?: string
48
50
  "redirectUris": string
49
51
  "tokenEndpointAuthMethod": string
52
+ "clientSecretHash"?: string
50
53
  "grantTypes": string
51
54
  "responseTypes": string
52
55
  "scope"?: string
@@ -1,14 +0,0 @@
1
- //#region src/utils/clock.d.ts
2
- /**
3
- * Minimal time abstraction shared across stores and orchestrators.
4
- *
5
- * Defaults to wall-clock; tests inject a fake clock to deterministically
6
- * advance time. Kept tiny (single `now()` method) so any timing primitive
7
- * — Date, performance.now-ish, monotonic, etc. — can be plugged in.
8
- */
9
- interface Clock {
10
- now(): number;
11
- }
12
- declare const defaultClock: Clock;
13
- //#endregion
14
- export { defaultClock as n, Clock as t };
@@ -1,14 +0,0 @@
1
- //#region src/utils/clock.d.ts
2
- /**
3
- * Minimal time abstraction shared across stores and orchestrators.
4
- *
5
- * Defaults to wall-clock; tests inject a fake clock to deterministically
6
- * advance time. Kept tiny (single `now()` method) so any timing primitive
7
- * — Date, performance.now-ish, monotonic, etc. — can be plugged in.
8
- */
9
- interface Clock {
10
- now(): number;
11
- }
12
- declare const defaultClock: Clock;
13
- //#endregion
14
- export { defaultClock as n, Clock as t };