@aooth/auth 0.1.7 → 0.1.9
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 +378 -0
- package/dist/authz.d.cts +502 -0
- package/dist/authz.d.mts +502 -0
- package/dist/authz.mjs +365 -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/authz.mjs
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { t as defaultClock } from "./clock-Bdsep_1j.mjs";
|
|
2
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { SignJWT, exportJWK, importPKCS8, importSPKI } from "jose";
|
|
4
|
+
//#region src/authz/authz-errors.ts
|
|
5
|
+
/** A typed authorization-server failure. */
|
|
6
|
+
var AuthorizeError = class extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "AuthorizeError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/authz/client-policy.ts
|
|
16
|
+
/**
|
|
17
|
+
* `true` when `uri` is a syntactically valid http(s) URL whose host is a
|
|
18
|
+
* **loopback literal** — `127.0.0.1`, `::1`, or `localhost` — on any port (RFC
|
|
19
|
+
* 8252 §7.3). Rejects everything else, including the classic bypasses: a
|
|
20
|
+
* host-suffix (`127.0.0.1.evil.com`, `localhost.evil.com`), embedded credentials
|
|
21
|
+
* (`http://127.0.0.1@evil.com` → host `evil.com`), a non-http scheme, and a bare
|
|
22
|
+
* `0.0.0.0`. Only a local process can receive a loopback redirect, which is why
|
|
23
|
+
* an arbitrary port is safe — the binding is the loopback host + PKCE.
|
|
24
|
+
*/
|
|
25
|
+
function isLoopbackRedirectUri(uri) {
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(uri);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
33
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
34
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
35
|
+
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
36
|
+
}
|
|
37
|
+
const DEFAULT_CLI_TOKEN_POLICY = {
|
|
38
|
+
kind: "cli-session",
|
|
39
|
+
ttl: 720 * 60 * 6e4
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Tier-1 policy: accept any **loopback** `redirect_uri`, treat the client as a
|
|
43
|
+
* public client (no `client_id` / secret — PKCE is the binding), and mint the
|
|
44
|
+
* configured CLI token policy. Rejects every non-loopback redirect.
|
|
45
|
+
*/
|
|
46
|
+
var LoopbackClientPolicy = class {
|
|
47
|
+
tokenPolicy;
|
|
48
|
+
constructor(opts) {
|
|
49
|
+
this.tokenPolicy = opts?.tokenPolicy ?? DEFAULT_CLI_TOKEN_POLICY;
|
|
50
|
+
}
|
|
51
|
+
resolveClient(args) {
|
|
52
|
+
if (!isLoopbackRedirectUri(args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri must be a loopback address");
|
|
53
|
+
return {
|
|
54
|
+
redirectUri: args.redirectUri,
|
|
55
|
+
tokenPolicy: structuredClone(this.tokenPolicy),
|
|
56
|
+
...args.scope !== void 0 && { scope: args.scope }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/authz/oidc-claims-resolver.ts
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the OIDC profile claims to embed in an `id_token` for a given user +
|
|
64
|
+
* granted scope (AUTH-SERVER.md §4.9). The authorization server already controls
|
|
65
|
+
* the registered claims (`iss`/`aud`/`sub`/`iat`/`exp`/`nonce`) itself — this seam
|
|
66
|
+
* supplies the **profile** claims that depend on the consumer's user shape
|
|
67
|
+
* (`email`/`email_verified`/`name`/`picture`), which `@aooth/auth` cannot know.
|
|
68
|
+
*
|
|
69
|
+
* Pluggable like every other store/policy: the no-op default ({@link NoopOidcClaimsResolver},
|
|
70
|
+
* `sub`-only tokens) ships, and a consumer subclasses it to read its own user
|
|
71
|
+
* record. Bound in `@aooth/auth-moost` under `OIDC_CLAIMS_RESOLVER_TOKEN`.
|
|
72
|
+
*
|
|
73
|
+
* The map's keys are standard OIDC claim names; values MUST be JSON-safe. Honour
|
|
74
|
+
* the granted `scope` — only emit `email`/`email_verified` under `email`, and
|
|
75
|
+
* `name`/`picture`/etc. under `profile`, so a client receives only what it asked
|
|
76
|
+
* for (and was allowed).
|
|
77
|
+
*/
|
|
78
|
+
var OidcClaimsResolver = class {};
|
|
79
|
+
/** Default resolver: emits no profile claims, so the `id_token` carries only `sub` + the registered claims. */
|
|
80
|
+
var NoopOidcClaimsResolver = class extends OidcClaimsResolver {
|
|
81
|
+
resolveClaims() {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/** `true` when `scope` (space-joined) grants `claim` — `"email"`/`"profile"` etc. */
|
|
86
|
+
function scopeGrants(scope, claim) {
|
|
87
|
+
if (!scope) return false;
|
|
88
|
+
return scope.split(/\s+/u).includes(claim);
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/authz/registered-client-policy.ts
|
|
92
|
+
/**
|
|
93
|
+
* Tier-2 policy: a static registry of first-party clients. `resolveClient`
|
|
94
|
+
* authorizes the client + `redirect_uri` (against the registered allowlist) and
|
|
95
|
+
* resolves the granted scope + what the grant delivers (`id_token`/`access_token`,
|
|
96
|
+
* `aud = client_id`); `authenticateClient` authenticates the client at `/token`
|
|
97
|
+
* (`client_secret` for confidential clients; PKCE is the binding for public ones).
|
|
98
|
+
* An unregistered client or an unlisted redirect is rejected.
|
|
99
|
+
*/
|
|
100
|
+
var RegisteredClientPolicy = class {
|
|
101
|
+
clients = /* @__PURE__ */ new Map();
|
|
102
|
+
constructor(opts) {
|
|
103
|
+
for (const c of opts.clients) this.clients.set(c.clientId, c);
|
|
104
|
+
}
|
|
105
|
+
resolveClient(args) {
|
|
106
|
+
const client = this.requireClient(args.clientId);
|
|
107
|
+
if (!this.redirectAllowed(client, args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri is not registered for this client");
|
|
108
|
+
const scope = this.grantScope(client, args.scope);
|
|
109
|
+
const idToken = client.idToken !== false && scopeGrants(scope, "openid");
|
|
110
|
+
return {
|
|
111
|
+
clientId: client.clientId,
|
|
112
|
+
redirectUri: args.redirectUri,
|
|
113
|
+
audience: client.clientId,
|
|
114
|
+
idToken,
|
|
115
|
+
accessToken: client.accessToken === true,
|
|
116
|
+
tokenPolicy: client.tokenPolicy ? structuredClone(client.tokenPolicy) : {},
|
|
117
|
+
...scope !== void 0 && { scope }
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
authenticateClient(args) {
|
|
121
|
+
const client = this.requireClient(args.clientId);
|
|
122
|
+
if ((client.type ?? "public") !== "confidential") return;
|
|
123
|
+
if (!client.clientSecret || !args.clientSecret || !timingSafeEqualStr(args.clientSecret, client.clientSecret)) throw new AuthorizeError("invalid_client", "client authentication failed");
|
|
124
|
+
}
|
|
125
|
+
requireClient(clientId) {
|
|
126
|
+
const client = clientId ? this.clients.get(clientId) : void 0;
|
|
127
|
+
if (!client) throw new AuthorizeError("invalid_client", "unknown client");
|
|
128
|
+
return client;
|
|
129
|
+
}
|
|
130
|
+
redirectAllowed(client, uri) {
|
|
131
|
+
if (client.redirectUris?.includes(uri)) return true;
|
|
132
|
+
if (!client.redirectPrefixes?.length) return false;
|
|
133
|
+
let normalized;
|
|
134
|
+
try {
|
|
135
|
+
normalized = new URL(uri).href;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return client.redirectPrefixes.some((p) => {
|
|
140
|
+
if (p.length === 0 || !normalized.startsWith(p)) return false;
|
|
141
|
+
if (p.endsWith("/")) return true;
|
|
142
|
+
const next = normalized[p.length];
|
|
143
|
+
return next === void 0 || next === "/" || next === "?" || next === "#";
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
grantScope(client, requested) {
|
|
147
|
+
if (!requested) return void 0;
|
|
148
|
+
const req = requested.split(/\s+/u).filter(Boolean);
|
|
149
|
+
const granted = client.scopes ? req.filter((s) => client.scopes.includes(s)) : req;
|
|
150
|
+
return granted.length > 0 ? granted.join(" ") : void 0;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
/** Constant-time string compare that also fails closed on a length mismatch. */
|
|
154
|
+
function timingSafeEqualStr(a, b) {
|
|
155
|
+
const ab = Buffer.from(a, "utf8");
|
|
156
|
+
const bb = Buffer.from(b, "utf8");
|
|
157
|
+
if (ab.length !== bb.length) return false;
|
|
158
|
+
return timingSafeEqual(ab, bb);
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/authz/composite-client-policy.ts
|
|
162
|
+
/**
|
|
163
|
+
* Runs Tier-1 and Tier-2 side by side, dispatching on the **presence of
|
|
164
|
+
* `client_id`** (AUTH-SERVER.md §10): a request with a `client_id` is a
|
|
165
|
+
* registered client, one without is a loopback CLI. The split is the safety
|
|
166
|
+
* boundary — a registered client is routed only to {@link RegisteredClientPolicy}
|
|
167
|
+
* (which enforces ITS redirect allowlist, so it cannot smuggle a loopback
|
|
168
|
+
* redirect), and a no-`client_id` request is routed only to the loopback policy
|
|
169
|
+
* (so it cannot claim to be a registered client). Each sub-policy still owns its
|
|
170
|
+
* own redirect validation; this only picks which one runs.
|
|
171
|
+
*/
|
|
172
|
+
var CompositeClientPolicy = class {
|
|
173
|
+
loopback;
|
|
174
|
+
registered;
|
|
175
|
+
constructor(opts) {
|
|
176
|
+
this.loopback = opts.loopback;
|
|
177
|
+
this.registered = opts.registered;
|
|
178
|
+
}
|
|
179
|
+
resolveClient(args) {
|
|
180
|
+
return args.clientId ? this.registered.resolveClient(args) : this.loopback.resolveClient(args);
|
|
181
|
+
}
|
|
182
|
+
authenticateClient(args) {
|
|
183
|
+
if (args.clientId) return this.registered.authenticateClient?.(args);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/authz/id-token-signer.ts
|
|
188
|
+
/**
|
|
189
|
+
* Signs OIDC `id_token`s and publishes the matching JWKS (AUTH-SERVER.md §4.9).
|
|
190
|
+
* Holds one asymmetric keypair; mints short-lived RS256/ES256 tokens with the
|
|
191
|
+
* issuer + audience + subject a relying `OidcProvider` validates, and exports the
|
|
192
|
+
* public half as a JWKS for `GET /auth/jwks`. Keys are imported lazily and cached
|
|
193
|
+
* (the Apple-client-secret pattern), so construction is cheap and synchronous.
|
|
194
|
+
*
|
|
195
|
+
* Never used for the access token (that stays in `AuthCredential`'s store) — the
|
|
196
|
+
* `id_token` is a separate, audience-bound identity assertion.
|
|
197
|
+
*/
|
|
198
|
+
var IdTokenSigner = class {
|
|
199
|
+
/** The `iss` claim + discovery `issuer` (read by `/.well-known/openid-configuration`). */
|
|
200
|
+
issuer;
|
|
201
|
+
/** Signature algorithm — published in discovery's `id_token_signing_alg_values_supported`. */
|
|
202
|
+
alg;
|
|
203
|
+
/** Key id — the JWS header + JWKS entry `kid`. */
|
|
204
|
+
kid;
|
|
205
|
+
privateKeyPem;
|
|
206
|
+
publicKeyPem;
|
|
207
|
+
ttlSec;
|
|
208
|
+
clock;
|
|
209
|
+
privateKeyPromise;
|
|
210
|
+
jwksPromise;
|
|
211
|
+
constructor(opts) {
|
|
212
|
+
this.issuer = opts.issuer.replace(/\/$/u, "");
|
|
213
|
+
this.alg = opts.alg ?? "RS256";
|
|
214
|
+
this.kid = opts.kid;
|
|
215
|
+
this.privateKeyPem = opts.privateKey;
|
|
216
|
+
this.publicKeyPem = opts.publicKey;
|
|
217
|
+
this.ttlSec = opts.ttlSec ?? 300;
|
|
218
|
+
this.clock = opts.clock ?? defaultClock;
|
|
219
|
+
}
|
|
220
|
+
/** Mint a signed `id_token` JWT. Iat/exp come from the injected clock. */
|
|
221
|
+
async sign(claims) {
|
|
222
|
+
const key = await this.importPrivateKey();
|
|
223
|
+
const nowSec = Math.floor(this.clock.now() / 1e3);
|
|
224
|
+
const ttl = claims.ttlSec ?? this.ttlSec;
|
|
225
|
+
const payload = { ...claims.extra };
|
|
226
|
+
if (claims.nonce !== void 0) payload.nonce = claims.nonce;
|
|
227
|
+
return new SignJWT(payload).setProtectedHeader({
|
|
228
|
+
alg: this.alg,
|
|
229
|
+
kid: this.kid,
|
|
230
|
+
typ: "JWT"
|
|
231
|
+
}).setIssuer(this.issuer).setSubject(claims.sub).setAudience(claims.aud).setIssuedAt(nowSec).setExpirationTime(nowSec + ttl).sign(key);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The JWKS document served at `/auth/jwks` — the public key as a single
|
|
235
|
+
* `use: "sig"` entry tagged with the same `kid`/`alg` as the minted tokens, so
|
|
236
|
+
* a verifier selects it by `kid`. Computed once and cached.
|
|
237
|
+
*/
|
|
238
|
+
async jwks() {
|
|
239
|
+
this.jwksPromise ??= (async () => {
|
|
240
|
+
const jwk = await exportJWK(await importSPKI(this.publicKeyPem, this.alg, { extractable: true }));
|
|
241
|
+
jwk.kid = this.kid;
|
|
242
|
+
jwk.alg = this.alg;
|
|
243
|
+
jwk.use = "sig";
|
|
244
|
+
return { keys: [jwk] };
|
|
245
|
+
})();
|
|
246
|
+
return this.jwksPromise;
|
|
247
|
+
}
|
|
248
|
+
importPrivateKey() {
|
|
249
|
+
this.privateKeyPromise ??= importPKCS8(this.privateKeyPem, this.alg);
|
|
250
|
+
return this.privateKeyPromise;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/authz/pending-authorization-store.ts
|
|
255
|
+
/**
|
|
256
|
+
* Storage seam for in-flight authorizations (AUTH-SERVER.md §4.3). Short-lived
|
|
257
|
+
* (≈ the login-session ceiling): created at `/authorize`, read+deleted at the
|
|
258
|
+
* wf terminal. An in-memory impl ships for single-process apps + tests; a
|
|
259
|
+
* multi-pod deployment provides a durable (e.g. Redis) impl under the same
|
|
260
|
+
* `PENDING_AUTHORIZATION_STORE_TOKEN` (from `@aooth/auth-moost`).
|
|
261
|
+
*/
|
|
262
|
+
var PendingAuthorizationStore = class {};
|
|
263
|
+
const DEFAULT_PENDING_TTL_MS = 15 * 6e4;
|
|
264
|
+
/**
|
|
265
|
+
* In-memory {@link PendingAuthorizationStore} — the reference impl for a
|
|
266
|
+
* single-process app + tests. `structuredClone` on read/write isolates callers.
|
|
267
|
+
*/
|
|
268
|
+
var PendingAuthorizationStoreMemory = class extends PendingAuthorizationStore {
|
|
269
|
+
store = /* @__PURE__ */ new Map();
|
|
270
|
+
clock;
|
|
271
|
+
ttlMs;
|
|
272
|
+
constructor(opts) {
|
|
273
|
+
super();
|
|
274
|
+
this.clock = opts?.clock ?? defaultClock;
|
|
275
|
+
this.ttlMs = opts?.ttlMs ?? DEFAULT_PENDING_TTL_MS;
|
|
276
|
+
}
|
|
277
|
+
async create(rec) {
|
|
278
|
+
const now = this.clock.now();
|
|
279
|
+
const row = {
|
|
280
|
+
handle: randomUUID(),
|
|
281
|
+
redirectUri: rec.redirectUri,
|
|
282
|
+
codeChallenge: rec.codeChallenge,
|
|
283
|
+
tokenPolicy: structuredClone(rec.tokenPolicy),
|
|
284
|
+
createdAt: now,
|
|
285
|
+
expiresAt: now + this.ttlMs,
|
|
286
|
+
...rec.clientId !== void 0 && { clientId: rec.clientId },
|
|
287
|
+
...rec.clientState !== void 0 && { clientState: rec.clientState },
|
|
288
|
+
...rec.scope !== void 0 && { scope: rec.scope },
|
|
289
|
+
...rec.nonce !== void 0 && { nonce: rec.nonce },
|
|
290
|
+
...rec.idToken !== void 0 && { idToken: rec.idToken },
|
|
291
|
+
...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
|
|
292
|
+
...rec.audience !== void 0 && { audience: rec.audience }
|
|
293
|
+
};
|
|
294
|
+
this.store.set(row.handle, structuredClone(row));
|
|
295
|
+
return { handle: row.handle };
|
|
296
|
+
}
|
|
297
|
+
async get(handle) {
|
|
298
|
+
const row = this.store.get(handle);
|
|
299
|
+
if (!row) return null;
|
|
300
|
+
if (row.expiresAt <= this.clock.now()) {
|
|
301
|
+
this.store.delete(handle);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return structuredClone(row);
|
|
305
|
+
}
|
|
306
|
+
async delete(handle) {
|
|
307
|
+
return this.store.delete(handle);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/authz/auth-code-store.ts
|
|
312
|
+
/**
|
|
313
|
+
* Storage seam for issued authorization codes (AUTH-SERVER.md §4.3). Very
|
|
314
|
+
* short-lived (≈ 30–60 s) and **single-use**: {@link consume} returns the row
|
|
315
|
+
* AND invalidates it in one atomic step, so a concurrent double-redeem (or a
|
|
316
|
+
* back-button replay) yields the code to exactly one caller. An in-memory impl
|
|
317
|
+
* ships (atomic for free in single-threaded JS); a durable impl must implement
|
|
318
|
+
* `consume` as an atomic check-and-delete (e.g. `withCas` / `@db.column.version`,
|
|
319
|
+
* or a Redis `GETDEL`).
|
|
320
|
+
*/
|
|
321
|
+
var AuthCodeStore = class {};
|
|
322
|
+
const DEFAULT_CODE_TTL_MS = 6e4;
|
|
323
|
+
/**
|
|
324
|
+
* In-memory {@link AuthCodeStore} — the reference impl. `consume` is atomic
|
|
325
|
+
* because the `get` + `delete` run with no intervening `await`, so a second
|
|
326
|
+
* concurrent `consume` of the same code always misses.
|
|
327
|
+
*/
|
|
328
|
+
var AuthCodeStoreMemory = class extends AuthCodeStore {
|
|
329
|
+
store = /* @__PURE__ */ new Map();
|
|
330
|
+
clock;
|
|
331
|
+
ttlMs;
|
|
332
|
+
constructor(opts) {
|
|
333
|
+
super();
|
|
334
|
+
this.clock = opts?.clock ?? defaultClock;
|
|
335
|
+
this.ttlMs = opts?.ttlMs ?? DEFAULT_CODE_TTL_MS;
|
|
336
|
+
}
|
|
337
|
+
async mint(rec) {
|
|
338
|
+
const code = randomUUID();
|
|
339
|
+
const row = {
|
|
340
|
+
code,
|
|
341
|
+
userId: rec.userId,
|
|
342
|
+
codeChallenge: rec.codeChallenge,
|
|
343
|
+
redirectUri: rec.redirectUri,
|
|
344
|
+
tokenPolicy: structuredClone(rec.tokenPolicy),
|
|
345
|
+
expiresAt: this.clock.now() + this.ttlMs,
|
|
346
|
+
...rec.clientId !== void 0 && { clientId: rec.clientId },
|
|
347
|
+
...rec.scope !== void 0 && { scope: rec.scope },
|
|
348
|
+
...rec.nonce !== void 0 && { nonce: rec.nonce },
|
|
349
|
+
...rec.idToken !== void 0 && { idToken: rec.idToken },
|
|
350
|
+
...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
|
|
351
|
+
...rec.audience !== void 0 && { audience: rec.audience }
|
|
352
|
+
};
|
|
353
|
+
this.store.set(code, structuredClone(row));
|
|
354
|
+
return { code };
|
|
355
|
+
}
|
|
356
|
+
async consume(code) {
|
|
357
|
+
const row = this.store.get(code);
|
|
358
|
+
if (!row) return null;
|
|
359
|
+
this.store.delete(code);
|
|
360
|
+
if (row.expiresAt <= this.clock.now()) return null;
|
|
361
|
+
return structuredClone(row);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
//#endregion
|
|
365
|
+
export { AuthCodeStore, AuthCodeStoreMemory, AuthorizeError, CompositeClientPolicy, IdTokenSigner, LoopbackClientPolicy, NoopOidcClaimsResolver, OidcClaimsResolver, PendingAuthorizationStore, PendingAuthorizationStoreMemory, RegisteredClientPolicy, isLoopbackRedirectUri, scopeGrants };
|
package/dist/client.cjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/client/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Wraps `fetch` with cookie-session silent refresh. Every call forwards
|
|
5
|
+
* credentials; a response whose status is in `refreshOn` (401 by default)
|
|
6
|
+
* triggers a **single-flight** refresh — N concurrent failing requests share
|
|
7
|
+
* exactly one `POST {refreshPath}` — and, on success, the original request is
|
|
8
|
+
* retried **once**. A failed refresh fires `onLogout()` once and returns the
|
|
9
|
+
* original failing response (no retry, no refresh storm).
|
|
10
|
+
*
|
|
11
|
+
* Browser-safe: pairs with the bundled `@aooth/auth-moost` httpOnly-cookie
|
|
12
|
+
* transport, so the SPA never touches token material. The retried request reuses
|
|
13
|
+
* the original `init`; passing a one-shot body (a consumed `ReadableStream`) is
|
|
14
|
+
* not retry-safe — prefer string / `FormData` / `Blob` bodies.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { createAuthedFetch } from '@aooth/auth/client'
|
|
19
|
+
* const api = createAuthedFetch({ refreshPath: '/auth/refresh', onLogout: () => location.assign('/login') })
|
|
20
|
+
* const res = await api('/api/me') // 401 → silent refresh → retried once
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
function createAuthedFetch(options = {}) {
|
|
24
|
+
const refreshPath = options.refreshPath ?? "/auth/refresh";
|
|
25
|
+
const onLogout = options.onLogout;
|
|
26
|
+
const refreshOn = options.refreshOn ?? [401];
|
|
27
|
+
const baseFetch = options.fetch ?? globalThis.fetch;
|
|
28
|
+
if (typeof baseFetch !== "function") throw new Error("createAuthedFetch: no fetch implementation available — pass options.fetch");
|
|
29
|
+
let refreshInFlight = null;
|
|
30
|
+
const runRefresh = () => {
|
|
31
|
+
if (refreshInFlight) return refreshInFlight;
|
|
32
|
+
refreshInFlight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
if ((await baseFetch(refreshPath, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
credentials: "include"
|
|
37
|
+
})).ok) return true;
|
|
38
|
+
} catch {}
|
|
39
|
+
onLogout?.();
|
|
40
|
+
return false;
|
|
41
|
+
})().finally(() => {
|
|
42
|
+
refreshInFlight = null;
|
|
43
|
+
});
|
|
44
|
+
return refreshInFlight;
|
|
45
|
+
};
|
|
46
|
+
const authedFetch = async (input, init) => {
|
|
47
|
+
const withCreds = {
|
|
48
|
+
credentials: "include",
|
|
49
|
+
...init
|
|
50
|
+
};
|
|
51
|
+
const res = await baseFetch(input, withCreds);
|
|
52
|
+
if (!refreshOn.includes(res.status)) return res;
|
|
53
|
+
if (!await runRefresh()) return res;
|
|
54
|
+
return baseFetch(input, withCreds);
|
|
55
|
+
};
|
|
56
|
+
return authedFetch;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
exports.createAuthedFetch = createAuthedFetch;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region src/client/index.d.ts
|
|
2
|
+
/** Minimal structural view of a fetch `Response` this helper reads. The DOM `Response` satisfies it. */
|
|
3
|
+
interface MinimalResponse {
|
|
4
|
+
readonly ok: boolean;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
}
|
|
7
|
+
/** Subset of `RequestInit` this helper sets or forwards. The DOM `RequestInit` satisfies it. */
|
|
8
|
+
interface MinimalRequestInit {
|
|
9
|
+
method?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
body?: unknown;
|
|
12
|
+
credentials?: "omit" | "same-origin" | "include";
|
|
13
|
+
signal?: unknown;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
/** A `fetch`-compatible function. The global `fetch` satisfies this. */
|
|
17
|
+
type FetchFn = (input: string | URL, init?: MinimalRequestInit) => Promise<MinimalResponse>;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves to the ambient `fetch` type when the consumer's tsconfig includes the
|
|
20
|
+
* DOM lib — so the returned wrapper yields a real `Response` with `.json()` etc.
|
|
21
|
+
* — and falls back to the structural {@link FetchFn} otherwise. This keeps
|
|
22
|
+
* `@aooth/auth/client` free of a hard DOM-lib dependency while preserving great
|
|
23
|
+
* DX in browser projects.
|
|
24
|
+
*/
|
|
25
|
+
type DefaultFetch = typeof globalThis extends {
|
|
26
|
+
fetch: infer F extends FetchFn;
|
|
27
|
+
} ? F : FetchFn;
|
|
28
|
+
interface CreateAuthedFetchOptions<TFetch extends FetchFn = DefaultFetch> {
|
|
29
|
+
/** Refresh endpoint path or URL. Default `'/auth/refresh'`. Match your `AuthController` mount. */
|
|
30
|
+
refreshPath?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Called exactly once per failed refresh attempt (non-OK response or network
|
|
33
|
+
* error). Use it to redirect to login / clear app state. Never called on a
|
|
34
|
+
* successful refresh.
|
|
35
|
+
*/
|
|
36
|
+
onLogout?: () => void;
|
|
37
|
+
/** Underlying fetch implementation. Default: the global `fetch`. */
|
|
38
|
+
fetch?: TFetch;
|
|
39
|
+
/** Response status codes that trigger a refresh + single retry. Default `[401]`. */
|
|
40
|
+
refreshOn?: number[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Wraps `fetch` with cookie-session silent refresh. Every call forwards
|
|
44
|
+
* credentials; a response whose status is in `refreshOn` (401 by default)
|
|
45
|
+
* triggers a **single-flight** refresh — N concurrent failing requests share
|
|
46
|
+
* exactly one `POST {refreshPath}` — and, on success, the original request is
|
|
47
|
+
* retried **once**. A failed refresh fires `onLogout()` once and returns the
|
|
48
|
+
* original failing response (no retry, no refresh storm).
|
|
49
|
+
*
|
|
50
|
+
* Browser-safe: pairs with the bundled `@aooth/auth-moost` httpOnly-cookie
|
|
51
|
+
* transport, so the SPA never touches token material. The retried request reuses
|
|
52
|
+
* the original `init`; passing a one-shot body (a consumed `ReadableStream`) is
|
|
53
|
+
* not retry-safe — prefer string / `FormData` / `Blob` bodies.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* import { createAuthedFetch } from '@aooth/auth/client'
|
|
58
|
+
* const api = createAuthedFetch({ refreshPath: '/auth/refresh', onLogout: () => location.assign('/login') })
|
|
59
|
+
* const res = await api('/api/me') // 401 → silent refresh → retried once
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
declare function createAuthedFetch<TFetch extends FetchFn = DefaultFetch>(options?: CreateAuthedFetchOptions<TFetch>): TFetch;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { CreateAuthedFetchOptions, DefaultFetch, FetchFn, MinimalRequestInit, MinimalResponse, createAuthedFetch };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region src/client/index.d.ts
|
|
2
|
+
/** Minimal structural view of a fetch `Response` this helper reads. The DOM `Response` satisfies it. */
|
|
3
|
+
interface MinimalResponse {
|
|
4
|
+
readonly ok: boolean;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
}
|
|
7
|
+
/** Subset of `RequestInit` this helper sets or forwards. The DOM `RequestInit` satisfies it. */
|
|
8
|
+
interface MinimalRequestInit {
|
|
9
|
+
method?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
body?: unknown;
|
|
12
|
+
credentials?: "omit" | "same-origin" | "include";
|
|
13
|
+
signal?: unknown;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
/** A `fetch`-compatible function. The global `fetch` satisfies this. */
|
|
17
|
+
type FetchFn = (input: string | URL, init?: MinimalRequestInit) => Promise<MinimalResponse>;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves to the ambient `fetch` type when the consumer's tsconfig includes the
|
|
20
|
+
* DOM lib — so the returned wrapper yields a real `Response` with `.json()` etc.
|
|
21
|
+
* — and falls back to the structural {@link FetchFn} otherwise. This keeps
|
|
22
|
+
* `@aooth/auth/client` free of a hard DOM-lib dependency while preserving great
|
|
23
|
+
* DX in browser projects.
|
|
24
|
+
*/
|
|
25
|
+
type DefaultFetch = typeof globalThis extends {
|
|
26
|
+
fetch: infer F extends FetchFn;
|
|
27
|
+
} ? F : FetchFn;
|
|
28
|
+
interface CreateAuthedFetchOptions<TFetch extends FetchFn = DefaultFetch> {
|
|
29
|
+
/** Refresh endpoint path or URL. Default `'/auth/refresh'`. Match your `AuthController` mount. */
|
|
30
|
+
refreshPath?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Called exactly once per failed refresh attempt (non-OK response or network
|
|
33
|
+
* error). Use it to redirect to login / clear app state. Never called on a
|
|
34
|
+
* successful refresh.
|
|
35
|
+
*/
|
|
36
|
+
onLogout?: () => void;
|
|
37
|
+
/** Underlying fetch implementation. Default: the global `fetch`. */
|
|
38
|
+
fetch?: TFetch;
|
|
39
|
+
/** Response status codes that trigger a refresh + single retry. Default `[401]`. */
|
|
40
|
+
refreshOn?: number[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Wraps `fetch` with cookie-session silent refresh. Every call forwards
|
|
44
|
+
* credentials; a response whose status is in `refreshOn` (401 by default)
|
|
45
|
+
* triggers a **single-flight** refresh — N concurrent failing requests share
|
|
46
|
+
* exactly one `POST {refreshPath}` — and, on success, the original request is
|
|
47
|
+
* retried **once**. A failed refresh fires `onLogout()` once and returns the
|
|
48
|
+
* original failing response (no retry, no refresh storm).
|
|
49
|
+
*
|
|
50
|
+
* Browser-safe: pairs with the bundled `@aooth/auth-moost` httpOnly-cookie
|
|
51
|
+
* transport, so the SPA never touches token material. The retried request reuses
|
|
52
|
+
* the original `init`; passing a one-shot body (a consumed `ReadableStream`) is
|
|
53
|
+
* not retry-safe — prefer string / `FormData` / `Blob` bodies.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* import { createAuthedFetch } from '@aooth/auth/client'
|
|
58
|
+
* const api = createAuthedFetch({ refreshPath: '/auth/refresh', onLogout: () => location.assign('/login') })
|
|
59
|
+
* const res = await api('/api/me') // 401 → silent refresh → retried once
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
declare function createAuthedFetch<TFetch extends FetchFn = DefaultFetch>(options?: CreateAuthedFetchOptions<TFetch>): TFetch;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { CreateAuthedFetchOptions, DefaultFetch, FetchFn, MinimalRequestInit, MinimalResponse, createAuthedFetch };
|
package/dist/client.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//#region src/client/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Wraps `fetch` with cookie-session silent refresh. Every call forwards
|
|
4
|
+
* credentials; a response whose status is in `refreshOn` (401 by default)
|
|
5
|
+
* triggers a **single-flight** refresh — N concurrent failing requests share
|
|
6
|
+
* exactly one `POST {refreshPath}` — and, on success, the original request is
|
|
7
|
+
* retried **once**. A failed refresh fires `onLogout()` once and returns the
|
|
8
|
+
* original failing response (no retry, no refresh storm).
|
|
9
|
+
*
|
|
10
|
+
* Browser-safe: pairs with the bundled `@aooth/auth-moost` httpOnly-cookie
|
|
11
|
+
* transport, so the SPA never touches token material. The retried request reuses
|
|
12
|
+
* the original `init`; passing a one-shot body (a consumed `ReadableStream`) is
|
|
13
|
+
* not retry-safe — prefer string / `FormData` / `Blob` bodies.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { createAuthedFetch } from '@aooth/auth/client'
|
|
18
|
+
* const api = createAuthedFetch({ refreshPath: '/auth/refresh', onLogout: () => location.assign('/login') })
|
|
19
|
+
* const res = await api('/api/me') // 401 → silent refresh → retried once
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
function createAuthedFetch(options = {}) {
|
|
23
|
+
const refreshPath = options.refreshPath ?? "/auth/refresh";
|
|
24
|
+
const onLogout = options.onLogout;
|
|
25
|
+
const refreshOn = options.refreshOn ?? [401];
|
|
26
|
+
const baseFetch = options.fetch ?? globalThis.fetch;
|
|
27
|
+
if (typeof baseFetch !== "function") throw new Error("createAuthedFetch: no fetch implementation available — pass options.fetch");
|
|
28
|
+
let refreshInFlight = null;
|
|
29
|
+
const runRefresh = () => {
|
|
30
|
+
if (refreshInFlight) return refreshInFlight;
|
|
31
|
+
refreshInFlight = (async () => {
|
|
32
|
+
try {
|
|
33
|
+
if ((await baseFetch(refreshPath, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
credentials: "include"
|
|
36
|
+
})).ok) return true;
|
|
37
|
+
} catch {}
|
|
38
|
+
onLogout?.();
|
|
39
|
+
return false;
|
|
40
|
+
})().finally(() => {
|
|
41
|
+
refreshInFlight = null;
|
|
42
|
+
});
|
|
43
|
+
return refreshInFlight;
|
|
44
|
+
};
|
|
45
|
+
const authedFetch = async (input, init) => {
|
|
46
|
+
const withCreds = {
|
|
47
|
+
credentials: "include",
|
|
48
|
+
...init
|
|
49
|
+
};
|
|
50
|
+
const res = await baseFetch(input, withCreds);
|
|
51
|
+
if (!refreshOn.includes(res.status)) return res;
|
|
52
|
+
if (!await runRefresh()) return res;
|
|
53
|
+
return baseFetch(input, withCreds);
|
|
54
|
+
};
|
|
55
|
+
return authedFetch;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
export { createAuthedFetch };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/utils/clock.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Minimal time abstraction shared across stores and orchestrators.
|
|
4
|
+
*
|
|
5
|
+
* Defaults to wall-clock; tests inject a fake clock to deterministically
|
|
6
|
+
* advance time. Kept tiny (single `now()` method) so any timing primitive
|
|
7
|
+
* — Date, performance.now-ish, monotonic, etc. — can be plugged in.
|
|
8
|
+
*/
|
|
9
|
+
interface Clock {
|
|
10
|
+
now(): number;
|
|
11
|
+
}
|
|
12
|
+
declare const defaultClock: Clock;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { defaultClock as n, Clock as t };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/utils/clock.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Minimal time abstraction shared across stores and orchestrators.
|
|
4
|
+
*
|
|
5
|
+
* Defaults to wall-clock; tests inject a fake clock to deterministically
|
|
6
|
+
* advance time. Kept tiny (single `now()` method) so any timing primitive
|
|
7
|
+
* — Date, performance.now-ish, monotonic, etc. — can be plugged in.
|
|
8
|
+
*/
|
|
9
|
+
interface Clock {
|
|
10
|
+
now(): number;
|
|
11
|
+
}
|
|
12
|
+
declare const defaultClock: Clock;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { defaultClock as n, Clock as t };
|