@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/atscript-db.cjs +36 -25
- package/dist/atscript-db.d.cts +28 -22
- package/dist/atscript-db.d.mts +28 -22
- package/dist/atscript-db.mjs +36 -25
- package/dist/authz.cjs +168 -0
- package/dist/authz.d.cts +252 -0
- package/dist/authz.d.mts +252 -0
- package/dist/authz.mjs +161 -0
- package/dist/client.cjs +59 -0
- package/dist/client.d.cts +64 -0
- package/dist/client.d.mts +64 -0
- package/dist/client.mjs +58 -0
- package/dist/clock-Bdsep_1j.mjs +4 -0
- package/dist/clock-BjXa0LXb.d.cts +14 -0
- package/dist/clock-BjXa0LXb.d.mts +14 -0
- package/dist/clock-Bl-H3eqE.cjs +9 -0
- package/dist/index.cjs +294 -65
- package/dist/index.d.cts +166 -44
- package/dist/index.d.mts +166 -44
- package/dist/index.mjs +289 -60
- package/dist/payload-BJjvj8AH.cjs +32 -0
- package/dist/payload-D-DzH5-J.mjs +27 -0
- package/dist/redis.cjs +9 -0
- package/dist/redis.d.cts +8 -7
- package/dist/redis.d.mts +8 -7
- package/dist/redis.mjs +9 -0
- package/dist/store-BG6m6oSJ.d.cts +263 -0
- package/dist/store-BG6m6oSJ.d.mts +263 -0
- package/package.json +28 -10
- package/src/atscript-db/auth-credential.as +16 -10
- package/src/atscript-db/auth-credential.as.d.ts +68 -0
- package/dist/store-B1t8KkfA.d.mts +0 -124
- package/dist/store-untAtWQz.d.cts +0 -124
package/dist/redis.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-
|
|
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<
|
|
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
|
|
62
|
-
retrieve(token: string): Promise<CredentialState
|
|
63
|
-
consume(token: string): Promise<CredentialState
|
|
64
|
-
update(token: string, state: CredentialState
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
77
|
+
"@aooth/user": "0.1.8"
|
|
60
78
|
},
|
|
61
79
|
"devDependencies": {
|
|
62
|
-
"@atscript/core": "^0.1.
|
|
63
|
-
"@atscript/db": "^0.1.
|
|
64
|
-
"@atscript/db-sql-tools": "^0.1.
|
|
65
|
-
"@atscript/db-sqlite": "^0.1.
|
|
66
|
-
"@atscript/typescript": "^0.1.
|
|
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.
|
|
87
|
+
"unplugin-atscript": "^0.1.69"
|
|
70
88
|
},
|
|
71
89
|
"peerDependencies": {
|
|
72
90
|
"@atscript/db": ">=0.1.79"
|