@aooth/auth 0.1.6 → 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/redis.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-B1t8KkfA.mjs";
1
+ import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-BG6m6oSJ.mjs";
2
2
 
3
3
  //#region src/redis/index.d.ts
4
4
  /**
@@ -52,19 +52,20 @@ interface RedisCredentialStoreOptions {
52
52
  * so Redis evicts it on expiry. The user-index set is NOT TTL-bounded — we
53
53
  * lazily prune dead members on `listForUser` and `revokeAllForUser`.
54
54
  */
55
- declare class CredentialStoreRedis<TClaims extends object = object> implements CredentialStore<TClaims> {
55
+ declare class CredentialStoreRedis<TPayload extends object = object> implements CredentialStore<TPayload> {
56
56
  private readonly redis;
57
57
  private readonly prefix;
58
58
  constructor(opts: RedisCredentialStoreOptions);
59
59
  private tokenKey;
60
60
  private userKey;
61
- persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
62
- retrieve(token: string): Promise<CredentialState<TClaims> | null>;
63
- consume(token: string): Promise<CredentialState<TClaims> | null>;
64
- update(token: string, state: CredentialState<TClaims>): Promise<string>;
61
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
62
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
63
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
64
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
65
65
  revoke(token: string): Promise<void>;
66
+ touch(token: string, at: number): Promise<void>;
66
67
  revokeAllForUser(userId: string): Promise<number>;
67
- listForUser(userId: string): Promise<Array<CredentialState<TClaims> & {
68
+ listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
68
69
  token: string;
69
70
  }>>;
70
71
  }
package/dist/redis.mjs CHANGED
@@ -73,6 +73,15 @@ var CredentialStoreRedis = class {
73
73
  await this.redis.del(this.tokenKey(token));
74
74
  await this.redis.srem(this.userKey(state.userId), token);
75
75
  }
76
+ async touch(token, at) {
77
+ const raw = await this.redis.get(this.tokenKey(token));
78
+ if (raw === null) return;
79
+ const state = JSON.parse(raw);
80
+ state.lastSeenAt = at;
81
+ const ttlMs = Math.max(0, state.expiresAt - Date.now());
82
+ if (ttlMs <= 0) return;
83
+ await this.redis.set(this.tokenKey(token), JSON.stringify(state), "PX", ttlMs);
84
+ }
76
85
  async revokeAllForUser(userId) {
77
86
  const setKey = this.userKey(userId);
78
87
  const tokens = await this.redis.smembers(setKey);
@@ -0,0 +1,263 @@
1
+ //#region src/credential/types.d.ts
2
+ /**
3
+ * The read-time envelope a successful auth check always produces — the fixed
4
+ * fields, before the credential's typed payload is merged in. See
5
+ * {@link AuthContext}.
6
+ */
7
+ interface AuthContextBase {
8
+ userId: string;
9
+ method: "session" | "token";
10
+ credentialId: string;
11
+ /**
12
+ * Stable id of the session (token family) that authenticated this request.
13
+ * Survives refresh-token rotation (see {@link CredentialState.sessionId}).
14
+ * Legacy tokens issued before sessionId existed fall back to the token
15
+ * fingerprint, so a session is always identifiable. Lets a consumer match
16
+ * "this device" against {@link SessionInfo} and pass `keepSessionId` to
17
+ * `revokeOtherSessions`.
18
+ */
19
+ sessionId?: string;
20
+ expiresAt: number;
21
+ }
22
+ /**
23
+ * What a successful auth check produces: the {@link AuthContextBase} envelope
24
+ * intersected with the credential's **typed payload** `TPayload` — the root
25
+ * fields a consumer adds to their credential model (e.g. an
26
+ * `@arbac.attenuate.*`-annotated field read by `@aooth/arbac-moost`). There is
27
+ * no free-form `claims` container; per-token data is typed, validated root
28
+ * fields that round-trip through the store and surface here by name.
29
+ */
30
+ type AuthContext<TPayload extends object = object> = AuthContextBase & TPayload;
31
+ /**
32
+ * Display metadata for stateful credentials.
33
+ * Extensible via TypeScript declaration merging:
34
+ * declare module '@aooth/auth' {
35
+ * interface CredentialMetadata { geoCountry?: string }
36
+ * }
37
+ */
38
+ interface CredentialMetadata {
39
+ ip?: string;
40
+ userAgent?: string;
41
+ fingerprint?: string;
42
+ label?: string;
43
+ /**
44
+ * Semantic credential kind — e.g. `"cli-session"` / `"pat"` — distinct from
45
+ * the internal {@link CredentialState.kind} (`access`/`refresh`) discriminator.
46
+ * Set from `IssueOptions.kind` at mint time and carried forward across
47
+ * rotation with the rest of `metadata`, so the whole session family shares it.
48
+ * Stored here (not as a top-level envelope column) so it round-trips through
49
+ * every store with no schema change; surfaced as {@link SessionInfo.kind} and
50
+ * the `listSessions({ kind })` filter. Absent ⇒ an ordinary interactive session.
51
+ */
52
+ credentialKind?: string;
53
+ }
54
+ /**
55
+ * Persisted state of a credential — the fixed **envelope**. A consumer's
56
+ * per-token payload is NOT a field here; it is carried as additional typed
57
+ * root fields intersected via `CredentialState & TPayload` (the orchestrator,
58
+ * stores, and atscript-db adapter are generic over that payload). Reserved
59
+ * envelope keys (see {@link credentialPayloadOf}) must not be reused as
60
+ * payload field names.
61
+ */
62
+ interface CredentialState {
63
+ userId: string;
64
+ issuedAt: number;
65
+ expiresAt: number;
66
+ metadata?: CredentialMetadata;
67
+ /**
68
+ * Discriminant between access and refresh credentials persisted in the same store.
69
+ * Defaults to "access" when omitted.
70
+ */
71
+ kind?: "access" | "refresh";
72
+ /** For rotated refresh tokens — id of the parent credential this one replaced */
73
+ parentCredentialId?: string;
74
+ /** Timestamp of rotation; used by sliding rotation grace period */
75
+ rotatedAt?: number;
76
+ /**
77
+ * Stable id of the session (token family) this credential belongs to. Minted
78
+ * once by `issue()` and copied forward onto every rotation (access + refresh),
79
+ * so a single login = one `sessionId` for its whole lifetime. The keystone for
80
+ * grouping access/refresh/rotations into one row in {@link SessionInfo} and for
81
+ * per-session revoke. Opaque + random; never derived from token material.
82
+ */
83
+ sessionId?: string;
84
+ /**
85
+ * Last activity timestamp for the session. Only written when
86
+ * `AuthCredential` is configured with `trackLastSeen`; otherwise undefined and
87
+ * consumers fall back to `issuedAt`. See {@link SessionInfo.lastSeenAt}.
88
+ */
89
+ lastSeenAt?: number;
90
+ }
91
+ /**
92
+ * One logical session — a token family collapsed into a single row. Returned by
93
+ * `AuthCredential.listSessions`; the public surface the "active sessions" UI
94
+ * reads (richer than {@link AuthContext}, additive). Stores only raw facts;
95
+ * device/browser/os/location are derived at read time via {@link SessionEnricher}.
96
+ */
97
+ interface SessionInfo {
98
+ /** Stable across rotation; see {@link CredentialState.sessionId}. */
99
+ sessionId: string;
100
+ userId: string;
101
+ /** `issuedAt` of the session's origin credential. */
102
+ createdAt: number;
103
+ /** Newest activity time across the family; falls back to `createdAt` when untracked. */
104
+ lastSeenAt?: number;
105
+ /** Expiry of the session's live refresh token (or access token when no refresh). */
106
+ expiresAt: number;
107
+ /** Set by the caller (e.g. `SessionsController`) when this is the caller's own session. */
108
+ current?: boolean;
109
+ metadata?: CredentialMetadata;
110
+ /**
111
+ * Semantic credential kind of the family (`metadata.credentialKind`) — e.g.
112
+ * `"cli-session"` / `"pat"`. Omitted for ordinary interactive sessions. Lets a
113
+ * UI segment non-browser credentials into their own bucket; the matching
114
+ * filter is `listSessions({ kind })`.
115
+ */
116
+ kind?: string;
117
+ }
118
+ /**
119
+ * A {@link SessionInfo} enriched with derived, non-stored fields. Produced by a
120
+ * consumer-supplied {@link SessionEnricher} at read time so aooth ships no
121
+ * UA-parser or geo dependency and stores no derived data.
122
+ */
123
+ interface EnrichedSession extends SessionInfo {
124
+ device?: string;
125
+ browser?: string;
126
+ os?: string;
127
+ location?: string;
128
+ geo?: {
129
+ country?: string;
130
+ city?: string;
131
+ };
132
+ }
133
+ /**
134
+ * Read-time enrichment hook. Maps a raw {@link SessionInfo} to an
135
+ * {@link EnrichedSession} (e.g. parse `metadata.userAgent`, GeoIP-lookup
136
+ * `metadata.ip`). Plugged in via `listSessions(userId, { enrich })`.
137
+ */
138
+ type SessionEnricher = (s: SessionInfo) => EnrichedSession | Promise<EnrichedSession>;
139
+ /**
140
+ * Result of issuing a credential or refreshing it.
141
+ */
142
+ interface IssueResult {
143
+ accessToken: string;
144
+ refreshToken?: string;
145
+ accessExpiresAt: number;
146
+ refreshExpiresAt?: number;
147
+ }
148
+ /**
149
+ * Refresh token configuration.
150
+ */
151
+ interface RefreshConfig {
152
+ /** Refresh token lifetime in milliseconds. */
153
+ ttl: number;
154
+ /**
155
+ * Rotation strategy. Defaults to 'sliding'.
156
+ *
157
+ * - `'none'` — issue a new access token only; the refresh token stays in place.
158
+ * - `'sliding'` — rotate the refresh token on every use and **slide** its
159
+ * expiry forward (`now + ttl`); a rolling session that lives as long as it
160
+ * keeps being used.
161
+ * - `'always'` — rotate the refresh token on every use but keep a **fixed**
162
+ * session ceiling: each rotated token inherits the family's original
163
+ * `expiresAt`, so the session has an absolute maximum lifetime regardless of
164
+ * activity.
165
+ *
166
+ * Both `'sliding'` and `'always'` are **grace-tolerant** on stateful stores:
167
+ * a benign concurrent refresh (multi-tab / parallel requests presenting the
168
+ * just-rotated token) within {@link rotationGraceMs} re-issues a fresh pair
169
+ * instead of being mistaken for token theft. Because the grace window is
170
+ * tracked in the store (via {@link CredentialState.rotatedAt}), it is correct
171
+ * across multiple app instances.
172
+ *
173
+ * Note: the grace window needs a stateful store (one with `listForUser`).
174
+ * Stateless stores (JWT, Encapsulated) cannot mutate an issued token in place;
175
+ * after the first rotation the old refresh becomes unusable, so on those
176
+ * stores both `'sliding'` and `'always'` fall back to single-use semantics
177
+ * with a process-local reuse signal (no cross-instance grace).
178
+ */
179
+ rotation?: "none" | "always" | "sliding";
180
+ /** Grace period for sliding/always rotation, in milliseconds. Defaults to 30_000. */
181
+ rotationGraceMs?: number;
182
+ /**
183
+ * Revocation scope when refresh-token reuse is detected (replay after grace,
184
+ * or — on stateless stores — replay of a single-use token). Defaults to
185
+ * `'session'`: revoke only the compromised token family
186
+ * ({@link AuthCredential.revokeSession}), the OAuth-best-practice response.
187
+ * `'user'` revokes every session for the user
188
+ * ({@link AuthCredential.revokeAllForUser}) — more aggressive, opt-in. On
189
+ * stateless stores that cannot enumerate sessions, `'session'` falls back to
190
+ * the user-wide revocation epoch regardless.
191
+ */
192
+ reuseResponse?: "session" | "user";
193
+ /** Theft detection hook — invoked when a previously-rotated refresh is reused. */
194
+ onRotationReuse?: (state: CredentialState) => void;
195
+ }
196
+ //#endregion
197
+ //#region src/stores/store.d.ts
198
+ /**
199
+ * Pluggable credential storage. Implementations: Memory, JWT, Encapsulated.
200
+ * Same shape works for stateful and stateless stores. Stateless stores treat
201
+ * the token as the state itself (sign/encrypt on persist, verify/decrypt on retrieve).
202
+ *
203
+ * The store is generic over the credential's typed **payload** `TPayload` — the
204
+ * root fields a consumer adds to their credential model. Every state it handles
205
+ * is `CredentialState & TPayload` (the envelope plus those flat root fields; no
206
+ * `claims` container). `TPayload extends object` widens to arrays/classes, so
207
+ * pass a plain object type (e.g. `{ scope: string; tier: number }`) whose
208
+ * values survive a JSON round-trip on stateless stores.
209
+ */
210
+ interface CredentialStore<TPayload extends object = object> {
211
+ /** Persist state, return token. For stateless stores, token IS the state. */
212
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
213
+ /** Retrieve state from token. Returns null if expired/invalid/revoked. */
214
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
215
+ /** Retrieve + invalidate (single-use). For stateless: needs denylist. */
216
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
217
+ /**
218
+ * Update state. The returned token MAY differ from the input — stateful
219
+ * stores typically return the same token, while stateless stores re-issue
220
+ * a new token (denylisting the old). Callers must use the returned value.
221
+ */
222
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
223
+ /** Revoke a single token. For stateless: requires denylist; otherwise throws. */
224
+ revoke(token: string): Promise<void>;
225
+ /**
226
+ * Revoke all credentials for a user. Returns count revoked.
227
+ * Stateless stores cannot enumerate individual tokens; they MAY implement
228
+ * the cascade via a per-user revocation epoch (see `CredentialStoreJwt`)
229
+ * and return a sentinel `1` to indicate "revocation took effect".
230
+ */
231
+ revokeAllForUser(userId: string): Promise<number>;
232
+ /** List active credentials for a user (stateful only). */
233
+ listForUser?(userId: string): Promise<Array<CredentialState & TPayload & {
234
+ token: string;
235
+ }>>;
236
+ /**
237
+ * Optional last-activity stamp. Set `state.lastSeenAt = at` for the token if
238
+ * it exists; no-op otherwise. Backs `AuthCredential`'s
239
+ * `trackLastSeen: 'validate'` mode. Stateless stores omit it (the token is
240
+ * immutable once issued).
241
+ */
242
+ touch?(token: string, at: number): Promise<void>;
243
+ /**
244
+ * Derive a stable, domain-separated 32-byte subkey from this store's
245
+ * symmetric secret via HKDF-SHA256. Used so other subsystems (e.g. the
246
+ * workflow-state encryption key) can reuse the auth secret WITHOUT the raw
247
+ * secret ever leaving the store. Only implemented by stores backed by a
248
+ * symmetric secret (JWT-HMAC, Encapsulated); stateful/asymmetric stores
249
+ * omit it. `label` provides domain separation (different label → different key).
250
+ */
251
+ deriveSubkey?(label: string): Buffer;
252
+ }
253
+ /**
254
+ * Denylist for stateless revocation. Stores revoked-but-not-yet-expired token IDs.
255
+ * Auto-bounded by token TTLs.
256
+ */
257
+ interface DenylistStore {
258
+ add(jti: string, expiresAt: number): Promise<void>;
259
+ has(jti: string): Promise<boolean>;
260
+ cleanup(): Promise<number>;
261
+ }
262
+ //#endregion
263
+ export { CredentialState as a, RefreshConfig as c, CredentialMetadata as i, SessionEnricher as l, DenylistStore as n, EnrichedSession as o, AuthContext as r, IssueResult as s, CredentialStore as t, SessionInfo as u };
@@ -0,0 +1,263 @@
1
+ //#region src/credential/types.d.ts
2
+ /**
3
+ * The read-time envelope a successful auth check always produces — the fixed
4
+ * fields, before the credential's typed payload is merged in. See
5
+ * {@link AuthContext}.
6
+ */
7
+ interface AuthContextBase {
8
+ userId: string;
9
+ method: "session" | "token";
10
+ credentialId: string;
11
+ /**
12
+ * Stable id of the session (token family) that authenticated this request.
13
+ * Survives refresh-token rotation (see {@link CredentialState.sessionId}).
14
+ * Legacy tokens issued before sessionId existed fall back to the token
15
+ * fingerprint, so a session is always identifiable. Lets a consumer match
16
+ * "this device" against {@link SessionInfo} and pass `keepSessionId` to
17
+ * `revokeOtherSessions`.
18
+ */
19
+ sessionId?: string;
20
+ expiresAt: number;
21
+ }
22
+ /**
23
+ * What a successful auth check produces: the {@link AuthContextBase} envelope
24
+ * intersected with the credential's **typed payload** `TPayload` — the root
25
+ * fields a consumer adds to their credential model (e.g. an
26
+ * `@arbac.attenuate.*`-annotated field read by `@aooth/arbac-moost`). There is
27
+ * no free-form `claims` container; per-token data is typed, validated root
28
+ * fields that round-trip through the store and surface here by name.
29
+ */
30
+ type AuthContext<TPayload extends object = object> = AuthContextBase & TPayload;
31
+ /**
32
+ * Display metadata for stateful credentials.
33
+ * Extensible via TypeScript declaration merging:
34
+ * declare module '@aooth/auth' {
35
+ * interface CredentialMetadata { geoCountry?: string }
36
+ * }
37
+ */
38
+ interface CredentialMetadata {
39
+ ip?: string;
40
+ userAgent?: string;
41
+ fingerprint?: string;
42
+ label?: string;
43
+ /**
44
+ * Semantic credential kind — e.g. `"cli-session"` / `"pat"` — distinct from
45
+ * the internal {@link CredentialState.kind} (`access`/`refresh`) discriminator.
46
+ * Set from `IssueOptions.kind` at mint time and carried forward across
47
+ * rotation with the rest of `metadata`, so the whole session family shares it.
48
+ * Stored here (not as a top-level envelope column) so it round-trips through
49
+ * every store with no schema change; surfaced as {@link SessionInfo.kind} and
50
+ * the `listSessions({ kind })` filter. Absent ⇒ an ordinary interactive session.
51
+ */
52
+ credentialKind?: string;
53
+ }
54
+ /**
55
+ * Persisted state of a credential — the fixed **envelope**. A consumer's
56
+ * per-token payload is NOT a field here; it is carried as additional typed
57
+ * root fields intersected via `CredentialState & TPayload` (the orchestrator,
58
+ * stores, and atscript-db adapter are generic over that payload). Reserved
59
+ * envelope keys (see {@link credentialPayloadOf}) must not be reused as
60
+ * payload field names.
61
+ */
62
+ interface CredentialState {
63
+ userId: string;
64
+ issuedAt: number;
65
+ expiresAt: number;
66
+ metadata?: CredentialMetadata;
67
+ /**
68
+ * Discriminant between access and refresh credentials persisted in the same store.
69
+ * Defaults to "access" when omitted.
70
+ */
71
+ kind?: "access" | "refresh";
72
+ /** For rotated refresh tokens — id of the parent credential this one replaced */
73
+ parentCredentialId?: string;
74
+ /** Timestamp of rotation; used by sliding rotation grace period */
75
+ rotatedAt?: number;
76
+ /**
77
+ * Stable id of the session (token family) this credential belongs to. Minted
78
+ * once by `issue()` and copied forward onto every rotation (access + refresh),
79
+ * so a single login = one `sessionId` for its whole lifetime. The keystone for
80
+ * grouping access/refresh/rotations into one row in {@link SessionInfo} and for
81
+ * per-session revoke. Opaque + random; never derived from token material.
82
+ */
83
+ sessionId?: string;
84
+ /**
85
+ * Last activity timestamp for the session. Only written when
86
+ * `AuthCredential` is configured with `trackLastSeen`; otherwise undefined and
87
+ * consumers fall back to `issuedAt`. See {@link SessionInfo.lastSeenAt}.
88
+ */
89
+ lastSeenAt?: number;
90
+ }
91
+ /**
92
+ * One logical session — a token family collapsed into a single row. Returned by
93
+ * `AuthCredential.listSessions`; the public surface the "active sessions" UI
94
+ * reads (richer than {@link AuthContext}, additive). Stores only raw facts;
95
+ * device/browser/os/location are derived at read time via {@link SessionEnricher}.
96
+ */
97
+ interface SessionInfo {
98
+ /** Stable across rotation; see {@link CredentialState.sessionId}. */
99
+ sessionId: string;
100
+ userId: string;
101
+ /** `issuedAt` of the session's origin credential. */
102
+ createdAt: number;
103
+ /** Newest activity time across the family; falls back to `createdAt` when untracked. */
104
+ lastSeenAt?: number;
105
+ /** Expiry of the session's live refresh token (or access token when no refresh). */
106
+ expiresAt: number;
107
+ /** Set by the caller (e.g. `SessionsController`) when this is the caller's own session. */
108
+ current?: boolean;
109
+ metadata?: CredentialMetadata;
110
+ /**
111
+ * Semantic credential kind of the family (`metadata.credentialKind`) — e.g.
112
+ * `"cli-session"` / `"pat"`. Omitted for ordinary interactive sessions. Lets a
113
+ * UI segment non-browser credentials into their own bucket; the matching
114
+ * filter is `listSessions({ kind })`.
115
+ */
116
+ kind?: string;
117
+ }
118
+ /**
119
+ * A {@link SessionInfo} enriched with derived, non-stored fields. Produced by a
120
+ * consumer-supplied {@link SessionEnricher} at read time so aooth ships no
121
+ * UA-parser or geo dependency and stores no derived data.
122
+ */
123
+ interface EnrichedSession extends SessionInfo {
124
+ device?: string;
125
+ browser?: string;
126
+ os?: string;
127
+ location?: string;
128
+ geo?: {
129
+ country?: string;
130
+ city?: string;
131
+ };
132
+ }
133
+ /**
134
+ * Read-time enrichment hook. Maps a raw {@link SessionInfo} to an
135
+ * {@link EnrichedSession} (e.g. parse `metadata.userAgent`, GeoIP-lookup
136
+ * `metadata.ip`). Plugged in via `listSessions(userId, { enrich })`.
137
+ */
138
+ type SessionEnricher = (s: SessionInfo) => EnrichedSession | Promise<EnrichedSession>;
139
+ /**
140
+ * Result of issuing a credential or refreshing it.
141
+ */
142
+ interface IssueResult {
143
+ accessToken: string;
144
+ refreshToken?: string;
145
+ accessExpiresAt: number;
146
+ refreshExpiresAt?: number;
147
+ }
148
+ /**
149
+ * Refresh token configuration.
150
+ */
151
+ interface RefreshConfig {
152
+ /** Refresh token lifetime in milliseconds. */
153
+ ttl: number;
154
+ /**
155
+ * Rotation strategy. Defaults to 'sliding'.
156
+ *
157
+ * - `'none'` — issue a new access token only; the refresh token stays in place.
158
+ * - `'sliding'` — rotate the refresh token on every use and **slide** its
159
+ * expiry forward (`now + ttl`); a rolling session that lives as long as it
160
+ * keeps being used.
161
+ * - `'always'` — rotate the refresh token on every use but keep a **fixed**
162
+ * session ceiling: each rotated token inherits the family's original
163
+ * `expiresAt`, so the session has an absolute maximum lifetime regardless of
164
+ * activity.
165
+ *
166
+ * Both `'sliding'` and `'always'` are **grace-tolerant** on stateful stores:
167
+ * a benign concurrent refresh (multi-tab / parallel requests presenting the
168
+ * just-rotated token) within {@link rotationGraceMs} re-issues a fresh pair
169
+ * instead of being mistaken for token theft. Because the grace window is
170
+ * tracked in the store (via {@link CredentialState.rotatedAt}), it is correct
171
+ * across multiple app instances.
172
+ *
173
+ * Note: the grace window needs a stateful store (one with `listForUser`).
174
+ * Stateless stores (JWT, Encapsulated) cannot mutate an issued token in place;
175
+ * after the first rotation the old refresh becomes unusable, so on those
176
+ * stores both `'sliding'` and `'always'` fall back to single-use semantics
177
+ * with a process-local reuse signal (no cross-instance grace).
178
+ */
179
+ rotation?: "none" | "always" | "sliding";
180
+ /** Grace period for sliding/always rotation, in milliseconds. Defaults to 30_000. */
181
+ rotationGraceMs?: number;
182
+ /**
183
+ * Revocation scope when refresh-token reuse is detected (replay after grace,
184
+ * or — on stateless stores — replay of a single-use token). Defaults to
185
+ * `'session'`: revoke only the compromised token family
186
+ * ({@link AuthCredential.revokeSession}), the OAuth-best-practice response.
187
+ * `'user'` revokes every session for the user
188
+ * ({@link AuthCredential.revokeAllForUser}) — more aggressive, opt-in. On
189
+ * stateless stores that cannot enumerate sessions, `'session'` falls back to
190
+ * the user-wide revocation epoch regardless.
191
+ */
192
+ reuseResponse?: "session" | "user";
193
+ /** Theft detection hook — invoked when a previously-rotated refresh is reused. */
194
+ onRotationReuse?: (state: CredentialState) => void;
195
+ }
196
+ //#endregion
197
+ //#region src/stores/store.d.ts
198
+ /**
199
+ * Pluggable credential storage. Implementations: Memory, JWT, Encapsulated.
200
+ * Same shape works for stateful and stateless stores. Stateless stores treat
201
+ * the token as the state itself (sign/encrypt on persist, verify/decrypt on retrieve).
202
+ *
203
+ * The store is generic over the credential's typed **payload** `TPayload` — the
204
+ * root fields a consumer adds to their credential model. Every state it handles
205
+ * is `CredentialState & TPayload` (the envelope plus those flat root fields; no
206
+ * `claims` container). `TPayload extends object` widens to arrays/classes, so
207
+ * pass a plain object type (e.g. `{ scope: string; tier: number }`) whose
208
+ * values survive a JSON round-trip on stateless stores.
209
+ */
210
+ interface CredentialStore<TPayload extends object = object> {
211
+ /** Persist state, return token. For stateless stores, token IS the state. */
212
+ persist(state: CredentialState & TPayload, ttl?: number): Promise<string>;
213
+ /** Retrieve state from token. Returns null if expired/invalid/revoked. */
214
+ retrieve(token: string): Promise<(CredentialState & TPayload) | null>;
215
+ /** Retrieve + invalidate (single-use). For stateless: needs denylist. */
216
+ consume(token: string): Promise<(CredentialState & TPayload) | null>;
217
+ /**
218
+ * Update state. The returned token MAY differ from the input — stateful
219
+ * stores typically return the same token, while stateless stores re-issue
220
+ * a new token (denylisting the old). Callers must use the returned value.
221
+ */
222
+ update(token: string, state: CredentialState & TPayload): Promise<string>;
223
+ /** Revoke a single token. For stateless: requires denylist; otherwise throws. */
224
+ revoke(token: string): Promise<void>;
225
+ /**
226
+ * Revoke all credentials for a user. Returns count revoked.
227
+ * Stateless stores cannot enumerate individual tokens; they MAY implement
228
+ * the cascade via a per-user revocation epoch (see `CredentialStoreJwt`)
229
+ * and return a sentinel `1` to indicate "revocation took effect".
230
+ */
231
+ revokeAllForUser(userId: string): Promise<number>;
232
+ /** List active credentials for a user (stateful only). */
233
+ listForUser?(userId: string): Promise<Array<CredentialState & TPayload & {
234
+ token: string;
235
+ }>>;
236
+ /**
237
+ * Optional last-activity stamp. Set `state.lastSeenAt = at` for the token if
238
+ * it exists; no-op otherwise. Backs `AuthCredential`'s
239
+ * `trackLastSeen: 'validate'` mode. Stateless stores omit it (the token is
240
+ * immutable once issued).
241
+ */
242
+ touch?(token: string, at: number): Promise<void>;
243
+ /**
244
+ * Derive a stable, domain-separated 32-byte subkey from this store's
245
+ * symmetric secret via HKDF-SHA256. Used so other subsystems (e.g. the
246
+ * workflow-state encryption key) can reuse the auth secret WITHOUT the raw
247
+ * secret ever leaving the store. Only implemented by stores backed by a
248
+ * symmetric secret (JWT-HMAC, Encapsulated); stateful/asymmetric stores
249
+ * omit it. `label` provides domain separation (different label → different key).
250
+ */
251
+ deriveSubkey?(label: string): Buffer;
252
+ }
253
+ /**
254
+ * Denylist for stateless revocation. Stores revoked-but-not-yet-expired token IDs.
255
+ * Auto-bounded by token TTLs.
256
+ */
257
+ interface DenylistStore {
258
+ add(jti: string, expiresAt: number): Promise<void>;
259
+ has(jti: string): Promise<boolean>;
260
+ cleanup(): Promise<number>;
261
+ }
262
+ //#endregion
263
+ export { CredentialState as a, RefreshConfig as c, CredentialMetadata as i, SessionEnricher as l, DenylistStore as n, EnrichedSession as o, AuthContext as r, IssueResult as s, CredentialStore as t, SessionInfo as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Auth method layer for aoothjs (sessions, tokens, password reset, MFA primitives)",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "files": [
27
27
  "dist",
28
- "src/atscript-db/auth-credential.as"
28
+ "src/atscript-db/auth-credential.as",
29
+ "src/atscript-db/auth-credential.as.d.ts"
29
30
  ],
30
31
  "type": "module",
31
32
  "sideEffects": false,
@@ -43,12 +44,29 @@
43
44
  "import": "./dist/redis.mjs",
44
45
  "require": "./dist/redis.cjs"
45
46
  },
47
+ "./client": {
48
+ "types": "./dist/client.d.mts",
49
+ "import": "./dist/client.mjs",
50
+ "require": "./dist/client.cjs"
51
+ },
52
+ "./authz": {
53
+ "types": "./dist/authz.d.mts",
54
+ "import": "./dist/authz.mjs",
55
+ "require": "./dist/authz.cjs"
56
+ },
46
57
  "./atscript-db": {
47
58
  "types": "./dist/atscript-db.d.mts",
48
59
  "import": "./dist/atscript-db.mjs",
49
60
  "require": "./dist/atscript-db.cjs"
50
61
  },
51
- "./atscript-db/model.as": "./src/atscript-db/auth-credential.as",
62
+ "./atscript-db/model": {
63
+ "types": "./src/atscript-db/auth-credential.as.d.ts",
64
+ "default": "./src/atscript-db/auth-credential.as"
65
+ },
66
+ "./atscript-db/model.as": {
67
+ "types": "./src/atscript-db/auth-credential.as.d.ts",
68
+ "default": "./src/atscript-db/auth-credential.as"
69
+ },
52
70
  "./package.json": "./package.json"
53
71
  },
54
72
  "publishConfig": {
@@ -56,17 +74,17 @@
56
74
  },
57
75
  "dependencies": {
58
76
  "jose": "^6.2.3",
59
- "@aooth/user": "0.1.6"
77
+ "@aooth/user": "0.1.8"
60
78
  },
61
79
  "devDependencies": {
62
- "@atscript/core": "^0.1.64",
63
- "@atscript/db": "^0.1.90",
64
- "@atscript/db-sql-tools": "^0.1.90",
65
- "@atscript/db-sqlite": "^0.1.90",
66
- "@atscript/typescript": "^0.1.64",
80
+ "@atscript/core": "^0.1.69",
81
+ "@atscript/db": "^0.1.96",
82
+ "@atscript/db-sql-tools": "^0.1.96",
83
+ "@atscript/db-sqlite": "^0.1.96",
84
+ "@atscript/typescript": "^0.1.69",
67
85
  "@types/better-sqlite3": "^7.6.13",
68
86
  "better-sqlite3": "^12.6.2",
69
- "unplugin-atscript": "^0.1.64"
87
+ "unplugin-atscript": "^0.1.69"
70
88
  },
71
89
  "peerDependencies": {
72
90
  "@atscript/db": ">=0.1.79"