@~lyre/auth 0.11.0 → 0.12.0

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.
@@ -0,0 +1,971 @@
1
+ // src/index.ts
2
+ import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
3
+
4
+ // src/oidc.ts
5
+ import {
6
+ createHash,
7
+ createPublicKey,
8
+ randomBytes,
9
+ timingSafeEqual,
10
+ verify as cryptoVerify
11
+ } from "crypto";
12
+ var OidcError = class extends Error {
13
+ code;
14
+ /** HTTP status from Accounts, when the failure was an HTTP response. */
15
+ status;
16
+ /** OAuth `error` field (`invalid_grant`, `invalid_client`, `access_denied`, …). */
17
+ oauthError;
18
+ oauthErrorDescription;
19
+ constructor(code, message, details = {}) {
20
+ super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
21
+ this.name = "OidcError";
22
+ this.code = code;
23
+ this.status = details.status;
24
+ this.oauthError = details.oauthError;
25
+ this.oauthErrorDescription = details.oauthErrorDescription;
26
+ }
27
+ };
28
+ function isOidcError(error, code) {
29
+ return error instanceof OidcError && (code === void 0 || error.code === code);
30
+ }
31
+ var ACCOUNTS_ENDPOINT_PATHS = Object.freeze({
32
+ authorization: "/auth/authorize",
33
+ token: "/api/auth/token",
34
+ userinfo: "/api/auth/userinfo",
35
+ jwks: "/api/.well-known/jwks.json",
36
+ revocation: "/api/auth/revoke",
37
+ endSession: "/auth/logout"
38
+ });
39
+ var DEFAULT_OIDC_SCOPES = ["openid", "profile", "email", "offline_access"];
40
+ function b64url(buf) {
41
+ return buf.toString("base64url");
42
+ }
43
+ function randomToken(bytes = 32) {
44
+ return b64url(randomBytes(bytes));
45
+ }
46
+ function pkceChallenge(verifier) {
47
+ return b64url(createHash("sha256").update(verifier, "ascii").digest());
48
+ }
49
+ function createPkcePair() {
50
+ const codeVerifier = randomToken(32);
51
+ return { codeVerifier, codeChallenge: pkceChallenge(codeVerifier) };
52
+ }
53
+ function safeEqual(a, b) {
54
+ const ab = Buffer.from(a);
55
+ const bb = Buffer.from(b);
56
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
57
+ }
58
+ function stripSlash(value) {
59
+ return value.replace(/\/+$/, "");
60
+ }
61
+ function resolveUrl(value, issuer) {
62
+ return new URL(value, issuer + "/").toString();
63
+ }
64
+ function normalizeScopes(scopes) {
65
+ if (scopes === void 0) return DEFAULT_OIDC_SCOPES.join(" ");
66
+ const list = typeof scopes === "string" ? scopes.split(/[\s,]+/) : [...scopes];
67
+ return list.map((s) => s.trim()).filter(Boolean).join(" ");
68
+ }
69
+ function formEncode(value) {
70
+ return encodeURIComponent(value).replace(/%20/g, "+");
71
+ }
72
+ async function safeFetch(fetchImpl, url, init, code) {
73
+ try {
74
+ return await fetchImpl(url, init);
75
+ } catch (cause) {
76
+ throw new OidcError(code === "token_request_failed" ? "network_error" : code, `Request to ${url} failed.`, {
77
+ cause
78
+ });
79
+ }
80
+ }
81
+ async function readJson(res) {
82
+ try {
83
+ const data = await res.json();
84
+ return data && typeof data === "object" && !Array.isArray(data) ? data : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ function createMetadataSource(opts) {
90
+ const issuer = stripSlash(opts.issuer);
91
+ const fetchImpl = opts.fetchImpl ?? fetch;
92
+ const now = opts.now ?? Date.now;
93
+ const ttl = opts.metadataCacheTtlMs ?? 60 * 60 * 1e3;
94
+ const override = (key) => opts.endpoints?.[key];
95
+ const fromPaths = () => {
96
+ const out = {};
97
+ for (const key of Object.keys(ACCOUNTS_ENDPOINT_PATHS)) {
98
+ out[key] = resolveUrl(override(key) ?? ACCOUNTS_ENDPOINT_PATHS[key], issuer);
99
+ }
100
+ return out;
101
+ };
102
+ if (!opts.discovery) {
103
+ const fixed = fromPaths();
104
+ return { get: async () => fixed };
105
+ }
106
+ let cached = null;
107
+ let inflight = null;
108
+ async function load() {
109
+ const url = `${issuer}/.well-known/openid-configuration`;
110
+ const res = await safeFetch(fetchImpl, url, { headers: { accept: "application/json" } }, "discovery_failed");
111
+ if (!res.ok) throw new OidcError("discovery_failed", `Discovery returned ${res.status}.`, { status: res.status });
112
+ const doc = await readJson(res);
113
+ if (!doc) throw new OidcError("discovery_failed", "Discovery document is not JSON.");
114
+ if (typeof doc.issuer !== "string" || stripSlash(doc.issuer) !== issuer) {
115
+ throw new OidcError("issuer_mismatch", `Discovery issuer ${String(doc.issuer)} is not ${issuer}.`);
116
+ }
117
+ const pick = (key, field) => {
118
+ const explicit = override(key);
119
+ if (explicit) return resolveUrl(explicit, issuer);
120
+ const advertised = doc[field];
121
+ return typeof advertised === "string" && advertised ? advertised : resolveUrl(ACCOUNTS_ENDPOINT_PATHS[key], issuer);
122
+ };
123
+ return {
124
+ authorization: pick("authorization", "authorization_endpoint"),
125
+ token: pick("token", "token_endpoint"),
126
+ userinfo: pick("userinfo", "userinfo_endpoint"),
127
+ jwks: pick("jwks", "jwks_uri"),
128
+ revocation: pick("revocation", "revocation_endpoint"),
129
+ endSession: pick("endSession", "end_session_endpoint")
130
+ };
131
+ }
132
+ return {
133
+ async get() {
134
+ if (cached && cached.expires > now()) return cached.value;
135
+ inflight ??= load().then((value) => {
136
+ cached = { value, expires: now() + ttl };
137
+ return value;
138
+ }).finally(() => {
139
+ inflight = null;
140
+ });
141
+ return inflight;
142
+ }
143
+ };
144
+ }
145
+ var JwksStore = class {
146
+ constructor(metadata, fetchImpl, ttlMs, cooldownMs, now) {
147
+ this.metadata = metadata;
148
+ this.fetchImpl = fetchImpl;
149
+ this.ttlMs = ttlMs;
150
+ this.cooldownMs = cooldownMs;
151
+ this.now = now;
152
+ }
153
+ metadata;
154
+ fetchImpl;
155
+ ttlMs;
156
+ cooldownMs;
157
+ now;
158
+ keys = /* @__PURE__ */ new Map();
159
+ anonymous = [];
160
+ fetchedAt = -Infinity;
161
+ inflight = null;
162
+ /** Number of JWKS fetches performed (observability / tests). */
163
+ fetchCount = 0;
164
+ async refresh() {
165
+ this.inflight ??= (async () => {
166
+ const { jwks } = await this.metadata.get();
167
+ const res = await safeFetch(this.fetchImpl, jwks, { headers: { accept: "application/json" } }, "jwks_fetch_failed");
168
+ if (!res.ok) throw new OidcError("jwks_fetch_failed", `JWKS returned ${res.status}.`, { status: res.status });
169
+ const body = await readJson(res);
170
+ if (!body || !Array.isArray(body.keys)) throw new OidcError("jwks_fetch_failed", "JWKS is not a key set.");
171
+ const keys = /* @__PURE__ */ new Map();
172
+ const anonymous = [];
173
+ for (const jwk of body.keys) {
174
+ if (!jwk || jwk.kty !== "RSA") continue;
175
+ if (jwk.use && jwk.use !== "sig") continue;
176
+ if (jwk.alg && jwk.alg !== "RS256") continue;
177
+ let key;
178
+ try {
179
+ key = createPublicKey({ key: jwk, format: "jwk" });
180
+ } catch {
181
+ continue;
182
+ }
183
+ if (jwk.kid) keys.set(jwk.kid, key);
184
+ else anonymous.push(key);
185
+ }
186
+ this.fetchCount++;
187
+ this.keys = keys;
188
+ this.anonymous = anonymous;
189
+ this.fetchedAt = this.now();
190
+ })().finally(() => {
191
+ this.inflight = null;
192
+ });
193
+ return this.inflight;
194
+ }
195
+ lookup(kid) {
196
+ if (kid) return this.keys.get(kid);
197
+ const all = [...this.keys.values(), ...this.anonymous];
198
+ return all.length === 1 ? all[0] : void 0;
199
+ }
200
+ async getKey(kid) {
201
+ const age = this.now() - this.fetchedAt;
202
+ if (age >= this.ttlMs) await this.refresh();
203
+ let key = this.lookup(kid);
204
+ if (!key && this.now() - this.fetchedAt >= this.cooldownMs) {
205
+ await this.refresh();
206
+ key = this.lookup(kid);
207
+ }
208
+ if (!key) throw new OidcError("unknown_kid", `No signing key for kid ${kid ?? "(none)"}.`);
209
+ return key;
210
+ }
211
+ };
212
+ function decodeJwt(token) {
213
+ const parts = typeof token === "string" ? token.split(".") : [];
214
+ if (parts.length !== 3 || parts.some((p) => !/^[A-Za-z0-9_-]*$/.test(p)) || !parts[0] || !parts[1]) {
215
+ throw new OidcError("invalid_jwt", "Token is not a compact JWS.");
216
+ }
217
+ const parse = (segment) => {
218
+ try {
219
+ const value = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
220
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("not an object");
221
+ return value;
222
+ } catch {
223
+ throw new OidcError("invalid_jwt", "Token segment is not base64url JSON.");
224
+ }
225
+ };
226
+ return {
227
+ header: parse(parts[0]),
228
+ payload: parse(parts[1]),
229
+ signingInput: `${parts[0]}.${parts[1]}`,
230
+ signature: Buffer.from(parts[2], "base64url")
231
+ };
232
+ }
233
+ function looksLikeJwt(token) {
234
+ return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/.test(token);
235
+ }
236
+ function checkClaims(payload, checks) {
237
+ if (typeof payload.iss !== "string" || stripSlash(payload.iss) !== checks.issuer) {
238
+ throw new OidcError("issuer_mismatch", `Token issuer ${String(payload.iss)} is not ${checks.issuer}.`);
239
+ }
240
+ if (typeof payload.sub !== "string" || !payload.sub) {
241
+ throw new OidcError("invalid_jwt", "Token has no subject.");
242
+ }
243
+ const aud = payload.aud;
244
+ const auds = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud.filter((a) => typeof a === "string") : [];
245
+ if (auds.length === 0) throw new OidcError("audience_mismatch", "Token has no audience.");
246
+ if (checks.audience) {
247
+ if (!auds.some((a) => checks.audience.includes(a))) {
248
+ throw new OidcError("audience_mismatch", `Token audience ${auds.join(",")} is not accepted.`);
249
+ }
250
+ if (auds.length > 1 && typeof payload.azp === "string" && !checks.audience.includes(payload.azp)) {
251
+ throw new OidcError("audience_mismatch", `Token azp ${payload.azp} is not accepted.`);
252
+ }
253
+ }
254
+ const nowSec = checks.nowMs / 1e3;
255
+ if (typeof payload.exp !== "number") throw new OidcError("invalid_jwt", "Token has no exp.");
256
+ if (nowSec > payload.exp + checks.clockToleranceSec) throw new OidcError("token_expired", "Token has expired.");
257
+ if (typeof payload.nbf === "number" && nowSec + checks.clockToleranceSec < payload.nbf) {
258
+ throw new OidcError("token_not_yet_valid", "Token is not valid yet.");
259
+ }
260
+ if (checks.nonce !== void 0) {
261
+ if (typeof payload.nonce !== "string" || !safeEqual(payload.nonce, checks.nonce)) {
262
+ throw new OidcError("nonce_mismatch", "id_token nonce is not the one this app sent.");
263
+ }
264
+ }
265
+ }
266
+ async function verifyJwt(token, store, checks) {
267
+ const { header, payload, signingInput, signature } = decodeJwt(token);
268
+ if (header.alg !== "RS256") throw new OidcError("unsupported_alg", `Unsupported alg ${String(header.alg)}.`);
269
+ const key = await store.getKey(typeof header.kid === "string" ? header.kid : void 0);
270
+ const valid = cryptoVerify("RSA-SHA256", Buffer.from(signingInput), key, signature);
271
+ if (!valid) throw new OidcError("invalid_signature", "Token signature is invalid.");
272
+ checkClaims(payload, checks);
273
+ return payload;
274
+ }
275
+ function toList(value) {
276
+ if (value === void 0) return null;
277
+ return typeof value === "string" ? [value] : [...value];
278
+ }
279
+ function createInternals(opts) {
280
+ if (!opts.issuer) throw new OidcError("invalid_config", "issuer is required.");
281
+ const fetchImpl = opts.fetchImpl ?? ((...args) => fetch(...args));
282
+ const now = opts.now ?? Date.now;
283
+ const metadata = createMetadataSource({ ...opts, fetchImpl, now });
284
+ return {
285
+ issuer: stripSlash(opts.issuer),
286
+ metadata,
287
+ jwks: new JwksStore(metadata, fetchImpl, opts.jwksCacheTtlMs ?? 60 * 60 * 1e3, opts.jwksRefetchCooldownMs ?? 5e3, now),
288
+ fetchImpl,
289
+ now,
290
+ tolerance: opts.clockToleranceSec ?? 30
291
+ };
292
+ }
293
+ async function userInfoRequest(internals, accessToken) {
294
+ const { userinfo } = await internals.metadata.get();
295
+ const res = await safeFetch(
296
+ internals.fetchImpl,
297
+ userinfo,
298
+ { headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" } },
299
+ "userinfo_failed"
300
+ );
301
+ const body = await readJson(res);
302
+ if (!res.ok || !body || typeof body.sub !== "string") {
303
+ throw new OidcError("userinfo_failed", `userinfo returned ${res.status}.`, {
304
+ status: res.status,
305
+ oauthError: typeof body?.error === "string" ? body.error : void 0
306
+ });
307
+ }
308
+ return body;
309
+ }
310
+ async function verifyAccess(internals, token, audience, remoteFallback) {
311
+ const checks = {
312
+ issuer: internals.issuer,
313
+ audience,
314
+ clockToleranceSec: internals.tolerance,
315
+ nowMs: internals.now()
316
+ };
317
+ const guardType = (payload) => {
318
+ if ("nonce" in payload) throw new OidcError("wrong_token_type", "An id_token is not an access token.");
319
+ };
320
+ try {
321
+ const payload = await verifyJwt(token, internals.jwks, checks);
322
+ guardType(payload);
323
+ return { claims: payload, verifiedBy: "jwks" };
324
+ } catch (error) {
325
+ const unreachable = isOidcError(error, "jwks_fetch_failed") || isOidcError(error, "discovery_failed");
326
+ if (!remoteFallback || !unreachable) throw error;
327
+ const { payload } = decodeJwt(token);
328
+ checkClaims(payload, checks);
329
+ guardType(payload);
330
+ const info = await userInfoRequest(internals, token);
331
+ if (info.sub !== payload.sub) throw new OidcError("userinfo_failed", "userinfo subject does not match the token.");
332
+ return { claims: payload, verifiedBy: "userinfo" };
333
+ }
334
+ }
335
+ function createAccessTokenVerifier(opts) {
336
+ const internals = createInternals(opts);
337
+ const defaultAudience = toList(opts.audience);
338
+ const remoteFallback = opts.remoteFallback ?? true;
339
+ return {
340
+ verify(token, options) {
341
+ const audience = options?.audience !== void 0 ? toList(options.audience) : defaultAudience;
342
+ return verifyAccess(internals, token, audience, remoteFallback);
343
+ },
344
+ fetchUserInfo: (accessToken) => userInfoRequest(internals, accessToken),
345
+ metadata: () => internals.metadata.get(),
346
+ _jwks: internals.jwks
347
+ };
348
+ }
349
+ function createOidcClient(opts) {
350
+ if (!opts.clientId) throw new OidcError("invalid_config", "clientId is required.");
351
+ if (!opts.redirectUri) throw new OidcError("invalid_config", "redirectUri is required.");
352
+ const internals = createInternals(opts);
353
+ const secret = opts.clientSecret?.trim() || void 0;
354
+ const authMethod = opts.tokenEndpointAuthMethod ?? (secret ? "client_secret_basic" : "none");
355
+ if (authMethod !== "none" && !secret) {
356
+ throw new OidcError("invalid_config", `${authMethod} needs a clientSecret.`);
357
+ }
358
+ const scope = normalizeScopes(opts.scopes);
359
+ const clientId = opts.clientId;
360
+ function applyClientAuth(body, headers) {
361
+ if (authMethod === "client_secret_basic") {
362
+ const raw = `${formEncode(clientId)}:${formEncode(secret)}`;
363
+ headers.authorization = `Basic ${Buffer.from(raw).toString("base64")}`;
364
+ } else if (authMethod === "client_secret_post") {
365
+ body.set("client_id", clientId);
366
+ body.set("client_secret", secret);
367
+ } else {
368
+ body.set("client_id", clientId);
369
+ }
370
+ }
371
+ async function tokenRequest(params, requireIdToken) {
372
+ const { token: endpoint } = await internals.metadata.get();
373
+ const body = new URLSearchParams(params);
374
+ const headers = {
375
+ "content-type": "application/x-www-form-urlencoded",
376
+ accept: "application/json"
377
+ };
378
+ applyClientAuth(body, headers);
379
+ const res = await safeFetch(
380
+ internals.fetchImpl,
381
+ endpoint,
382
+ { method: "POST", headers, body: body.toString() },
383
+ "token_request_failed"
384
+ );
385
+ const json = await readJson(res);
386
+ if (!res.ok) {
387
+ const oauthError = typeof json?.error === "string" ? json.error : void 0;
388
+ throw new OidcError(
389
+ "token_request_failed",
390
+ `Token endpoint returned ${res.status}${oauthError ? ` (${oauthError})` : ""}.`,
391
+ {
392
+ status: res.status,
393
+ oauthError,
394
+ oauthErrorDescription: typeof json?.error_description === "string" ? json.error_description : void 0
395
+ }
396
+ );
397
+ }
398
+ if (!json || typeof json.access_token !== "string" || !json.access_token) {
399
+ throw new OidcError("invalid_token_response", "Token response has no access_token.");
400
+ }
401
+ if (typeof json.token_type !== "string" || json.token_type.toLowerCase() !== "bearer") {
402
+ throw new OidcError("invalid_token_response", `Unexpected token_type ${String(json.token_type)}.`);
403
+ }
404
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : Number(json.expires_in ?? 0) || 0;
405
+ const tokens = {
406
+ accessToken: json.access_token,
407
+ tokenType: "Bearer",
408
+ expiresIn,
409
+ expiresAt: internals.now() + expiresIn * 1e3,
410
+ refreshToken: typeof json.refresh_token === "string" && json.refresh_token ? json.refresh_token : void 0,
411
+ idToken: typeof json.id_token === "string" && json.id_token ? json.id_token : void 0,
412
+ scope: typeof json.scope === "string" ? json.scope : void 0,
413
+ raw: json
414
+ };
415
+ if (requireIdToken && !tokens.idToken) {
416
+ throw new OidcError("missing_id_token", "openid was requested but no id_token was returned.");
417
+ }
418
+ return tokens;
419
+ }
420
+ async function verifyIdToken(idToken, options) {
421
+ const payload = await verifyJwt(idToken, internals.jwks, {
422
+ issuer: internals.issuer,
423
+ audience: [clientId],
424
+ nonce: options?.nonce,
425
+ clockToleranceSec: internals.tolerance,
426
+ nowMs: internals.now()
427
+ });
428
+ return payload;
429
+ }
430
+ const scopeHasOpenId = (value) => value.split(" ").includes("openid");
431
+ return {
432
+ clientId,
433
+ issuer: internals.issuer,
434
+ scope,
435
+ authMethod,
436
+ metadata: () => internals.metadata.get(),
437
+ _jwks: internals.jwks,
438
+ async beginLogin(options = {}) {
439
+ const { authorization } = await internals.metadata.get();
440
+ const state = randomToken(32);
441
+ const nonce = randomToken(32);
442
+ const { codeVerifier, codeChallenge } = createPkcePair();
443
+ const url = new URL(authorization);
444
+ for (const [k, v] of Object.entries(options.extraParams ?? {})) url.searchParams.set(k, v);
445
+ url.searchParams.set("client_id", clientId);
446
+ url.searchParams.set("redirect_uri", opts.redirectUri);
447
+ url.searchParams.set("response_type", "code");
448
+ url.searchParams.set("scope", options.scopes !== void 0 ? normalizeScopes(options.scopes) : scope);
449
+ url.searchParams.set("state", state);
450
+ url.searchParams.set("nonce", nonce);
451
+ url.searchParams.set("code_challenge", codeChallenge);
452
+ url.searchParams.set("code_challenge_method", "S256");
453
+ if (options.prompt) url.searchParams.set("prompt", options.prompt);
454
+ if (options.loginHint) url.searchParams.set("login_hint", options.loginHint);
455
+ return { url: url.toString(), state, nonce, codeVerifier };
456
+ },
457
+ async handleCallback(input) {
458
+ let params;
459
+ if (input.url !== void 0) params = new URL(input.url.toString()).searchParams;
460
+ else if (input.params instanceof URLSearchParams) params = input.params;
461
+ else {
462
+ params = new URLSearchParams();
463
+ for (const [k, v] of Object.entries(input.params ?? {})) if (v !== void 0) params.set(k, v);
464
+ }
465
+ const expected = input.expected;
466
+ const state = params.get("state");
467
+ if (!expected?.state || !state || !safeEqual(state, expected.state)) {
468
+ throw new OidcError("state_mismatch", "Callback state does not match the login this browser started.");
469
+ }
470
+ const error = params.get("error");
471
+ if (error) {
472
+ throw new OidcError("authorization_error", `Accounts refused the sign-in: ${error}.`, {
473
+ oauthError: error,
474
+ oauthErrorDescription: params.get("error_description") ?? void 0
475
+ });
476
+ }
477
+ const iss = params.get("iss");
478
+ if (iss && stripSlash(iss) !== internals.issuer) {
479
+ throw new OidcError("issuer_mismatch", `Callback iss ${iss} is not ${internals.issuer}.`);
480
+ }
481
+ const code = params.get("code");
482
+ if (!code) throw new OidcError("missing_code", "Callback has no authorization code.");
483
+ if (!expected.nonce || !expected.codeVerifier) {
484
+ throw new OidcError("state_mismatch", "Stored login is missing its nonce or PKCE verifier.");
485
+ }
486
+ const tokens = await tokenRequest(
487
+ {
488
+ grant_type: "authorization_code",
489
+ code,
490
+ redirect_uri: opts.redirectUri,
491
+ code_verifier: expected.codeVerifier
492
+ },
493
+ true
494
+ );
495
+ const claims = await verifyIdToken(tokens.idToken, { nonce: expected.nonce });
496
+ return { claims, tokens };
497
+ },
498
+ exchangeCode({ code, codeVerifier }) {
499
+ return tokenRequest(
500
+ { grant_type: "authorization_code", code, redirect_uri: opts.redirectUri, code_verifier: codeVerifier },
501
+ scopeHasOpenId(scope)
502
+ );
503
+ },
504
+ async refresh(refreshToken, options) {
505
+ const params = { grant_type: "refresh_token", refresh_token: refreshToken };
506
+ if (options?.scopes !== void 0) params.scope = normalizeScopes(options.scopes);
507
+ const tokens = await tokenRequest(params, false);
508
+ if (tokens.idToken) await verifyIdToken(tokens.idToken);
509
+ return tokens;
510
+ },
511
+ async revoke(token, tokenTypeHint = "refresh_token") {
512
+ const { revocation } = await internals.metadata.get();
513
+ const body = new URLSearchParams({ token, token_type_hint: tokenTypeHint });
514
+ const headers = { "content-type": "application/x-www-form-urlencoded" };
515
+ applyClientAuth(body, headers);
516
+ const res = await safeFetch(
517
+ internals.fetchImpl,
518
+ revocation,
519
+ { method: "POST", headers, body: body.toString() },
520
+ "revocation_failed"
521
+ );
522
+ if (!res.ok) throw new OidcError("revocation_failed", `Revocation returned ${res.status}.`, { status: res.status });
523
+ },
524
+ verifyIdToken,
525
+ verifyAccessToken(token, options) {
526
+ const audience = options?.audience !== void 0 ? toList(options.audience) : [clientId];
527
+ return verifyAccess(internals, token, audience, options?.remoteFallback ?? true);
528
+ },
529
+ fetchUserInfo: (accessToken) => userInfoRequest(internals, accessToken),
530
+ async logoutUrl(options = {}) {
531
+ const { endSession } = await internals.metadata.get();
532
+ const url = new URL(endSession);
533
+ url.searchParams.set("client_id", clientId);
534
+ const target = options.postLogoutRedirectUri ?? opts.postLogoutRedirectUri;
535
+ if (target) url.searchParams.set("post_logout_redirect_uri", target);
536
+ if (options.state) url.searchParams.set("state", options.state);
537
+ if (options.idTokenHint) url.searchParams.set("id_token_hint", options.idTokenHint);
538
+ return url.toString();
539
+ }
540
+ };
541
+ }
542
+
543
+ // src/index.ts
544
+ var SESSION_COOKIE_NAME = "platform_session";
545
+ var STATE_COOKIE_NAME = "accounts_auth_state";
546
+ function normalizeOptionalString(value) {
547
+ const trimmed = value?.trim();
548
+ return trimmed ? trimmed : void 0;
549
+ }
550
+ function createAccountsClientConfig(input = {}) {
551
+ return {
552
+ baseUrl: normalizeOptionalString(input.baseUrl ?? process.env.ACCOUNTS_BASE_URL),
553
+ issuer: normalizeOptionalString(input.issuer ?? process.env.ACCOUNTS_ISSUER),
554
+ scopes: input.scopes ?? (normalizeOptionalString(process.env.ACCOUNTS_SCOPES)?.split(/[\s,]+/).filter(Boolean) ?? [
555
+ ...DEFAULT_OIDC_SCOPES
556
+ ]),
557
+ discovery: input.discovery ?? process.env.ACCOUNTS_DISCOVERY === "true",
558
+ clientId: input.clientId ?? process.env.ACCOUNTS_CLIENT_ID ?? "accounts-app",
559
+ clientSecret: normalizeOptionalString(input.clientSecret ?? process.env.ACCOUNTS_CLIENT_SECRET),
560
+ redirectUri: input.redirectUri ?? process.env.ACCOUNTS_REDIRECT_URI ?? "http://localhost:5173/auth/callback",
561
+ logoutRedirectUri: input.logoutRedirectUri ?? process.env.ACCOUNTS_LOGOUT_REDIRECT_URI ?? "http://localhost:5173/",
562
+ useMock: input.useMock ?? (process.env.ACCOUNTS_USE_MOCK === "true" || !process.env.ACCOUNTS_BASE_URL || !process.env.ACCOUNTS_CLIENT_ID)
563
+ };
564
+ }
565
+ function createOidcClientFromConfig(config, overrides = {}) {
566
+ const issuer = overrides.issuer ?? config.issuer ?? config.baseUrl;
567
+ if (!issuer) throw new Error("Accounts issuer/baseUrl is required for sign-in.");
568
+ return createOidcClient({
569
+ issuer,
570
+ clientId: config.clientId,
571
+ clientSecret: config.clientSecret,
572
+ redirectUri: config.redirectUri,
573
+ postLogoutRedirectUri: config.logoutRedirectUri,
574
+ scopes: config.scopes,
575
+ discovery: config.discovery,
576
+ ...overrides
577
+ });
578
+ }
579
+ function identityFromClaims(claims, userInfo) {
580
+ const c = { ...claims, ...userInfo ?? {} };
581
+ const str = (k) => typeof c[k] === "string" && c[k] ? c[k] : void 0;
582
+ const firstName = str("given_name");
583
+ const lastName = str("family_name");
584
+ const sub = claims.sub;
585
+ return {
586
+ id: sub,
587
+ email: str("email") ?? `${sub}@accounts.local`,
588
+ name: str("name") ?? ([firstName, lastName].filter(Boolean).join(" ") || "Accounts User"),
589
+ firstName,
590
+ lastName,
591
+ avatarUrl: str("picture")
592
+ };
593
+ }
594
+ function safeNextPath(value) {
595
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return "/";
596
+ return value;
597
+ }
598
+ function createPlatformAuth() {
599
+ return {
600
+ readSession(cookieHeader) {
601
+ return readPlatformSessionCookie(cookieHeader);
602
+ },
603
+ requirePrincipal(session) {
604
+ if (!session.principal) {
605
+ throw new Error("Authentication required.");
606
+ }
607
+ return session.principal;
608
+ }
609
+ };
610
+ }
611
+ function beginAccountsLoginRedirect(config, options) {
612
+ const nonce = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
613
+ const state = {
614
+ nonce,
615
+ nextPath: options.nextPath && options.nextPath.startsWith("/") ? options.nextPath : "/",
616
+ tenantSlug: options.tenantSlug
617
+ };
618
+ if (config.useMock) {
619
+ const redirectUrl = new URL(config.redirectUri);
620
+ redirectUrl.searchParams.set("code", `demo_${nonce}`);
621
+ redirectUrl.searchParams.set("state", serializeAuthorizationState(state));
622
+ return {
623
+ redirectUrl: redirectUrl.toString(),
624
+ stateValue: serializeAuthorizationState(state),
625
+ stateCookie: createStateCookie(serializeAuthorizationState(state), options.cookieOptions)
626
+ };
627
+ }
628
+ const authorizeUrl = new URL("/auth/authorize", config.baseUrl);
629
+ authorizeUrl.searchParams.set("app_id", config.clientId);
630
+ authorizeUrl.searchParams.set("redirect_uri", config.redirectUri);
631
+ authorizeUrl.searchParams.set("state", serializeAuthorizationState(state));
632
+ return {
633
+ redirectUrl: authorizeUrl.toString(),
634
+ stateValue: serializeAuthorizationState(state),
635
+ stateCookie: createStateCookie(serializeAuthorizationState(state))
636
+ };
637
+ }
638
+ async function handleAccountsCallback(input) {
639
+ if (!input.code) {
640
+ throw new Error("Missing authorization code.");
641
+ }
642
+ if (input.storedState && input.state !== input.storedState) {
643
+ throw new Error("Invalid authorization state.");
644
+ }
645
+ const parsedState = parseAuthorizationState(input.state);
646
+ const tokenResponse = await exchangeAuthorizationCode({
647
+ config: input.config,
648
+ code: input.code,
649
+ fetchImpl: input.fetchImpl
650
+ });
651
+ const identity = normalizeIdentity(tokenResponse.user);
652
+ const local = await input.syncAccountsUser(identity);
653
+ const session = {
654
+ principal: {
655
+ identity,
656
+ user: local.user,
657
+ tenantAccess: {
658
+ activeTenantId: local.activeTenantId,
659
+ memberships: local.memberships
660
+ }
661
+ },
662
+ tenant: null,
663
+ accessToken: tokenResponse.access_token,
664
+ refreshToken: tokenResponse.refresh_token,
665
+ expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1e3).toISOString()
666
+ };
667
+ return {
668
+ session,
669
+ nextPath: parsedState.nextPath,
670
+ tenantSlug: parsedState.tenantSlug,
671
+ sessionCookie: createSessionCookie(session, input.cookieOptions, input.secret),
672
+ clearStateCookie: clearStateCookie(input.cookieOptions)
673
+ };
674
+ }
675
+ async function exchangeAuthorizationCode(input) {
676
+ if (input.config.useMock) {
677
+ return createMockTokenResponse(input.code);
678
+ }
679
+ const fetchImpl = input.fetchImpl ?? fetch;
680
+ const clientSecret = normalizeOptionalString(input.config.clientSecret);
681
+ const tokenBody = {
682
+ grant_type: "authorization_code",
683
+ code: input.code,
684
+ redirect_uri: input.config.redirectUri,
685
+ client_id: input.config.clientId
686
+ };
687
+ if (clientSecret) {
688
+ tokenBody.client_secret = clientSecret;
689
+ }
690
+ const response = await fetchImpl(new URL("/api/auth/token", input.config.baseUrl), {
691
+ method: "POST",
692
+ headers: {
693
+ "content-type": "application/json"
694
+ },
695
+ body: JSON.stringify(tokenBody)
696
+ });
697
+ if (!response.ok) {
698
+ throw new Error(`Accounts token exchange failed with status ${response.status}.`);
699
+ }
700
+ return await response.json();
701
+ }
702
+ function syncAccountsUser(identity, syncer) {
703
+ return syncer(identity);
704
+ }
705
+ var identityPassthroughSync = (identity) => ({
706
+ user: {
707
+ id: identity.id,
708
+ externalIdentityId: identity.id,
709
+ email: identity.email,
710
+ name: identity.name,
711
+ firstName: identity.firstName,
712
+ lastName: identity.lastName
713
+ },
714
+ memberships: []
715
+ });
716
+ function resolveActiveTenant(memberships, preferredTenantId) {
717
+ if (preferredTenantId && memberships.some((membership) => membership.tenantId === preferredTenantId)) {
718
+ return preferredTenantId;
719
+ }
720
+ return memberships.find((membership) => membership.isDefault)?.tenantId ?? memberships[0]?.tenantId;
721
+ }
722
+ function readPlatformSessionCookie(cookieHeader, opts) {
723
+ const cookieValue = readCookie(cookieHeader, SESSION_COOKIE_NAME);
724
+ if (!cookieValue) {
725
+ return { principal: null, tenant: null };
726
+ }
727
+ let raw = cookieValue;
728
+ if (opts?.secret) {
729
+ const verified = unsignValue(cookieValue, opts.secret);
730
+ if (verified == null) return { principal: null, tenant: null };
731
+ raw = verified;
732
+ }
733
+ try {
734
+ return JSON.parse(decodeURIComponent(raw));
735
+ } catch {
736
+ return { principal: null, tenant: null };
737
+ }
738
+ }
739
+ function cookieAttrs(opts) {
740
+ const sameSite = opts?.sameSite ?? "lax";
741
+ const secure = opts?.secure || sameSite === "none";
742
+ const cap = sameSite.charAt(0).toUpperCase() + sameSite.slice(1);
743
+ return `Path=/; HttpOnly; SameSite=${cap}${secure ? "; Secure" : ""}`;
744
+ }
745
+ function signValue(value, secret) {
746
+ const sig = createHmac("sha256", secret).update(value).digest("hex");
747
+ return `${value}.${sig}`;
748
+ }
749
+ function unsignValue(signed, secret) {
750
+ const dot = signed.lastIndexOf(".");
751
+ if (dot < 0) return null;
752
+ const value = signed.slice(0, dot);
753
+ const sig = signed.slice(dot + 1);
754
+ if (!/^[0-9a-f]{64}$/.test(sig)) return null;
755
+ const expected = createHmac("sha256", secret).update(value).digest("hex");
756
+ const a = Buffer.from(sig, "hex");
757
+ const b = Buffer.from(expected, "hex");
758
+ if (a.length !== b.length) return null;
759
+ return timingSafeEqual2(a, b) ? value : null;
760
+ }
761
+ function clearPlatformSessionCookie(opts) {
762
+ return `${SESSION_COOKIE_NAME}=; ${cookieAttrs(opts)}; Max-Age=0`;
763
+ }
764
+ function createPlatformSessionCookie(session, opts, secret) {
765
+ return createSessionCookie(session, opts, secret);
766
+ }
767
+ var LOGIN_STATE_COOKIE_NAME = "accounts_oidc_login";
768
+ var LOGIN_STATE_MAX_AGE_SEC = 60 * 10;
769
+ function createLoginStateCookie(login, options = {}) {
770
+ const payload = {
771
+ state: login.state,
772
+ nonce: login.nonce,
773
+ codeVerifier: login.codeVerifier,
774
+ nextPath: safeNextPath(options.nextPath),
775
+ tenantSlug: options.tenantSlug,
776
+ issuedAt: Math.floor(Date.now() / 1e3)
777
+ };
778
+ const raw = Buffer.from(JSON.stringify(payload)).toString("base64url");
779
+ const value = options.secret ? signValue(raw, options.secret) : raw;
780
+ return `${LOGIN_STATE_COOKIE_NAME}=${value}; ${cookieAttrs(options.cookieOptions)}; Max-Age=${LOGIN_STATE_MAX_AGE_SEC}`;
781
+ }
782
+ function readLoginStateCookie(cookieHeader, options = {}) {
783
+ const value = readCookie(cookieHeader, LOGIN_STATE_COOKIE_NAME);
784
+ if (!value) return null;
785
+ const raw = options.secret ? unsignValue(value, options.secret) : value;
786
+ if (raw == null) return null;
787
+ try {
788
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
789
+ if (!parsed || typeof parsed.state !== "string" || typeof parsed.nonce !== "string") return null;
790
+ if (typeof parsed.codeVerifier !== "string") return null;
791
+ if (typeof parsed.issuedAt !== "number" || Date.now() / 1e3 - parsed.issuedAt > LOGIN_STATE_MAX_AGE_SEC) return null;
792
+ return { ...parsed, nextPath: safeNextPath(parsed.nextPath) };
793
+ } catch {
794
+ return null;
795
+ }
796
+ }
797
+ function clearLoginStateCookie(opts) {
798
+ return `${LOGIN_STATE_COOKIE_NAME}=; ${cookieAttrs(opts)}; Max-Age=0`;
799
+ }
800
+ function createSessionCookie(session, opts, secret) {
801
+ const raw = encodeURIComponent(JSON.stringify(session));
802
+ const value = secret ? signValue(raw, secret) : raw;
803
+ return `${SESSION_COOKIE_NAME}=${value}; ${cookieAttrs(opts)}; Max-Age=${60 * 60 * 24 * 7}`;
804
+ }
805
+ function createStateCookie(value, opts) {
806
+ return `${STATE_COOKIE_NAME}=${encodeURIComponent(value)}; ${cookieAttrs(opts)}; Max-Age=${60 * 10}`;
807
+ }
808
+ function clearStateCookie(opts) {
809
+ return `${STATE_COOKIE_NAME}=; ${cookieAttrs(opts)}; Max-Age=0`;
810
+ }
811
+ function serializeAuthorizationState(state) {
812
+ return encodeURIComponent(JSON.stringify(state));
813
+ }
814
+ function parseAuthorizationState(value) {
815
+ try {
816
+ return JSON.parse(decodeURIComponent(value));
817
+ } catch {
818
+ throw new Error("Invalid authorization state payload.");
819
+ }
820
+ }
821
+ function normalizeIdentity(user) {
822
+ const firstName = user.firstName ?? user.first_name;
823
+ const lastName = user.lastName ?? user.last_name;
824
+ return {
825
+ id: user.id,
826
+ // Prefer the real address under either key; only synthesize when truly absent.
827
+ email: user.emailAddress ?? user.email ?? `${user.id}@accounts.local`,
828
+ name: user.name ?? ([firstName, lastName].filter(Boolean).join(" ") || "Accounts User"),
829
+ firstName,
830
+ lastName,
831
+ avatarUrl: user.avatarUrl ?? user.avatar_url
832
+ };
833
+ }
834
+ function createMockTokenResponse(code) {
835
+ const suffix = code.replace("demo_", "").slice(0, 8) || "demo";
836
+ return {
837
+ access_token: `access_${suffix}`,
838
+ refresh_token: `refresh_${suffix}`,
839
+ token_type: "Bearer",
840
+ expires_in: 60 * 60 * 24 * 7,
841
+ user: {
842
+ id: "accounts-user-demo-admin",
843
+ email: "hello@babyplanet.example",
844
+ name: "Baby Planet Admin",
845
+ first_name: "Baby",
846
+ last_name: "Planet"
847
+ }
848
+ };
849
+ }
850
+ function readCookie(cookieHeader, name) {
851
+ if (!cookieHeader) return null;
852
+ for (const part of cookieHeader.split(";")) {
853
+ const [key, ...rest] = part.trim().split("=");
854
+ if (key === name) {
855
+ return rest.join("=");
856
+ }
857
+ }
858
+ return null;
859
+ }
860
+ function serviceKeyFromRequest(request) {
861
+ const explicit = request.headers.get("x-api-key");
862
+ if (explicit && explicit.trim()) return explicit.trim();
863
+ const auth = request.headers.get("authorization");
864
+ if (auth && auth.startsWith("Bearer ")) {
865
+ const token = auth.slice(7).trim();
866
+ if (token.startsWith("sk_")) return token;
867
+ }
868
+ return null;
869
+ }
870
+ async function introspectServiceKey(input) {
871
+ const base = normalizeOptionalString(input.config.baseUrl);
872
+ if (!input.key || !base) return null;
873
+ const fetchImpl = input.fetchImpl ?? fetch;
874
+ try {
875
+ const res = await fetchImpl(new URL("/api/auth/service-keys/introspect", base), {
876
+ method: "POST",
877
+ headers: { "x-api-key": input.key, accept: "application/json" }
878
+ });
879
+ if (!res.ok) return null;
880
+ const data = await res.json();
881
+ if (!data.valid || !data.keyId) return null;
882
+ const accessibleApps = (data.accessibleApps ?? []).filter(
883
+ (a) => Boolean(a) && typeof a.id === "string" && typeof a.slug === "string"
884
+ ).map((a) => ({ id: a.id, slug: a.slug, name: a.name }));
885
+ return {
886
+ keyId: data.keyId,
887
+ tenantId: data.tenantId ?? null,
888
+ scopes: Array.isArray(data.scopes) ? data.scopes : [],
889
+ accessibleApps,
890
+ expiresAt: data.expiresAt ?? null
891
+ };
892
+ } catch {
893
+ return null;
894
+ }
895
+ }
896
+ async function logoutFromAccounts(input) {
897
+ const base = normalizeOptionalString(input.config.baseUrl);
898
+ if (!base || !input.accessToken) return { success: false };
899
+ const fetchImpl = input.fetchImpl ?? fetch;
900
+ try {
901
+ const res = await fetchImpl(new URL("/api/auth/logout", base), {
902
+ method: "POST",
903
+ headers: {
904
+ "content-type": "application/json",
905
+ authorization: `Bearer ${input.accessToken}`,
906
+ accept: "application/json"
907
+ },
908
+ body: JSON.stringify({ all: input.all ?? false })
909
+ });
910
+ if (!res.ok) return { success: false };
911
+ return await res.json();
912
+ } catch {
913
+ return { success: false };
914
+ }
915
+ }
916
+ function verifyWebhookSignature(rawBody, signature, secret) {
917
+ if (!signature || !secret) return false;
918
+ const provided = (signature.startsWith("sha256=") ? signature.slice(7) : signature).toLowerCase();
919
+ if (!/^[0-9a-f]{64}$/.test(provided)) return false;
920
+ const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
921
+ const a = Buffer.from(provided, "hex");
922
+ const b = Buffer.from(expected, "hex");
923
+ if (a.length !== b.length) return false;
924
+ return timingSafeEqual2(a, b);
925
+ }
926
+ function normalizeAccountsWebhook(payload) {
927
+ const body = payload && typeof payload === "object" ? payload : {};
928
+ const type = String(body.event ?? body.type ?? "unknown");
929
+ return { ...body, type, data: body.data };
930
+ }
931
+ function scopeSatisfied(granted, required) {
932
+ if (!granted || granted.length === 0) return false;
933
+ return granted.includes(required);
934
+ }
935
+
936
+ export {
937
+ OidcError,
938
+ isOidcError,
939
+ ACCOUNTS_ENDPOINT_PATHS,
940
+ DEFAULT_OIDC_SCOPES,
941
+ randomToken,
942
+ pkceChallenge,
943
+ createPkcePair,
944
+ looksLikeJwt,
945
+ createAccessTokenVerifier,
946
+ createOidcClient,
947
+ createAccountsClientConfig,
948
+ createOidcClientFromConfig,
949
+ identityFromClaims,
950
+ safeNextPath,
951
+ createPlatformAuth,
952
+ beginAccountsLoginRedirect,
953
+ handleAccountsCallback,
954
+ exchangeAuthorizationCode,
955
+ syncAccountsUser,
956
+ identityPassthroughSync,
957
+ resolveActiveTenant,
958
+ readPlatformSessionCookie,
959
+ clearPlatformSessionCookie,
960
+ createPlatformSessionCookie,
961
+ LOGIN_STATE_COOKIE_NAME,
962
+ createLoginStateCookie,
963
+ readLoginStateCookie,
964
+ clearLoginStateCookie,
965
+ serviceKeyFromRequest,
966
+ introspectServiceKey,
967
+ logoutFromAccounts,
968
+ verifyWebhookSignature,
969
+ normalizeAccountsWebhook,
970
+ scopeSatisfied
971
+ };