@broberg/sso 0.20.0 → 0.21.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.
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  var jose = require('jose');
4
+ var fs = require('fs');
5
+ var http = require('http');
4
6
 
5
7
  // src/config.ts
6
8
  var SsoConfigError = class extends Error {
@@ -16,6 +18,12 @@ function required(env, name, hint) {
16
18
  }
17
19
  return raw.trim();
18
20
  }
21
+ function parseClientAuth(raw) {
22
+ const v = raw?.trim();
23
+ if (!v) return void 0;
24
+ if (v === "secret" || v === "fly") return v;
25
+ throw new SsoConfigError(`SSO_CLIENT_AUTH must be "secret" or "fly" (got ${JSON.stringify(raw)}).`);
26
+ }
19
27
  var DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 7;
20
28
  function loadSsoConfig(env = process.env) {
21
29
  const rawIssuer = required(
@@ -70,7 +78,8 @@ function loadSsoConfig(env = process.env) {
70
78
  cookieName: env.SSO_COOKIE_NAME?.trim() || "bid_session",
71
79
  sessionMaxAge,
72
80
  postLogoutRedirectUri: env.SSO_POST_LOGOUT_REDIRECT_URI?.trim() || void 0,
73
- clientSecret: env.SSO_CLIENT_SECRET?.trim() || void 0
81
+ clientSecret: env.SSO_CLIENT_SECRET?.trim() || void 0,
82
+ clientAuth: parseClientAuth(env.SSO_CLIENT_AUTH)
74
83
  };
75
84
  }
76
85
 
@@ -286,6 +295,184 @@ function createJwksCache(options) {
286
295
  }
287
296
  };
288
297
  }
298
+ var DEFAULT_ISSUER = "https://id.broberg.ai";
299
+ var FLY_SOCKET = "/.fly/api";
300
+ var NoWorkloadIdentityError = class extends Error {
301
+ constructor(message) {
302
+ super(message);
303
+ this.name = "NoWorkloadIdentityError";
304
+ }
305
+ };
306
+ var TicketExchangeError = class extends Error {
307
+ constructor(message, code, status) {
308
+ super(message);
309
+ this.code = code;
310
+ this.status = status;
311
+ this.name = "TicketExchangeError";
312
+ }
313
+ code;
314
+ status;
315
+ };
316
+ var TicketUnavailableError = class extends Error {
317
+ constructor(message) {
318
+ super(message);
319
+ this.name = "TicketUnavailableError";
320
+ }
321
+ };
322
+ function flyOidcOverSocket(aud) {
323
+ return new Promise((resolve, reject) => {
324
+ const body = JSON.stringify({ aud });
325
+ const req = http.request(
326
+ { socketPath: FLY_SOCKET, path: "/v1/tokens/oidc", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } },
327
+ (res) => {
328
+ let data = "";
329
+ res.setEncoding("utf8");
330
+ res.on("data", (c) => data += c);
331
+ res.on("end", () => {
332
+ if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(data.trim());
333
+ else reject(new TicketUnavailableError(`Fly OIDC answered ${res.statusCode}`));
334
+ });
335
+ }
336
+ );
337
+ req.on("error", (e) => reject(new TicketUnavailableError(`Fly OIDC socket: ${e.message}`)));
338
+ req.end(body);
339
+ });
340
+ }
341
+ function jwtExp(token) {
342
+ try {
343
+ const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
344
+ return typeof payload.exp === "number" ? payload.exp : null;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+ async function readTicketResponse(res, issuer, target, now) {
350
+ let body = {};
351
+ try {
352
+ body = await res.json();
353
+ } catch {
354
+ }
355
+ if (res.status >= 500) throw new TicketUnavailableError(`${issuer} answered ${res.status}`);
356
+ if (!res.ok || !body.access_token) {
357
+ const desc = body.error_description ?? "";
358
+ const code = desc.includes(":") ? desc.slice(0, desc.indexOf(":")).trim() : body.error ?? "exchange_refused";
359
+ throw new TicketExchangeError(`BID refused the ticket for ${target}: ${desc || body.error || res.status}`, code, res.status);
360
+ }
361
+ const nowSec = Math.floor(now() / 1e3);
362
+ const exp = jwtExp(body.access_token) ?? nowSec + (body.expires_in ?? 300);
363
+ return { token: body.access_token, exp };
364
+ }
365
+ function createTicketClient(options = {}) {
366
+ const issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
367
+ const renewBeforeSec = options.renewBeforeSec ?? 30;
368
+ const env = options.env ?? process.env;
369
+ const fetchImpl = options.fetchImpl ?? fetch;
370
+ const now = options.now ?? (() => Date.now());
371
+ const flyOidc = options.flyOidc ?? flyOidcOverSocket;
372
+ const onFly = options.onFly ?? (() => !!env.FLY_APP_NAME && fs.existsSync(FLY_SOCKET));
373
+ const cache = /* @__PURE__ */ new Map();
374
+ const inFlight = /* @__PURE__ */ new Map();
375
+ let dpopKey = null;
376
+ const ownKey = () => dpopKey ??= createDpopKey();
377
+ async function platformToken() {
378
+ if (onFly()) return flyOidc(issuer);
379
+ const url = env.ACTIONS_ID_TOKEN_REQUEST_URL;
380
+ const bearer = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
381
+ if (url && bearer) {
382
+ let res;
383
+ try {
384
+ res = await fetchImpl(`${url}&audience=${encodeURIComponent(issuer)}`, { headers: { authorization: `Bearer ${bearer}` } });
385
+ } catch (e) {
386
+ throw new TicketUnavailableError(`GitHub OIDC endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
387
+ }
388
+ if (!res.ok) throw new TicketUnavailableError(`GitHub OIDC endpoint answered ${res.status}`);
389
+ const { value } = await res.json();
390
+ if (!value) throw new TicketUnavailableError("GitHub OIDC endpoint returned no token");
391
+ return value;
392
+ }
393
+ throw new NoWorkloadIdentityError(
394
+ "No workload identity to exchange for a ticket: not on a Fly machine (FLY_APP_NAME + /.fly/api) and not in a GitHub Actions job (ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN \u2014 add `permissions: id-token: write`). A Mac session keeps using its session key."
395
+ );
396
+ }
397
+ async function exchange(audience, scope) {
398
+ const subject = await platformToken();
399
+ const form = new URLSearchParams({
400
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
401
+ subject_token: subject,
402
+ subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
403
+ audience
404
+ });
405
+ if (scope) form.set("scope", scope);
406
+ let res;
407
+ try {
408
+ const tokenUrl = `${issuer}/oauth2/token`;
409
+ const headers = { "content-type": "application/x-www-form-urlencoded" };
410
+ if (options.dpop) headers.dpop = await createDpopProof(await ownKey(), { method: "POST", url: tokenUrl, now });
411
+ res = await fetchImpl(tokenUrl, { method: "POST", headers, body: form });
412
+ } catch (e) {
413
+ throw new TicketUnavailableError(`${issuer} unreachable: ${e instanceof Error ? e.message : String(e)}`);
414
+ }
415
+ return readTicketResponse(res, issuer, audience, now);
416
+ }
417
+ const client = {
418
+ async authHeaders({ audience, scope, method, url }) {
419
+ const ticket = await client.get({ audience, scope });
420
+ if (!options.dpop) return { authorization: `Bearer ${ticket}` };
421
+ return { authorization: `DPoP ${ticket}`, dpop: await createDpopProof(await ownKey(), { method, url, ticket, now }) };
422
+ },
423
+ async get({ audience, scope }) {
424
+ const key = `${audience} ${scope ?? ""}`;
425
+ const hit = cache.get(key);
426
+ if (hit && hit.exp - renewBeforeSec > now() / 1e3) return hit.token;
427
+ const pending = inFlight.get(key);
428
+ if (pending) return pending;
429
+ const p = exchange(audience, scope).then((t) => {
430
+ cache.set(key, t);
431
+ return t.token;
432
+ }).finally(() => inFlight.delete(key));
433
+ inFlight.set(key, p);
434
+ return p;
435
+ }
436
+ };
437
+ return client;
438
+ }
439
+ var shared = null;
440
+ function fetchTicket(opts) {
441
+ shared ??= createTicketClient();
442
+ return shared.get(opts);
443
+ }
444
+ async function exchangeUserToken(opts) {
445
+ const issuer = (opts.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
446
+ const target = opts.resource ?? opts.audience;
447
+ if (!target) throw new TicketExchangeError("exchangeUserToken needs a receiver: resource (its URL) or audience (BID's name for it)", "malformed_request", 0);
448
+ const fetchImpl = opts.fetchImpl ?? fetch;
449
+ const now = opts.now ?? (() => Date.now());
450
+ const form = new URLSearchParams({
451
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
452
+ subject_token: opts.subjectToken,
453
+ subject_token_type: "urn:ietf:params:oauth:token-type:access_token"
454
+ });
455
+ if (opts.resource) form.set("resource", opts.resource);
456
+ if (opts.audience) form.set("audience", opts.audience);
457
+ if (opts.scope) form.set("scope", opts.scope);
458
+ const tokenUrl = `${issuer}/oauth2/token`;
459
+ const headers = { "content-type": "application/x-www-form-urlencoded" };
460
+ if (opts.clientSecret) {
461
+ headers.authorization = `Basic ${btoa(`${encodeURIComponent(opts.clientId)}:${encodeURIComponent(opts.clientSecret)}`)}`;
462
+ } else {
463
+ form.set("client_id", opts.clientId);
464
+ }
465
+ if (opts.dpopKey) headers.dpop = await createDpopProof(opts.dpopKey, { method: "POST", url: tokenUrl, now });
466
+ let res;
467
+ try {
468
+ res = await fetchImpl(tokenUrl, { method: "POST", headers, body: form });
469
+ } catch (e) {
470
+ throw new TicketUnavailableError(`${issuer} unreachable: ${e instanceof Error ? e.message : String(e)}`);
471
+ }
472
+ return readTicketResponse(res, issuer, target, now);
473
+ }
474
+
475
+ // src/client.ts
289
476
  var SsoError = class extends Error {
290
477
  constructor(message) {
291
478
  super(message);
@@ -370,6 +557,7 @@ var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
370
557
  var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
371
558
  var BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
372
559
  var MAX_INVITATIONS = 500;
560
+ var FLY_CLIENT_ASSERTION_TYPE = "urn:broberg:params:oauth:client-assertion-type:fly-oidc";
373
561
  function excerptForLog(raw, clientSecret) {
374
562
  let text = raw.replace(/\s+/g, " ").trim();
375
563
  if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
@@ -405,6 +593,18 @@ var ALLOWED_ALGS = [
405
593
  function createSsoClient(config, options = {}) {
406
594
  const fetchImpl = options.fetchImpl ?? fetch;
407
595
  const timeoutMs = options.timeoutMs ?? 1e4;
596
+ const flyOidc = options.flyOidc ?? flyOidcOverSocket;
597
+ async function tokenEndpointAuth(tokenEndpoint) {
598
+ if (config.clientAuth !== "fly") return config.clientSecret ? { client_secret: config.clientSecret } : {};
599
+ let assertion;
600
+ try {
601
+ assertion = await flyOidc(tokenEndpoint);
602
+ } catch (e) {
603
+ throw new SsoError(`fly client assertion unavailable: could not get a Fly OIDC token for ${tokenEndpoint} \u2014 ${e instanceof Error ? e.message : String(e)}`);
604
+ }
605
+ if (!assertion) throw new SsoError(`fly client assertion unavailable: Fly returned an empty token for ${tokenEndpoint}`);
606
+ return { client_assertion_type: FLY_CLIENT_ASSERTION_TYPE, client_assertion: assertion };
607
+ }
408
608
  async function appPost(path, bearer, payload, what) {
409
609
  const url = new URL(path, config.issuer).toString();
410
610
  const ctrl = new AbortController();
@@ -688,7 +888,7 @@ function createSsoClient(config, options = {}) {
688
888
  // ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two
689
889
  // prove different things, so a confidential client keeps PKCE.
690
890
  code_verifier: codeVerifier,
691
- ...config.clientSecret ? { client_secret: config.clientSecret } : {}
891
+ ...await tokenEndpointAuth(token_endpoint)
692
892
  })
693
893
  });
694
894
  const raw = await res.text();
@@ -782,6 +982,11 @@ function createSsoClient(config, options = {}) {
782
982
  code_challenge: await challengeFor(codeVerifier),
783
983
  code_challenge_method: "S256",
784
984
  nonce,
985
+ // F084.183 — the redirect the code is BOUND to, the same value /oauth2/token sends.
986
+ // Without it BID bound the code to the client's FIRST registered redirect, and an
987
+ // app served from another of its registered hosts (Jam on jams.party) got
988
+ // «invalid_grant — redirect_uri mismatch» at the exchange.
989
+ redirect_uri: config.redirectUri,
785
990
  ...input.intent === "signup" ? { intent: "signup", name: input.name, terms_version: input.termsVersion } : {},
786
991
  ...input.endUserIp ? { end_user_ip: input.endUserIp } : {}
787
992
  });
@@ -868,7 +1073,7 @@ function createSsoClient(config, options = {}) {
868
1073
  grant_type: "refresh_token",
869
1074
  refresh_token: refreshToken,
870
1075
  client_id: config.clientId,
871
- ...config.clientSecret ? { client_secret: config.clientSecret } : {}
1076
+ ...await tokenEndpointAuth(token_endpoint)
872
1077
  })
873
1078
  });
874
1079
  const raw = await res.text();
@@ -974,6 +1179,84 @@ function createSsoClient(config, options = {}) {
974
1179
  };
975
1180
  }
976
1181
 
1182
+ // src/dpop.ts
1183
+ async function createDpopKey() {
1184
+ const { privateKey, publicKey } = await jose.generateKeyPair("ES256", { extractable: false });
1185
+ const jwk = await jose.exportJWK(publicKey);
1186
+ const publicJwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
1187
+ return { privateKey, publicJwk, jkt: await jose.calculateJwkThumbprint(publicJwk) };
1188
+ }
1189
+ var enc2 = new TextEncoder();
1190
+ var b64url3 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1191
+ async function accessTokenHash(ticket) {
1192
+ return b64url3(await crypto.subtle.digest("SHA-256", enc2.encode(ticket)));
1193
+ }
1194
+ function normalizeHtu(url) {
1195
+ const u = new URL(url);
1196
+ return `${u.protocol}//${u.host}${u.pathname}`;
1197
+ }
1198
+ async function createDpopProof(key, opts) {
1199
+ const payload = {
1200
+ jti: crypto.randomUUID(),
1201
+ htm: opts.method.toUpperCase(),
1202
+ htu: normalizeHtu(opts.url),
1203
+ iat: Math.floor((opts.now?.() ?? Date.now()) / 1e3)
1204
+ };
1205
+ if (opts.ticket !== void 0) payload.ath = await accessTokenHash(opts.ticket);
1206
+ return new jose.SignJWT(payload).setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: key.publicJwk }).sign(key.privateKey);
1207
+ }
1208
+ function createMemoryReplayStore(now = () => Date.now()) {
1209
+ const entries = /* @__PURE__ */ new Map();
1210
+ return {
1211
+ async seen(key, ttlMs) {
1212
+ const t = now();
1213
+ for (const [k, until] of entries) if (until <= t) entries.delete(k);
1214
+ if (entries.has(key)) return true;
1215
+ entries.set(key, t + ttlMs);
1216
+ return false;
1217
+ }
1218
+ };
1219
+ }
1220
+ var ASYMMETRIC = /* @__PURE__ */ new Set(["ES256", "ES384", "ES512", "EdDSA", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512"]);
1221
+ async function verifyDpopProof(proof, o) {
1222
+ let header;
1223
+ try {
1224
+ header = jose.decodeProtectedHeader(proof);
1225
+ } catch {
1226
+ throw new SsoError("DPoP proof is not a JWS");
1227
+ }
1228
+ if (header.typ !== "dpop+jwt") throw new SsoError(`DPoP proof has typ ${JSON.stringify(header.typ)}, not "dpop+jwt"`);
1229
+ if (!header.alg || !ASYMMETRIC.has(header.alg)) throw new SsoError(`DPoP proof alg ${JSON.stringify(header.alg)} is not an accepted asymmetric algorithm`);
1230
+ const jwk = header.jwk;
1231
+ if (!jwk || typeof jwk !== "object") throw new SsoError("DPoP proof carries no jwk");
1232
+ if ("d" in jwk) throw new SsoError("DPoP proof jwk contains a private key");
1233
+ let payload;
1234
+ try {
1235
+ const key = await jose.importJWK(jwk, header.alg);
1236
+ ({ payload } = await jose.jwtVerify(proof, key, { typ: "dpop+jwt", algorithms: [header.alg] }));
1237
+ } catch (e) {
1238
+ throw new SsoError(`DPoP proof signature rejected: ${e instanceof Error ? e.message : String(e)}`);
1239
+ }
1240
+ if (await jose.calculateJwkThumbprint(jwk) !== o.jkt) throw new SsoError("DPoP proof key does not match the ticket's cnf.jkt");
1241
+ if (payload.htm !== o.method.toUpperCase()) throw new SsoError(`DPoP proof is for ${JSON.stringify(payload.htm)}, the request is ${o.method.toUpperCase()}`);
1242
+ let htu;
1243
+ try {
1244
+ htu = normalizeHtu(String(payload.htu));
1245
+ } catch {
1246
+ throw new SsoError("DPoP proof htu is not a URL");
1247
+ }
1248
+ if (htu !== normalizeHtu(o.url)) throw new SsoError(`DPoP proof is for ${htu}, the request is ${normalizeHtu(o.url)}`);
1249
+ const nowSec = Math.floor(o.now() / 1e3);
1250
+ if (typeof payload.iat !== "number" || Math.abs(nowSec - payload.iat) > o.iatWindowSec) {
1251
+ throw new SsoError(`DPoP proof iat is outside \xB1${o.iatWindowSec}s`);
1252
+ }
1253
+ if (payload.ath !== await accessTokenHash(o.ticket)) throw new SsoError("DPoP proof ath does not match this ticket");
1254
+ if (typeof payload.jti !== "string" || payload.jti === "") throw new SsoError("DPoP proof has no jti");
1255
+ if (await o.replay.seen(`${o.jkt}:${payload.jti}`, o.iatWindowSec * 2 * 1e3)) {
1256
+ throw new SsoError("DPoP proof was already used (jti replay)");
1257
+ }
1258
+ }
1259
+
977
1260
  // src/tokens.ts
978
1261
  var warned = false;
979
1262
  function memoryTokenStore(options = {}) {
@@ -1023,13 +1306,16 @@ function newSid() {
1023
1306
 
1024
1307
  exports.ALLOWED_ALGS = ALLOWED_ALGS;
1025
1308
  exports.AVATAR_TYPES = AVATAR_TYPES;
1309
+ exports.DEFAULT_ISSUER = DEFAULT_ISSUER;
1026
1310
  exports.DEFAULT_SESSION_MAX_AGE = DEFAULT_SESSION_MAX_AGE;
1027
1311
  exports.EMBEDDED_FLOW_MAX_AGE = EMBEDDED_FLOW_MAX_AGE;
1312
+ exports.FLY_CLIENT_ASSERTION_TYPE = FLY_CLIENT_ASSERTION_TYPE;
1028
1313
  exports.JwksError = JwksError;
1029
1314
  exports.JwksUnavailableError = JwksUnavailableError;
1030
1315
  exports.JwksUnknownKeyError = JwksUnknownKeyError;
1031
1316
  exports.MAX_AVATAR_BYTES = MAX_AVATAR_BYTES;
1032
1317
  exports.MAX_INVITATIONS = MAX_INVITATIONS;
1318
+ exports.NoWorkloadIdentityError = NoWorkloadIdentityError;
1033
1319
  exports.SsoAppApiError = SsoAppApiError;
1034
1320
  exports.SsoAvatarRejectedError = SsoAvatarRejectedError;
1035
1321
  exports.SsoConfigError = SsoConfigError;
@@ -1038,20 +1324,31 @@ exports.SsoInsufficientScopeError = SsoInsufficientScopeError;
1038
1324
  exports.SsoIntrospectionUnavailableError = SsoIntrospectionUnavailableError;
1039
1325
  exports.SsoReauthError = SsoReauthError;
1040
1326
  exports.SsoTokenInactiveError = SsoTokenInactiveError;
1327
+ exports.TicketExchangeError = TicketExchangeError;
1328
+ exports.TicketUnavailableError = TicketUnavailableError;
1329
+ exports.accessTokenHash = accessTokenHash;
1041
1330
  exports.cookieHeader = cookieHeader;
1331
+ exports.createDpopKey = createDpopKey;
1332
+ exports.createDpopProof = createDpopProof;
1042
1333
  exports.createJwksCache = createJwksCache;
1334
+ exports.createMemoryReplayStore = createMemoryReplayStore;
1043
1335
  exports.createSsoClient = createSsoClient;
1336
+ exports.createTicketClient = createTicketClient;
1337
+ exports.exchangeUserToken = exchangeUserToken;
1044
1338
  exports.expiresAtFrom = expiresAtFrom;
1339
+ exports.fetchTicket = fetchTicket;
1045
1340
  exports.loadSsoConfig = loadSsoConfig;
1046
1341
  exports.mediaType = mediaType;
1047
1342
  exports.memoryTokenStore = memoryTokenStore;
1048
1343
  exports.newSid = newSid;
1344
+ exports.normalizeHtu = normalizeHtu;
1049
1345
  exports.openEmbeddedFlow = openEmbeddedFlow;
1050
1346
  exports.readCookie = readCookie;
1051
1347
  exports.sealEmbeddedFlow = sealEmbeddedFlow;
1052
1348
  exports.signSession = signSession;
1053
1349
  exports.signValue = signValue;
1350
+ exports.verifyDpopProof = verifyDpopProof;
1054
1351
  exports.verifySession = verifySession;
1055
1352
  exports.verifyValue = verifyValue;
1056
- //# sourceMappingURL=chunk-KEBSZBHL.cjs.map
1057
- //# sourceMappingURL=chunk-KEBSZBHL.cjs.map
1353
+ //# sourceMappingURL=chunk-XQEHSHLR.cjs.map
1354
+ //# sourceMappingURL=chunk-XQEHSHLR.cjs.map