@aooth/auth 0.1.1
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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/atscript-db.cjs +113 -0
- package/dist/atscript-db.d.cts +85 -0
- package/dist/atscript-db.d.mts +85 -0
- package/dist/atscript-db.mjs +112 -0
- package/dist/index.cjs +741 -0
- package/dist/index.d.cts +386 -0
- package/dist/index.d.mts +386 -0
- package/dist/index.mjs +733 -0
- package/dist/redis.cjs +142 -0
- package/dist/redis.d.cts +91 -0
- package/dist/redis.d.mts +91 -0
- package/dist/redis.mjs +140 -0
- package/dist/store-B1t8KkfA.d.mts +124 -0
- package/dist/store-untAtWQz.d.cts +124 -0
- package/package.json +75 -0
- package/src/atscript-db/auth-credential.as +44 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_crypto = require("node:crypto");
|
|
3
|
+
let jose = require("jose");
|
|
4
|
+
//#region src/errors.ts
|
|
5
|
+
const defaultMessages = {
|
|
6
|
+
INVALID_TOKEN: "Invalid token",
|
|
7
|
+
TOKEN_EXPIRED: "Token has expired",
|
|
8
|
+
TOKEN_REVOKED: "Token has been revoked",
|
|
9
|
+
REFRESH_REUSE_DETECTED: "Refresh token reuse detected",
|
|
10
|
+
STATELESS_OPERATION_UNSUPPORTED: "Operation is not supported by stateless credential store",
|
|
11
|
+
MAX_CONCURRENT_REACHED: "Maximum concurrent credentials reached for user",
|
|
12
|
+
INVALID_CONFIG: "Invalid auth configuration"
|
|
13
|
+
};
|
|
14
|
+
var AuthError = class extends Error {
|
|
15
|
+
name = "AuthError";
|
|
16
|
+
constructor(type, message, details) {
|
|
17
|
+
super(message ?? defaultMessages[type]);
|
|
18
|
+
this.type = type;
|
|
19
|
+
this.details = details;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/utils/clock.ts
|
|
24
|
+
const defaultClock = { now: () => Date.now() };
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/stores/memory.ts
|
|
27
|
+
/**
|
|
28
|
+
* In-memory implementation of CredentialStore using random opaque token IDs.
|
|
29
|
+
*
|
|
30
|
+
* - `Map<token, state>` is the primary index.
|
|
31
|
+
* - `Map<userId, Set<token>>` is a secondary index for `revokeAllForUser` /
|
|
32
|
+
* `listForUser` without scanning the full primary map.
|
|
33
|
+
* - Both maps are kept in sync on every mutation.
|
|
34
|
+
*/
|
|
35
|
+
var CredentialStoreMemory = class {
|
|
36
|
+
states = /* @__PURE__ */ new Map();
|
|
37
|
+
byUser = /* @__PURE__ */ new Map();
|
|
38
|
+
clock;
|
|
39
|
+
constructor(opts) {
|
|
40
|
+
this.clock = opts?.clock ?? defaultClock;
|
|
41
|
+
}
|
|
42
|
+
async persist(state, ttl) {
|
|
43
|
+
const token = (0, node_crypto.randomUUID)();
|
|
44
|
+
const stored = { ...state };
|
|
45
|
+
if (typeof ttl === "number") stored.expiresAt = this.clock.now() + ttl;
|
|
46
|
+
this.states.set(token, stored);
|
|
47
|
+
this.indexAdd(stored.userId, token);
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
50
|
+
async retrieve(token) {
|
|
51
|
+
const state = this.states.get(token);
|
|
52
|
+
if (!state) return null;
|
|
53
|
+
if (state.expiresAt <= this.clock.now()) {
|
|
54
|
+
this.states.delete(token);
|
|
55
|
+
this.indexRemove(state.userId, token);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { ...state };
|
|
59
|
+
}
|
|
60
|
+
async consume(token) {
|
|
61
|
+
const state = await this.retrieve(token);
|
|
62
|
+
if (!state) return null;
|
|
63
|
+
await this.revoke(token);
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
async update(token, state) {
|
|
67
|
+
const existing = this.states.get(token);
|
|
68
|
+
if (!existing) return token;
|
|
69
|
+
if (existing.userId !== state.userId) {
|
|
70
|
+
this.indexRemove(existing.userId, token);
|
|
71
|
+
this.indexAdd(state.userId, token);
|
|
72
|
+
}
|
|
73
|
+
this.states.set(token, { ...state });
|
|
74
|
+
return token;
|
|
75
|
+
}
|
|
76
|
+
async revoke(token) {
|
|
77
|
+
const state = this.states.get(token);
|
|
78
|
+
if (!state) return;
|
|
79
|
+
this.states.delete(token);
|
|
80
|
+
this.indexRemove(state.userId, token);
|
|
81
|
+
}
|
|
82
|
+
async revokeAllForUser(userId) {
|
|
83
|
+
const tokens = this.byUser.get(userId);
|
|
84
|
+
if (!tokens || tokens.size === 0) return 0;
|
|
85
|
+
let count = 0;
|
|
86
|
+
for (const token of tokens) if (this.states.delete(token)) count++;
|
|
87
|
+
this.byUser.delete(userId);
|
|
88
|
+
return count;
|
|
89
|
+
}
|
|
90
|
+
async listForUser(userId) {
|
|
91
|
+
const tokens = this.byUser.get(userId);
|
|
92
|
+
if (!tokens || tokens.size === 0) return [];
|
|
93
|
+
const now = this.clock.now();
|
|
94
|
+
const out = [];
|
|
95
|
+
const expired = [];
|
|
96
|
+
for (const token of tokens) {
|
|
97
|
+
const state = this.states.get(token);
|
|
98
|
+
if (!state) {
|
|
99
|
+
expired.push(token);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (state.expiresAt <= now) {
|
|
103
|
+
expired.push(token);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
out.push({
|
|
107
|
+
...state,
|
|
108
|
+
token
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
for (const token of expired) {
|
|
112
|
+
this.states.delete(token);
|
|
113
|
+
tokens.delete(token);
|
|
114
|
+
}
|
|
115
|
+
if (tokens.size === 0) this.byUser.delete(userId);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
indexAdd(userId, token) {
|
|
119
|
+
let set = this.byUser.get(userId);
|
|
120
|
+
if (!set) {
|
|
121
|
+
set = /* @__PURE__ */ new Set();
|
|
122
|
+
this.byUser.set(userId, set);
|
|
123
|
+
}
|
|
124
|
+
set.add(token);
|
|
125
|
+
}
|
|
126
|
+
indexRemove(userId, token) {
|
|
127
|
+
const set = this.byUser.get(userId);
|
|
128
|
+
if (!set) return;
|
|
129
|
+
set.delete(token);
|
|
130
|
+
if (set.size === 0) this.byUser.delete(userId);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/stores/denylist-memory.ts
|
|
135
|
+
/**
|
|
136
|
+
* In-memory denylist for stateless revocation.
|
|
137
|
+
*
|
|
138
|
+
* Stores a map from JTI (or any opaque identifier) to absolute expiry timestamp.
|
|
139
|
+
* `has` returns false for entries whose TTL has elapsed and lazily removes them.
|
|
140
|
+
* `cleanup` performs a sweep across the map and returns the number of entries
|
|
141
|
+
* removed, useful for periodic compaction in long-lived processes.
|
|
142
|
+
*/
|
|
143
|
+
var DenylistStoreMemory = class {
|
|
144
|
+
entries = /* @__PURE__ */ new Map();
|
|
145
|
+
clock;
|
|
146
|
+
constructor(opts) {
|
|
147
|
+
this.clock = opts?.clock ?? defaultClock;
|
|
148
|
+
}
|
|
149
|
+
async add(jti, expiresAt) {
|
|
150
|
+
this.entries.set(jti, expiresAt);
|
|
151
|
+
}
|
|
152
|
+
async has(jti) {
|
|
153
|
+
const expiresAt = this.entries.get(jti);
|
|
154
|
+
if (expiresAt === void 0) return false;
|
|
155
|
+
if (expiresAt <= this.clock.now()) {
|
|
156
|
+
this.entries.delete(jti);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
async cleanup() {
|
|
162
|
+
const now = this.clock.now();
|
|
163
|
+
let removed = 0;
|
|
164
|
+
for (const [jti, expiresAt] of this.entries) if (expiresAt <= now) {
|
|
165
|
+
this.entries.delete(jti);
|
|
166
|
+
removed++;
|
|
167
|
+
}
|
|
168
|
+
return removed;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/stores/jwt.ts
|
|
173
|
+
const HS_ALGS = new Set([
|
|
174
|
+
"HS256",
|
|
175
|
+
"HS384",
|
|
176
|
+
"HS512"
|
|
177
|
+
]);
|
|
178
|
+
/**
|
|
179
|
+
* Stateless credential store that signs the credential state into a JWT.
|
|
180
|
+
*
|
|
181
|
+
* The token IS the state — there is no internal map. State is reconstructed
|
|
182
|
+
* by verifying signature/claims on every retrieve. Revocation requires a
|
|
183
|
+
* `denylist`; without one, `revoke`/`update`/`consume` throw
|
|
184
|
+
* STATELESS_OPERATION_UNSUPPORTED.
|
|
185
|
+
*/
|
|
186
|
+
var CredentialStoreJwt = class {
|
|
187
|
+
algorithm;
|
|
188
|
+
signingKey;
|
|
189
|
+
verifyingKey;
|
|
190
|
+
issuer;
|
|
191
|
+
audience;
|
|
192
|
+
denylist;
|
|
193
|
+
clock;
|
|
194
|
+
/**
|
|
195
|
+
* Per-user revocation epoch (ms). `revokeAllForUser` sets this to
|
|
196
|
+
* `clock.now()`; `retrieve` rejects any token whose `iatMs` predates the
|
|
197
|
+
* user's epoch (same-ms mints are accepted so recovery/invite flows can
|
|
198
|
+
* revoke and re-issue in one tick). Compensates for JWT statelessness so
|
|
199
|
+
* password-change cascades invalidate tokens minted before the change.
|
|
200
|
+
* In-memory: resets on process restart (a known JWT limitation — production
|
|
201
|
+
* deployments needing durability should back this with an external store).
|
|
202
|
+
*/
|
|
203
|
+
epochs = /* @__PURE__ */ new Map();
|
|
204
|
+
constructor(opts) {
|
|
205
|
+
this.algorithm = opts.algorithm ?? "HS256";
|
|
206
|
+
this.issuer = opts.issuer;
|
|
207
|
+
this.audience = opts.audience;
|
|
208
|
+
this.denylist = opts.denylist;
|
|
209
|
+
this.clock = opts.clock ?? defaultClock;
|
|
210
|
+
if (HS_ALGS.has(this.algorithm)) {
|
|
211
|
+
if (opts.secret == null) throw new AuthError("INVALID_CONFIG", `Algorithm ${this.algorithm} requires a 'secret' option`);
|
|
212
|
+
const key = normalizeSecret(opts.secret);
|
|
213
|
+
this.signingKey = key;
|
|
214
|
+
this.verifyingKey = key;
|
|
215
|
+
} else {
|
|
216
|
+
if (opts.privateKey == null || opts.publicKey == null) throw new AuthError("INVALID_CONFIG", `Algorithm ${this.algorithm} requires both 'privateKey' and 'publicKey' options`);
|
|
217
|
+
this.signingKey = opts.privateKey;
|
|
218
|
+
this.verifyingKey = opts.publicKey;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async persist(state, ttl) {
|
|
222
|
+
const jti = (0, node_crypto.randomUUID)();
|
|
223
|
+
const now = this.clock.now();
|
|
224
|
+
const expiresAtMs = typeof ttl === "number" ? now + ttl : state.expiresAt;
|
|
225
|
+
const stateClaim = {
|
|
226
|
+
iatMs: state.issuedAt,
|
|
227
|
+
expMs: expiresAtMs
|
|
228
|
+
};
|
|
229
|
+
if (state.kind !== void 0) stateClaim.kind = state.kind;
|
|
230
|
+
if (state.claims !== void 0) stateClaim.claims = state.claims;
|
|
231
|
+
if (state.metadata !== void 0) stateClaim.metadata = state.metadata;
|
|
232
|
+
if (state.parentCredentialId !== void 0) stateClaim.parentCredentialId = state.parentCredentialId;
|
|
233
|
+
if (state.rotatedAt !== void 0) stateClaim.rotatedAt = state.rotatedAt;
|
|
234
|
+
const builder = new jose.SignJWT({ state: stateClaim }).setProtectedHeader({ alg: this.algorithm }).setSubject(state.userId).setIssuedAt(Math.floor(state.issuedAt / 1e3)).setExpirationTime(Math.floor(expiresAtMs / 1e3)).setJti(jti);
|
|
235
|
+
if (this.issuer) builder.setIssuer(this.issuer);
|
|
236
|
+
if (this.audience) builder.setAudience(this.audience);
|
|
237
|
+
return builder.sign(this.signingKey);
|
|
238
|
+
}
|
|
239
|
+
async retrieve(token) {
|
|
240
|
+
const verified = await this.verify(token);
|
|
241
|
+
if (!verified) return null;
|
|
242
|
+
if (this.denylist && verified.payload.jti) {
|
|
243
|
+
if (await this.denylist.has(verified.payload.jti)) return null;
|
|
244
|
+
}
|
|
245
|
+
if (!this.passesEpoch(verified.payload)) return null;
|
|
246
|
+
return this.payloadToState(verified.payload);
|
|
247
|
+
}
|
|
248
|
+
async consume(token) {
|
|
249
|
+
const denylist = this.requireDenylist("consume");
|
|
250
|
+
const verified = await this.verify(token);
|
|
251
|
+
if (!verified) return null;
|
|
252
|
+
if (!this.passesEpoch(verified.payload)) return null;
|
|
253
|
+
const jti = verified.payload.jti;
|
|
254
|
+
if (jti) {
|
|
255
|
+
if (await denylist.has(jti)) return null;
|
|
256
|
+
await denylist.add(jti, this.payloadExpMs(verified.payload));
|
|
257
|
+
}
|
|
258
|
+
return this.payloadToState(verified.payload);
|
|
259
|
+
}
|
|
260
|
+
async update(token, state) {
|
|
261
|
+
const denylist = this.requireDenylist("update");
|
|
262
|
+
const verified = await this.verify(token);
|
|
263
|
+
if (verified?.payload.jti) await denylist.add(verified.payload.jti, this.payloadExpMs(verified.payload));
|
|
264
|
+
return this.persist(state);
|
|
265
|
+
}
|
|
266
|
+
async revoke(token) {
|
|
267
|
+
const denylist = this.requireDenylist("revoke");
|
|
268
|
+
const verified = await this.verify(token);
|
|
269
|
+
if (!verified?.payload.jti) return;
|
|
270
|
+
await denylist.add(verified.payload.jti, this.payloadExpMs(verified.payload));
|
|
271
|
+
}
|
|
272
|
+
async revokeAllForUser(userId) {
|
|
273
|
+
this.epochs.set(userId, this.clock.now());
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
requireDenylist(op) {
|
|
277
|
+
if (!this.denylist) throw new AuthError("STATELESS_OPERATION_UNSUPPORTED", `${op} requires a denylist on stateless JWT store`);
|
|
278
|
+
return this.denylist;
|
|
279
|
+
}
|
|
280
|
+
/** Convert payload `exp` (seconds) to ms; fall back to a 60s window. */
|
|
281
|
+
payloadExpMs(payload) {
|
|
282
|
+
return typeof payload.exp === "number" ? payload.exp * 1e3 : this.clock.now() + 6e4;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Reject tokens minted before the user's revocation epoch. Same-ms mints
|
|
286
|
+
* (`iatMs === epoch`) are accepted so a workflow can revoke and re-issue in
|
|
287
|
+
* one tick. `iatMs` mirrors `iat` at ms precision; we fall back to
|
|
288
|
+
* `iat * 1000` for tokens that predate the mirror field.
|
|
289
|
+
*/
|
|
290
|
+
passesEpoch(payload) {
|
|
291
|
+
if (typeof payload.sub !== "string") return true;
|
|
292
|
+
const epoch = this.epochs.get(payload.sub);
|
|
293
|
+
if (epoch === void 0) return true;
|
|
294
|
+
const stateClaim = payload.state ?? {};
|
|
295
|
+
return (typeof stateClaim.iatMs === "number" ? stateClaim.iatMs : typeof payload.iat === "number" ? payload.iat * 1e3 : 0) >= epoch;
|
|
296
|
+
}
|
|
297
|
+
async verify(token) {
|
|
298
|
+
try {
|
|
299
|
+
return { payload: (await (0, jose.jwtVerify)(token, this.verifyingKey, {
|
|
300
|
+
algorithms: [this.algorithm],
|
|
301
|
+
issuer: this.issuer,
|
|
302
|
+
audience: this.audience,
|
|
303
|
+
currentDate: new Date(this.clock.now())
|
|
304
|
+
})).payload };
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
payloadToState(payload) {
|
|
310
|
+
if (typeof payload.sub !== "string") return null;
|
|
311
|
+
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
|
|
312
|
+
const stateClaim = payload.state ?? {};
|
|
313
|
+
const issuedAt = typeof stateClaim.iatMs === "number" ? stateClaim.iatMs : payload.iat * 1e3;
|
|
314
|
+
const expiresAt = typeof stateClaim.expMs === "number" ? stateClaim.expMs : payload.exp * 1e3;
|
|
315
|
+
const out = {
|
|
316
|
+
userId: payload.sub,
|
|
317
|
+
issuedAt,
|
|
318
|
+
expiresAt
|
|
319
|
+
};
|
|
320
|
+
if (stateClaim.kind !== void 0) out.kind = stateClaim.kind;
|
|
321
|
+
if (stateClaim.claims !== void 0) out.claims = stateClaim.claims;
|
|
322
|
+
if (stateClaim.metadata !== void 0) out.metadata = stateClaim.metadata;
|
|
323
|
+
if (stateClaim.parentCredentialId !== void 0) out.parentCredentialId = stateClaim.parentCredentialId;
|
|
324
|
+
if (stateClaim.rotatedAt !== void 0) out.rotatedAt = stateClaim.rotatedAt;
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
function normalizeSecret(secret) {
|
|
329
|
+
if (typeof secret === "string") return new TextEncoder().encode(secret);
|
|
330
|
+
return secret;
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/stores/encapsulated.ts
|
|
334
|
+
const KEY_LEN = 32;
|
|
335
|
+
const IV_LEN = 12;
|
|
336
|
+
const TAG_LEN = 16;
|
|
337
|
+
const KDF_SALT = Buffer.from("aoothjs-auth-encapsulated-v1", "utf8");
|
|
338
|
+
/**
|
|
339
|
+
* Stateless credential store that encrypts the credential state with
|
|
340
|
+
* AES-256-GCM. The token IS the encrypted blob, base64url-encoded as
|
|
341
|
+
* `iv (12B) || ciphertext || authTag (16B)`.
|
|
342
|
+
*
|
|
343
|
+
* Same denylist semantics as {@link CredentialStoreJwt}: revocation /
|
|
344
|
+
* single-use consume / update require a denylist; otherwise these operations
|
|
345
|
+
* throw STATELESS_OPERATION_UNSUPPORTED.
|
|
346
|
+
*
|
|
347
|
+
* Key derivation:
|
|
348
|
+
* - A 32-byte `Buffer`/`Uint8Array` `secret` is used as the AES key directly
|
|
349
|
+
* (no KDF).
|
|
350
|
+
* - Any other secret (string, shorter/longer buffer) is run through scrypt
|
|
351
|
+
* with a fixed library-scoped salt to produce a 32-byte key. The salt is
|
|
352
|
+
* intentionally fixed so keys remain stable across process restarts; if it
|
|
353
|
+
* were random, every previously issued token would become undecryptable.
|
|
354
|
+
* For maximum protection against rainbow tables on weak passphrases,
|
|
355
|
+
* provide a 32-byte random buffer as `secret` (the KDF path is skipped).
|
|
356
|
+
*
|
|
357
|
+
* Revocation caveat: `revokeAllForUser` uses an in-memory per-user epoch map.
|
|
358
|
+
* It does not survive process restart and does not sync across instances —
|
|
359
|
+
* the same limitation as `CredentialStoreJwt`. Use `CredentialStoreRedis` or
|
|
360
|
+
* `CredentialStoreAtscriptDb` when you need native enumeration with durable
|
|
361
|
+
* cross-instance cascade semantics.
|
|
362
|
+
*/
|
|
363
|
+
var CredentialStoreEncapsulated = class {
|
|
364
|
+
key;
|
|
365
|
+
denylist;
|
|
366
|
+
clock;
|
|
367
|
+
/**
|
|
368
|
+
* Per-user revocation epoch (ms). `revokeAllForUser` sets this to
|
|
369
|
+
* `clock.now()`; `retrieve`/`consume` reject any decrypted state whose
|
|
370
|
+
* `issuedAt` is strictly less than the user's epoch (same-ms mints are
|
|
371
|
+
* accepted so recovery/invite flows can revoke and re-issue in the same
|
|
372
|
+
* tick). Mirrors the JWT store pattern; see class JSDoc for durability
|
|
373
|
+
* caveats.
|
|
374
|
+
*/
|
|
375
|
+
epochs = /* @__PURE__ */ new Map();
|
|
376
|
+
constructor(opts) {
|
|
377
|
+
if (opts.secret == null) throw new AuthError("INVALID_CONFIG", "CredentialStoreEncapsulated requires a 'secret'");
|
|
378
|
+
this.key = deriveKey(opts.secret);
|
|
379
|
+
this.denylist = opts.denylist;
|
|
380
|
+
this.clock = opts.clock ?? defaultClock;
|
|
381
|
+
}
|
|
382
|
+
async persist(state, ttl) {
|
|
383
|
+
const jti = (0, node_crypto.randomUUID)();
|
|
384
|
+
const expiresAt = typeof ttl === "number" ? this.clock.now() + ttl : state.expiresAt;
|
|
385
|
+
const payload = {
|
|
386
|
+
...state,
|
|
387
|
+
expiresAt,
|
|
388
|
+
jti
|
|
389
|
+
};
|
|
390
|
+
const iv = (0, node_crypto.randomBytes)(IV_LEN);
|
|
391
|
+
const cipher = (0, node_crypto.createCipheriv)("aes-256-gcm", this.key, iv);
|
|
392
|
+
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
|
393
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
394
|
+
const authTag = cipher.getAuthTag();
|
|
395
|
+
return Buffer.concat([
|
|
396
|
+
iv,
|
|
397
|
+
ciphertext,
|
|
398
|
+
authTag
|
|
399
|
+
]).toString("base64url");
|
|
400
|
+
}
|
|
401
|
+
async retrieve(token) {
|
|
402
|
+
const decrypted = this.decrypt(token);
|
|
403
|
+
if (!decrypted) return null;
|
|
404
|
+
if (decrypted.expiresAt <= this.clock.now()) return null;
|
|
405
|
+
if (!this.passesEpoch(decrypted)) return null;
|
|
406
|
+
if (this.denylist && await this.denylist.has(decrypted.jti)) return null;
|
|
407
|
+
return stripJti(decrypted);
|
|
408
|
+
}
|
|
409
|
+
async consume(token) {
|
|
410
|
+
const denylist = this.requireDenylist("consume");
|
|
411
|
+
const decrypted = this.decrypt(token);
|
|
412
|
+
if (!decrypted) return null;
|
|
413
|
+
if (decrypted.expiresAt <= this.clock.now()) return null;
|
|
414
|
+
if (!this.passesEpoch(decrypted)) return null;
|
|
415
|
+
if (await denylist.has(decrypted.jti)) return null;
|
|
416
|
+
await denylist.add(decrypted.jti, decrypted.expiresAt);
|
|
417
|
+
return stripJti(decrypted);
|
|
418
|
+
}
|
|
419
|
+
async update(token, state) {
|
|
420
|
+
const denylist = this.requireDenylist("update");
|
|
421
|
+
const decrypted = this.decrypt(token);
|
|
422
|
+
if (decrypted) await denylist.add(decrypted.jti, decrypted.expiresAt);
|
|
423
|
+
return this.persist(state);
|
|
424
|
+
}
|
|
425
|
+
async revoke(token) {
|
|
426
|
+
const denylist = this.requireDenylist("revoke");
|
|
427
|
+
const decrypted = this.decrypt(token);
|
|
428
|
+
if (!decrypted) return;
|
|
429
|
+
await denylist.add(decrypted.jti, decrypted.expiresAt);
|
|
430
|
+
}
|
|
431
|
+
async revokeAllForUser(userId) {
|
|
432
|
+
this.epochs.set(userId, this.clock.now());
|
|
433
|
+
return 1;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Reject decrypted payloads whose `issuedAt` predates the user's revocation
|
|
437
|
+
* epoch. Same-ms mints (`issuedAt === epoch`) are accepted so a workflow can
|
|
438
|
+
* revoke and re-issue in one tick. Mirrors `CredentialStoreJwt.passesEpoch`.
|
|
439
|
+
*/
|
|
440
|
+
passesEpoch(payload) {
|
|
441
|
+
const epoch = this.epochs.get(payload.userId);
|
|
442
|
+
if (epoch === void 0) return true;
|
|
443
|
+
return payload.issuedAt >= epoch;
|
|
444
|
+
}
|
|
445
|
+
requireDenylist(op) {
|
|
446
|
+
if (!this.denylist) throw new AuthError("STATELESS_OPERATION_UNSUPPORTED", `${op} requires a denylist on stateless encapsulated store`);
|
|
447
|
+
return this.denylist;
|
|
448
|
+
}
|
|
449
|
+
decrypt(token) {
|
|
450
|
+
try {
|
|
451
|
+
const blob = Buffer.from(token, "base64url");
|
|
452
|
+
if (blob.length < IV_LEN + 1 + TAG_LEN) return null;
|
|
453
|
+
const iv = blob.subarray(0, IV_LEN);
|
|
454
|
+
const authTag = blob.subarray(blob.length - TAG_LEN);
|
|
455
|
+
const ciphertext = blob.subarray(IV_LEN, blob.length - TAG_LEN);
|
|
456
|
+
const decipher = (0, node_crypto.createDecipheriv)("aes-256-gcm", this.key, iv);
|
|
457
|
+
decipher.setAuthTag(authTag);
|
|
458
|
+
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
459
|
+
const parsed = JSON.parse(plain.toString("utf8"));
|
|
460
|
+
if (typeof parsed?.userId !== "string" || typeof parsed.jti !== "string" || typeof parsed.issuedAt !== "number" || typeof parsed.expiresAt !== "number") return null;
|
|
461
|
+
return parsed;
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
function deriveKey(secret) {
|
|
468
|
+
if (typeof secret !== "string") {
|
|
469
|
+
const buf = Buffer.isBuffer(secret) ? secret : Buffer.from(secret);
|
|
470
|
+
if (buf.length === KEY_LEN) return buf;
|
|
471
|
+
return (0, node_crypto.scryptSync)(buf, KDF_SALT, KEY_LEN);
|
|
472
|
+
}
|
|
473
|
+
return (0, node_crypto.scryptSync)(secret, KDF_SALT, KEY_LEN);
|
|
474
|
+
}
|
|
475
|
+
function stripJti(payload) {
|
|
476
|
+
const { jti: _jti, ...rest } = payload;
|
|
477
|
+
return rest;
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
480
|
+
//#region src/credential/auth-credential.ts
|
|
481
|
+
const DEFAULT_ACCESS_TTL = 3600 * 1e3;
|
|
482
|
+
const DEFAULT_ROTATION_GRACE_MS = 3e4;
|
|
483
|
+
/**
|
|
484
|
+
* Stable, non-reversible fingerprint of a token used as `credentialId` in
|
|
485
|
+
* {@link AuthContext}. Hashing avoids leaking a live bearer token whenever
|
|
486
|
+
* downstream consumers log or persist `ctx.credentialId`.
|
|
487
|
+
*/
|
|
488
|
+
function fingerprint(token) {
|
|
489
|
+
return (0, node_crypto.createHash)("sha256").update(token).digest("hex");
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Orchestrates credential issuance, validation, refresh, and revocation
|
|
493
|
+
* on top of a pluggable {@link CredentialStore}.
|
|
494
|
+
*
|
|
495
|
+
* Design notes:
|
|
496
|
+
* - `credentialId` returned in {@link AuthContext} is a SHA-256 fingerprint of
|
|
497
|
+
* the access token, never the token itself. The fingerprint is stable
|
|
498
|
+
* per-token, safe to log/persist, and cannot be replayed against the API.
|
|
499
|
+
* M3 will switch to a `jti` claim for JWT/encapsulated stores.
|
|
500
|
+
* - `kind: 'access' | 'refresh'` on {@link CredentialState} discriminates
|
|
501
|
+
* tokens stored side-by-side in the same store.
|
|
502
|
+
* - `listForUser` and `maxConcurrent` consider only access-kind credentials,
|
|
503
|
+
* matching how callers typically display "active sessions".
|
|
504
|
+
* - On detected refresh-reuse-after-grace, the orchestrator best-effort
|
|
505
|
+
* revokes ALL credentials for the affected user (access + refresh).
|
|
506
|
+
* This is OAuth-best-practice theft response; documented and intentional.
|
|
507
|
+
*/
|
|
508
|
+
var AuthCredential = class {
|
|
509
|
+
store;
|
|
510
|
+
method;
|
|
511
|
+
accessTtl;
|
|
512
|
+
refreshConfig;
|
|
513
|
+
denylist;
|
|
514
|
+
maxConcurrent;
|
|
515
|
+
onLimit;
|
|
516
|
+
clock;
|
|
517
|
+
/**
|
|
518
|
+
* Recently-consumed refresh tokens, keyed by the raw refresh token string.
|
|
519
|
+
* Lets `'always'` rotation detect reuse: stateful stores forget the token
|
|
520
|
+
* after `consume`, and stateless (JWT) stores hide it behind a denylist hit
|
|
521
|
+
* on `retrieve`. Without this map the orchestrator can no longer distinguish
|
|
522
|
+
* "fake token" from "previously valid token replayed". Pruned lazily on
|
|
523
|
+
* access; bounded by refresh TTL.
|
|
524
|
+
*/
|
|
525
|
+
consumedRefreshes = /* @__PURE__ */ new Map();
|
|
526
|
+
constructor(opts) {
|
|
527
|
+
this.store = opts.store;
|
|
528
|
+
this.method = opts.method ?? "token";
|
|
529
|
+
this.accessTtl = opts.accessTtl ?? DEFAULT_ACCESS_TTL;
|
|
530
|
+
this.refreshConfig = opts.refresh;
|
|
531
|
+
this.denylist = opts.denylist;
|
|
532
|
+
this.maxConcurrent = opts.maxConcurrent;
|
|
533
|
+
this.onLimit = opts.onLimit ?? "reject";
|
|
534
|
+
this.clock = opts.clock ?? defaultClock;
|
|
535
|
+
if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
|
|
536
|
+
if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
|
|
537
|
+
}
|
|
538
|
+
async issue(userId, options) {
|
|
539
|
+
if (this.maxConcurrent !== void 0 && this.store.listForUser) await this.enforceConcurrencyLimit(userId);
|
|
540
|
+
const now = this.clock.now();
|
|
541
|
+
const accessState = {
|
|
542
|
+
userId,
|
|
543
|
+
issuedAt: now,
|
|
544
|
+
expiresAt: now + this.accessTtl,
|
|
545
|
+
kind: "access",
|
|
546
|
+
claims: options?.claims,
|
|
547
|
+
metadata: options?.metadata
|
|
548
|
+
};
|
|
549
|
+
const accessToken = await this.store.persist(accessState, this.accessTtl);
|
|
550
|
+
let refreshToken;
|
|
551
|
+
let refreshExpiresAt;
|
|
552
|
+
if (this.refreshConfig) {
|
|
553
|
+
const refreshState = {
|
|
554
|
+
userId,
|
|
555
|
+
issuedAt: now,
|
|
556
|
+
expiresAt: now + this.refreshConfig.ttl,
|
|
557
|
+
kind: "refresh",
|
|
558
|
+
claims: options?.claims,
|
|
559
|
+
metadata: options?.metadata
|
|
560
|
+
};
|
|
561
|
+
refreshToken = await this.store.persist(refreshState, this.refreshConfig.ttl);
|
|
562
|
+
refreshExpiresAt = now + this.refreshConfig.ttl;
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
accessToken,
|
|
566
|
+
refreshToken,
|
|
567
|
+
accessExpiresAt: now + this.accessTtl,
|
|
568
|
+
refreshExpiresAt
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
async validate(accessToken) {
|
|
572
|
+
if (this.denylist && await this.denylist.has(accessToken)) return null;
|
|
573
|
+
const state = await this.store.retrieve(accessToken);
|
|
574
|
+
if (!state) return null;
|
|
575
|
+
if (state.expiresAt <= this.clock.now()) return null;
|
|
576
|
+
if (state.kind === "refresh") return null;
|
|
577
|
+
return {
|
|
578
|
+
userId: state.userId,
|
|
579
|
+
method: this.method,
|
|
580
|
+
credentialId: fingerprint(accessToken),
|
|
581
|
+
expiresAt: state.expiresAt,
|
|
582
|
+
claims: state.claims
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
async refresh(refreshToken) {
|
|
586
|
+
if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
|
|
587
|
+
const rotation = this.refreshConfig.rotation ?? "sliding";
|
|
588
|
+
const now = this.clock.now();
|
|
589
|
+
const oldState = await this.store.retrieve(refreshToken);
|
|
590
|
+
if (!oldState) {
|
|
591
|
+
const consumed = this.lookupConsumedRefresh(refreshToken);
|
|
592
|
+
if (consumed) await this.fireRefreshReuseTheftResponse(consumed, refreshToken);
|
|
593
|
+
throw new AuthError("INVALID_TOKEN");
|
|
594
|
+
}
|
|
595
|
+
if (oldState.kind !== "refresh") throw new AuthError("INVALID_TOKEN", "Token is not a refresh credential");
|
|
596
|
+
switch (rotation) {
|
|
597
|
+
case "none": return await this.refreshNone(oldState, refreshToken, now);
|
|
598
|
+
case "always":
|
|
599
|
+
await this.store.consume(refreshToken);
|
|
600
|
+
this.consumedRefreshes.set(refreshToken, {
|
|
601
|
+
userId: oldState.userId,
|
|
602
|
+
iat: oldState.issuedAt,
|
|
603
|
+
exp: oldState.expiresAt
|
|
604
|
+
});
|
|
605
|
+
return await this.issueRotatedPair(oldState, refreshToken, false, now);
|
|
606
|
+
case "sliding": return await this.refreshSliding(oldState, refreshToken, now);
|
|
607
|
+
default: throw new AuthError("INVALID_CONFIG", `Unknown rotation: ${String(rotation)}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async refreshNone(oldState, refreshToken, now) {
|
|
611
|
+
const newAccess = await this.issueAccessFromRefresh(oldState, now);
|
|
612
|
+
return {
|
|
613
|
+
accessToken: newAccess.token,
|
|
614
|
+
accessExpiresAt: newAccess.expiresAt,
|
|
615
|
+
refreshToken,
|
|
616
|
+
refreshExpiresAt: oldState.expiresAt
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
async refreshSliding(oldState, refreshToken, now) {
|
|
620
|
+
if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
|
|
621
|
+
const graceMs = this.refreshConfig.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
|
|
622
|
+
if (typeof oldState.rotatedAt !== "number") return await this.issueRotatedPair(oldState, refreshToken, true, now);
|
|
623
|
+
if (now - oldState.rotatedAt > graceMs) {
|
|
624
|
+
this.refreshConfig.onRotationReuse?.(oldState);
|
|
625
|
+
await this.store.revokeAllForUser(oldState.userId);
|
|
626
|
+
throw new AuthError("REFRESH_REUSE_DETECTED", void 0, {
|
|
627
|
+
userId: oldState.userId,
|
|
628
|
+
rotatedAt: oldState.rotatedAt
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return await this.issueRotatedPair(oldState, refreshToken, false, now);
|
|
632
|
+
}
|
|
633
|
+
async revoke(token) {
|
|
634
|
+
await this.store.revoke(token);
|
|
635
|
+
}
|
|
636
|
+
async revokeAllForUser(userId) {
|
|
637
|
+
return await this.store.revokeAllForUser(userId);
|
|
638
|
+
}
|
|
639
|
+
async listForUser(userId) {
|
|
640
|
+
if (!this.store.listForUser) return [];
|
|
641
|
+
return (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh").map((entry) => ({
|
|
642
|
+
userId: entry.userId,
|
|
643
|
+
method: this.method,
|
|
644
|
+
credentialId: fingerprint(entry.token),
|
|
645
|
+
expiresAt: entry.expiresAt,
|
|
646
|
+
claims: entry.claims
|
|
647
|
+
}));
|
|
648
|
+
}
|
|
649
|
+
async enforceConcurrencyLimit(userId) {
|
|
650
|
+
if (!this.store.listForUser || this.maxConcurrent === void 0) return;
|
|
651
|
+
const accessOnly = (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh");
|
|
652
|
+
if (accessOnly.length < this.maxConcurrent) return;
|
|
653
|
+
if (this.onLimit === "reject") throw new AuthError("MAX_CONCURRENT_REACHED", void 0, {
|
|
654
|
+
userId,
|
|
655
|
+
limit: this.maxConcurrent,
|
|
656
|
+
active: accessOnly.length
|
|
657
|
+
});
|
|
658
|
+
const sorted = accessOnly.toSorted((a, b) => a.issuedAt - b.issuedAt);
|
|
659
|
+
const toEvict = accessOnly.length - this.maxConcurrent + 1;
|
|
660
|
+
for (let i = 0; i < toEvict && i < sorted.length; i++) await this.store.revoke(sorted[i].token);
|
|
661
|
+
}
|
|
662
|
+
async issueAccessFromRefresh(refreshState, now) {
|
|
663
|
+
const accessState = {
|
|
664
|
+
userId: refreshState.userId,
|
|
665
|
+
issuedAt: now,
|
|
666
|
+
expiresAt: now + this.accessTtl,
|
|
667
|
+
kind: "access",
|
|
668
|
+
claims: refreshState.claims,
|
|
669
|
+
metadata: refreshState.metadata
|
|
670
|
+
};
|
|
671
|
+
return {
|
|
672
|
+
token: await this.store.persist(accessState, this.accessTtl),
|
|
673
|
+
expiresAt: now + this.accessTtl
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
async issueRotatedPair(oldRefreshState, oldRefreshToken, rotateOld, now) {
|
|
677
|
+
if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
|
|
678
|
+
const access = await this.issueAccessFromRefresh(oldRefreshState, now);
|
|
679
|
+
const newRefreshState = {
|
|
680
|
+
userId: oldRefreshState.userId,
|
|
681
|
+
issuedAt: now,
|
|
682
|
+
expiresAt: now + this.refreshConfig.ttl,
|
|
683
|
+
kind: "refresh",
|
|
684
|
+
claims: oldRefreshState.claims,
|
|
685
|
+
metadata: oldRefreshState.metadata,
|
|
686
|
+
parentCredentialId: oldRefreshToken
|
|
687
|
+
};
|
|
688
|
+
const newRefreshToken = await this.store.persist(newRefreshState, this.refreshConfig.ttl);
|
|
689
|
+
if (rotateOld) {
|
|
690
|
+
const rotatedState = {
|
|
691
|
+
...oldRefreshState,
|
|
692
|
+
rotatedAt: now
|
|
693
|
+
};
|
|
694
|
+
await this.store.update(oldRefreshToken, rotatedState);
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
accessToken: access.token,
|
|
698
|
+
accessExpiresAt: access.expiresAt,
|
|
699
|
+
refreshToken: newRefreshToken,
|
|
700
|
+
refreshExpiresAt: now + this.refreshConfig.ttl
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
lookupConsumedRefresh(token) {
|
|
704
|
+
const entry = this.consumedRefreshes.get(token);
|
|
705
|
+
if (!entry) return null;
|
|
706
|
+
if (entry.exp <= this.clock.now()) {
|
|
707
|
+
this.consumedRefreshes.delete(token);
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
return entry;
|
|
711
|
+
}
|
|
712
|
+
async fireRefreshReuseTheftResponse(consumed, refreshToken) {
|
|
713
|
+
this.refreshConfig?.onRotationReuse?.({
|
|
714
|
+
userId: consumed.userId,
|
|
715
|
+
issuedAt: consumed.iat,
|
|
716
|
+
expiresAt: consumed.exp,
|
|
717
|
+
kind: "refresh"
|
|
718
|
+
});
|
|
719
|
+
await this.store.revokeAllForUser(consumed.userId);
|
|
720
|
+
this.consumedRefreshes.delete(refreshToken);
|
|
721
|
+
throw new AuthError("REFRESH_REUSE_DETECTED", void 0, { userId: consumed.userId });
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/magic-link.ts
|
|
726
|
+
/**
|
|
727
|
+
* 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
|
|
728
|
+
* URL-safe. Strong enough to survive short TTLs against online guessing.
|
|
729
|
+
*/
|
|
730
|
+
function generateMagicLinkToken() {
|
|
731
|
+
return (0, node_crypto.randomBytes)(32).toString("base64url");
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
exports.AuthCredential = AuthCredential;
|
|
735
|
+
exports.AuthError = AuthError;
|
|
736
|
+
exports.CredentialStoreEncapsulated = CredentialStoreEncapsulated;
|
|
737
|
+
exports.CredentialStoreJwt = CredentialStoreJwt;
|
|
738
|
+
exports.CredentialStoreMemory = CredentialStoreMemory;
|
|
739
|
+
exports.DenylistStoreMemory = DenylistStoreMemory;
|
|
740
|
+
exports.defaultClock = defaultClock;
|
|
741
|
+
exports.generateMagicLinkToken = generateMagicLinkToken;
|