@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.
- package/dist/atscript-db.cjs +3 -1
- package/dist/atscript-db.d.cts +4 -3
- package/dist/atscript-db.d.mts +4 -3
- package/dist/atscript-db.mjs +3 -1
- package/dist/auth-credential-C3qebKuy.d.mts +245 -0
- package/dist/auth-credential-CEijxoGU.d.cts +245 -0
- package/dist/authz.cjs +124 -35
- package/dist/authz.d.cts +78 -24
- package/dist/authz.d.mts +78 -24
- package/dist/authz.mjs +121 -37
- package/dist/{store-BG6m6oSJ.d.mts → clock-DJ_eroHW.d.cts} +50 -1
- package/dist/{store-BG6m6oSJ.d.cts → clock-DJ_eroHW.d.mts} +50 -1
- package/dist/{dynamic-client-store-DLRfntHr.mjs → dynamic-client-store-B1JwFHdP.mjs} +1 -0
- package/dist/{dynamic-client-store-DzqdUwnw.d.cts → dynamic-client-store-DJgtMjPw.d.mts} +58 -9
- package/dist/{dynamic-client-store-oHEsQaJg.d.mts → dynamic-client-store-DbiSLOj0.d.cts} +58 -9
- package/dist/{dynamic-client-store-DAStgv2j.cjs → dynamic-client-store-DykM9QOT.cjs} +1 -0
- package/dist/index.cjs +39 -26
- package/dist/index.d.cts +12 -218
- package/dist/index.d.mts +12 -218
- package/dist/index.mjs +39 -27
- package/dist/opaque-token-BaMOrC1A.mjs +13 -0
- package/dist/opaque-token-CaZgD2i6.cjs +18 -0
- package/dist/redis.d.cts +2 -2
- package/dist/redis.d.mts +2 -2
- package/dist/{store-N9daSmHS.d.cts → store-BYnpA7VK.d.cts} +1 -1
- package/dist/{store-jFgby4bK.d.mts → store-pKh0ZaGG.d.mts} +1 -1
- package/package.json +2 -2
- package/src/atscript-db/auth-credential.as +16 -0
- package/src/atscript-db/auth-credential.as.d.ts +4 -1
- package/src/atscript-db/dynamic-client.as +11 -1
- package/src/atscript-db/dynamic-client.as.d.ts +3 -0
- package/dist/clock-BjXa0LXb.d.cts +0 -14
- package/dist/clock-BjXa0LXb.d.mts +0 -14
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { n as
|
|
3
|
-
import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
621
|
+
if (refreshTtl !== void 0) {
|
|
610
622
|
const refreshState = stateWithPayload(payload, {
|
|
611
623
|
userId,
|
|
612
624
|
issuedAt: now,
|
|
613
|
-
expiresAt: now +
|
|
625
|
+
expiresAt: now + refreshTtl,
|
|
614
626
|
kind: "refresh",
|
|
615
627
|
metadata: effectiveMetadata,
|
|
616
628
|
sessionId
|
|
617
629
|
});
|
|
618
|
-
refreshToken = await this.store.persist(refreshState,
|
|
619
|
-
refreshExpiresAt = now +
|
|
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
|
-
|
|
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 +
|
|
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,
|
|
868
|
-
expiresAt: now +
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
const refreshExpiresAt = preserveExpiry ? oldRefreshState.expiresAt : now +
|
|
882
|
-
const refreshTtl = preserveExpiry ? Math.max(0, oldRefreshState.expiresAt - now) :
|
|
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
|
|
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 {
|
|
2
|
-
import { t as RateLimitStore } from "./store-
|
|
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 {
|
|
2
|
-
import { t as RateLimitStore } from "./store-
|
|
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
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aooth/auth",
|
|
3
|
-
"version": "0.1.
|
|
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,7 +104,7 @@
|
|
|
104
104
|
},
|
|
105
105
|
"dependencies": {
|
|
106
106
|
"jose": "^6.2.3",
|
|
107
|
-
"@aooth/user": "0.1.
|
|
107
|
+
"@aooth/user": "0.1.48"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
110
|
"@atscript/core": "^0.1.82",
|
|
@@ -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:
|
|
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
|
-
/**
|
|
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 };
|