@broberg/sso 0.19.1 → 0.21.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.
- package/README.md +54 -0
- package/dist/{chunk-2F3RPYJV.cjs → chunk-2XMKZWC6.cjs} +314 -7
- package/dist/chunk-2XMKZWC6.cjs.map +1 -0
- package/dist/{chunk-CCDRB6NJ.js → chunk-UDUPP35Z.js} +302 -9
- package/dist/chunk-UDUPP35Z.js.map +1 -0
- package/dist/hono.cjs +38 -37
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +7 -1
- package/dist/hono.d.ts +7 -1
- package/dist/hono.js +3 -2
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +112 -291
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +95 -60
- package/dist/index.d.ts +95 -60
- package/dist/index.js +4 -227
- package/dist/index.js.map +1 -1
- package/dist/{session-qvhMg3ID.d.cts → session-DnotfVNd.d.cts} +26 -1
- package/dist/{session-qvhMg3ID.d.ts → session-DnotfVNd.d.ts} +26 -1
- package/package.json +1 -1
- package/dist/chunk-2F3RPYJV.cjs.map +0 -1
- package/dist/chunk-CCDRB6NJ.js.map +0 -1
package/README.md
CHANGED
|
@@ -264,6 +264,12 @@ const terms = await client.embedded.terms("da");
|
|
|
264
264
|
(re-fetch `terms()`), `invalid_request`, `unavailable`.
|
|
265
265
|
- A 401 from BID is YOUR configuration (wrong client id/secret) and THROWS `SsoError` — it is never shown to the
|
|
266
266
|
user as a wrong code.
|
|
267
|
+
- **Passkey offer (0.20.0, BID F084.178).** When the user has no passkey, a `done` step also carries
|
|
268
|
+
`passkeySetup: { setupId, frameUrl, expiresIn }`. Show `frameUrl` in an `<iframe allow="publickey-credentials-create">`.
|
|
269
|
+
`frameUrl` is always on the issuer's own origin — an offer pointing anywhere else is dropped, not passed on.
|
|
270
|
+
No offer → the step is exactly `{ kind: "done", result }` as before.
|
|
271
|
+
- **The app's name on the terms (0.20.0, BID F084.177).** `terms(lang)` sends your `client_id`, so BID's consent text
|
|
272
|
+
can say «hos <your app>».
|
|
267
273
|
|
|
268
274
|
## What it will never do
|
|
269
275
|
|
|
@@ -665,3 +671,51 @@ await fetch("https://discovery.broberg.ai/api/fleet", { headers: { authorization
|
|
|
665
671
|
- **Cached** per audience + scope until 30 s before exp (BID issues 5 min), and concurrent calls share one exchange. `createTicketClient({...})` gives you your own cache and injectable platform/fetch for tests.
|
|
666
672
|
- **Errors:** `TicketExchangeError` (BID refused — `code` is BID's name for it, `status` the HTTP status; permanent, fix the rule) · `TicketUnavailableError` (BID or the platform unreachable / 5xx; transient) · `NoWorkloadIdentityError`. BID's codes include `no_rule_for_audience`, `scope_not_allowed`, `service_not_registered`, `audience_unknown`, `workflow_not_allowed`, `fly_org_untrusted`, `fly_audience_mismatch`, `github_owner_untrusted`, `client_mismatch`, `issuer_unknown`.
|
|
667
673
|
- The ticket is never logged or put in an error message.
|
|
674
|
+
|
|
675
|
+
## A ticket on behalf of a USER — `exchangeUserToken` (0.20.0, F084.179, BID F087.37)
|
|
676
|
+
|
|
677
|
+
A signed-in user's BID access token, exchanged for a ticket to ONE receiver. Same ticket format as the
|
|
678
|
+
machines' (`ver` 1), with `principal_type: "user"`, `sub` = the user and `client_id` = your app — so the
|
|
679
|
+
receiver checks it with `createTicketVerifier`, the same as a machine ticket.
|
|
680
|
+
|
|
681
|
+
```ts
|
|
682
|
+
import { exchangeUserToken } from "@broberg/sso";
|
|
683
|
+
|
|
684
|
+
const { token, exp } = await exchangeUserToken({
|
|
685
|
+
clientId: "jam",
|
|
686
|
+
clientSecret: process.env.SSO_CLIENT_SECRET, // omit for a public app (a browser extension): client_id goes in the body
|
|
687
|
+
subjectToken: userAccessToken, // the user's BID access token — never logged, never in an error
|
|
688
|
+
resource: "https://cardmem.com", // or audience: "<BID's name for the receiver>" — one is required
|
|
689
|
+
scope: "cardmem:mockups:read",
|
|
690
|
+
// dpopKey: await createDpopKey(), // optional: bind the ticket to a key
|
|
691
|
+
});
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
- **Not cached** — it is per user. When it expires, exchange again with the user's (renewed) access token.
|
|
695
|
+
- **Errors** are the same classes as `fetchTicket`. BID's codes for this path include `subject_token_inactive`
|
|
696
|
+
(the user's BID login has ended — «log ud» means log out), `client_mismatch` (the token belongs to another app),
|
|
697
|
+
`subject_is_ticket`, `session_ended`, `no_rule_for_audience`, `scope_not_allowed`, `client_unauthenticated`.
|
|
698
|
+
- BID issues nothing without a rule for (your app, the receiver). Ask broberg-id for the rule.
|
|
699
|
+
|
|
700
|
+
## Login without a client secret — `clientAuth: "fly"` (0.21.0, F084.181, BID F084.180)
|
|
701
|
+
|
|
702
|
+
An app on Fly can prove itself at BID's token endpoint with its Fly identity instead of a
|
|
703
|
+
`client_secret`. Nothing to store, nothing to rotate, nothing to leak.
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
const sso = ssoRoutes({ clientAuth: "fly", tokenStore }); // or SSO_CLIENT_AUTH=fly
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
- At the code exchange (and at refresh) the app asks Fly's machine API for an OIDC token with
|
|
710
|
+
`aud` = BID's **token endpoint** (not the issuer, so a ticket token cannot be replayed as a login
|
|
711
|
+
proof) and sends it as `client_assertion` with
|
|
712
|
+
`client_assertion_type=urn:broberg:params:oauth:client-assertion-type:fly-oidc`. PKCE is unchanged.
|
|
713
|
+
- **No fallback.** With `"fly"`, `client_secret` is never sent to the token endpoint, even if one is
|
|
714
|
+
configured. If Fly cannot give a token, the login fails: the browser gets one generic 500, the log
|
|
715
|
+
gets `fly client assertion unavailable: …`.
|
|
716
|
+
- **BID must be switched first**: the client registered as `token_endpoint_auth_method: fly_oidc`,
|
|
717
|
+
bound to your Fly org and app. Ask broberg-id. Switch the app, measure one login, then BID deletes
|
|
718
|
+
the secret.
|
|
719
|
+
- `clientSecret` is still what introspection and the embedded login use — those are not the token
|
|
720
|
+
endpoint's code exchange.
|
|
721
|
+
- Without the option nothing changes: the 0.20.0 request, byte for byte.
|
|
@@ -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
|
-
...
|
|
891
|
+
...await tokenEndpointAuth(token_endpoint)
|
|
692
892
|
})
|
|
693
893
|
});
|
|
694
894
|
const raw = await res.text();
|
|
@@ -747,10 +947,25 @@ function createSsoClient(config, options = {}) {
|
|
|
747
947
|
return res.ok ? { ok: true, body: json } : { ok: false, status: res.status, body: json };
|
|
748
948
|
}
|
|
749
949
|
const toError = (r) => r.status === 0 ? { kind: "unavailable", message: `BID did not answer: ${String(r.body.message ?? "")}` } : embeddedError(r.status, r.body);
|
|
950
|
+
function readPasskeySetup(v) {
|
|
951
|
+
if (typeof v !== "object" || v === null) return void 0;
|
|
952
|
+
const o = v;
|
|
953
|
+
if (typeof o.setup_id !== "string" || !o.setup_id || typeof o.frame_url !== "string") return void 0;
|
|
954
|
+
let frame;
|
|
955
|
+
try {
|
|
956
|
+
frame = new URL(o.frame_url, config.issuer);
|
|
957
|
+
} catch {
|
|
958
|
+
return void 0;
|
|
959
|
+
}
|
|
960
|
+
if (frame.origin !== new URL(config.issuer).origin) return void 0;
|
|
961
|
+
return { setupId: o.setup_id, frameUrl: frame.toString(), expiresIn: typeof o.expires_in === "number" ? o.expires_in : 600 };
|
|
962
|
+
}
|
|
750
963
|
async function embeddedNext(r, flow) {
|
|
751
964
|
if (!r.ok) return { kind: "error", error: toError(r) };
|
|
752
965
|
if (r.body.next === "done" && typeof r.body.code === "string") {
|
|
753
|
-
|
|
966
|
+
const result = await exchangeCode(r.body.code, flow.codeVerifier, flow.nonce);
|
|
967
|
+
const passkeySetup = readPasskeySetup(r.body.passkey_setup);
|
|
968
|
+
return passkeySetup ? { kind: "done", result, passkeySetup } : { kind: "done", result };
|
|
754
969
|
}
|
|
755
970
|
if (r.body.next === "second_factor") {
|
|
756
971
|
const methods = (Array.isArray(r.body.methods) ? r.body.methods : []).filter((m) => m === "totp" || m === "sms");
|
|
@@ -789,7 +1004,7 @@ function createSsoClient(config, options = {}) {
|
|
|
789
1004
|
return embeddedNext(await embeddedCall("POST", "/second-factor", { flow_id: flow.flowId, method, code }), flow);
|
|
790
1005
|
},
|
|
791
1006
|
async terms(lang) {
|
|
792
|
-
const r = await embeddedCall("GET", `/terms
|
|
1007
|
+
const r = await embeddedCall("GET", `/terms?${new URLSearchParams({ lang, client_id: config.clientId })}`);
|
|
793
1008
|
const b = r.body;
|
|
794
1009
|
if (!r.ok || typeof b.version !== "string" || typeof b.text !== "string") {
|
|
795
1010
|
throw new SsoError(`BID did not answer the signup terms (${r.ok ? "no version/text" : r.status})`);
|
|
@@ -853,7 +1068,7 @@ function createSsoClient(config, options = {}) {
|
|
|
853
1068
|
grant_type: "refresh_token",
|
|
854
1069
|
refresh_token: refreshToken,
|
|
855
1070
|
client_id: config.clientId,
|
|
856
|
-
...
|
|
1071
|
+
...await tokenEndpointAuth(token_endpoint)
|
|
857
1072
|
})
|
|
858
1073
|
});
|
|
859
1074
|
const raw = await res.text();
|
|
@@ -959,6 +1174,84 @@ function createSsoClient(config, options = {}) {
|
|
|
959
1174
|
};
|
|
960
1175
|
}
|
|
961
1176
|
|
|
1177
|
+
// src/dpop.ts
|
|
1178
|
+
async function createDpopKey() {
|
|
1179
|
+
const { privateKey, publicKey } = await jose.generateKeyPair("ES256", { extractable: false });
|
|
1180
|
+
const jwk = await jose.exportJWK(publicKey);
|
|
1181
|
+
const publicJwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
|
1182
|
+
return { privateKey, publicJwk, jkt: await jose.calculateJwkThumbprint(publicJwk) };
|
|
1183
|
+
}
|
|
1184
|
+
var enc2 = new TextEncoder();
|
|
1185
|
+
var b64url3 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1186
|
+
async function accessTokenHash(ticket) {
|
|
1187
|
+
return b64url3(await crypto.subtle.digest("SHA-256", enc2.encode(ticket)));
|
|
1188
|
+
}
|
|
1189
|
+
function normalizeHtu(url) {
|
|
1190
|
+
const u = new URL(url);
|
|
1191
|
+
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
1192
|
+
}
|
|
1193
|
+
async function createDpopProof(key, opts) {
|
|
1194
|
+
const payload = {
|
|
1195
|
+
jti: crypto.randomUUID(),
|
|
1196
|
+
htm: opts.method.toUpperCase(),
|
|
1197
|
+
htu: normalizeHtu(opts.url),
|
|
1198
|
+
iat: Math.floor((opts.now?.() ?? Date.now()) / 1e3)
|
|
1199
|
+
};
|
|
1200
|
+
if (opts.ticket !== void 0) payload.ath = await accessTokenHash(opts.ticket);
|
|
1201
|
+
return new jose.SignJWT(payload).setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: key.publicJwk }).sign(key.privateKey);
|
|
1202
|
+
}
|
|
1203
|
+
function createMemoryReplayStore(now = () => Date.now()) {
|
|
1204
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1205
|
+
return {
|
|
1206
|
+
async seen(key, ttlMs) {
|
|
1207
|
+
const t = now();
|
|
1208
|
+
for (const [k, until] of entries) if (until <= t) entries.delete(k);
|
|
1209
|
+
if (entries.has(key)) return true;
|
|
1210
|
+
entries.set(key, t + ttlMs);
|
|
1211
|
+
return false;
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
var ASYMMETRIC = /* @__PURE__ */ new Set(["ES256", "ES384", "ES512", "EdDSA", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512"]);
|
|
1216
|
+
async function verifyDpopProof(proof, o) {
|
|
1217
|
+
let header;
|
|
1218
|
+
try {
|
|
1219
|
+
header = jose.decodeProtectedHeader(proof);
|
|
1220
|
+
} catch {
|
|
1221
|
+
throw new SsoError("DPoP proof is not a JWS");
|
|
1222
|
+
}
|
|
1223
|
+
if (header.typ !== "dpop+jwt") throw new SsoError(`DPoP proof has typ ${JSON.stringify(header.typ)}, not "dpop+jwt"`);
|
|
1224
|
+
if (!header.alg || !ASYMMETRIC.has(header.alg)) throw new SsoError(`DPoP proof alg ${JSON.stringify(header.alg)} is not an accepted asymmetric algorithm`);
|
|
1225
|
+
const jwk = header.jwk;
|
|
1226
|
+
if (!jwk || typeof jwk !== "object") throw new SsoError("DPoP proof carries no jwk");
|
|
1227
|
+
if ("d" in jwk) throw new SsoError("DPoP proof jwk contains a private key");
|
|
1228
|
+
let payload;
|
|
1229
|
+
try {
|
|
1230
|
+
const key = await jose.importJWK(jwk, header.alg);
|
|
1231
|
+
({ payload } = await jose.jwtVerify(proof, key, { typ: "dpop+jwt", algorithms: [header.alg] }));
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
throw new SsoError(`DPoP proof signature rejected: ${e instanceof Error ? e.message : String(e)}`);
|
|
1234
|
+
}
|
|
1235
|
+
if (await jose.calculateJwkThumbprint(jwk) !== o.jkt) throw new SsoError("DPoP proof key does not match the ticket's cnf.jkt");
|
|
1236
|
+
if (payload.htm !== o.method.toUpperCase()) throw new SsoError(`DPoP proof is for ${JSON.stringify(payload.htm)}, the request is ${o.method.toUpperCase()}`);
|
|
1237
|
+
let htu;
|
|
1238
|
+
try {
|
|
1239
|
+
htu = normalizeHtu(String(payload.htu));
|
|
1240
|
+
} catch {
|
|
1241
|
+
throw new SsoError("DPoP proof htu is not a URL");
|
|
1242
|
+
}
|
|
1243
|
+
if (htu !== normalizeHtu(o.url)) throw new SsoError(`DPoP proof is for ${htu}, the request is ${normalizeHtu(o.url)}`);
|
|
1244
|
+
const nowSec = Math.floor(o.now() / 1e3);
|
|
1245
|
+
if (typeof payload.iat !== "number" || Math.abs(nowSec - payload.iat) > o.iatWindowSec) {
|
|
1246
|
+
throw new SsoError(`DPoP proof iat is outside \xB1${o.iatWindowSec}s`);
|
|
1247
|
+
}
|
|
1248
|
+
if (payload.ath !== await accessTokenHash(o.ticket)) throw new SsoError("DPoP proof ath does not match this ticket");
|
|
1249
|
+
if (typeof payload.jti !== "string" || payload.jti === "") throw new SsoError("DPoP proof has no jti");
|
|
1250
|
+
if (await o.replay.seen(`${o.jkt}:${payload.jti}`, o.iatWindowSec * 2 * 1e3)) {
|
|
1251
|
+
throw new SsoError("DPoP proof was already used (jti replay)");
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
962
1255
|
// src/tokens.ts
|
|
963
1256
|
var warned = false;
|
|
964
1257
|
function memoryTokenStore(options = {}) {
|
|
@@ -1008,13 +1301,16 @@ function newSid() {
|
|
|
1008
1301
|
|
|
1009
1302
|
exports.ALLOWED_ALGS = ALLOWED_ALGS;
|
|
1010
1303
|
exports.AVATAR_TYPES = AVATAR_TYPES;
|
|
1304
|
+
exports.DEFAULT_ISSUER = DEFAULT_ISSUER;
|
|
1011
1305
|
exports.DEFAULT_SESSION_MAX_AGE = DEFAULT_SESSION_MAX_AGE;
|
|
1012
1306
|
exports.EMBEDDED_FLOW_MAX_AGE = EMBEDDED_FLOW_MAX_AGE;
|
|
1307
|
+
exports.FLY_CLIENT_ASSERTION_TYPE = FLY_CLIENT_ASSERTION_TYPE;
|
|
1013
1308
|
exports.JwksError = JwksError;
|
|
1014
1309
|
exports.JwksUnavailableError = JwksUnavailableError;
|
|
1015
1310
|
exports.JwksUnknownKeyError = JwksUnknownKeyError;
|
|
1016
1311
|
exports.MAX_AVATAR_BYTES = MAX_AVATAR_BYTES;
|
|
1017
1312
|
exports.MAX_INVITATIONS = MAX_INVITATIONS;
|
|
1313
|
+
exports.NoWorkloadIdentityError = NoWorkloadIdentityError;
|
|
1018
1314
|
exports.SsoAppApiError = SsoAppApiError;
|
|
1019
1315
|
exports.SsoAvatarRejectedError = SsoAvatarRejectedError;
|
|
1020
1316
|
exports.SsoConfigError = SsoConfigError;
|
|
@@ -1023,20 +1319,31 @@ exports.SsoInsufficientScopeError = SsoInsufficientScopeError;
|
|
|
1023
1319
|
exports.SsoIntrospectionUnavailableError = SsoIntrospectionUnavailableError;
|
|
1024
1320
|
exports.SsoReauthError = SsoReauthError;
|
|
1025
1321
|
exports.SsoTokenInactiveError = SsoTokenInactiveError;
|
|
1322
|
+
exports.TicketExchangeError = TicketExchangeError;
|
|
1323
|
+
exports.TicketUnavailableError = TicketUnavailableError;
|
|
1324
|
+
exports.accessTokenHash = accessTokenHash;
|
|
1026
1325
|
exports.cookieHeader = cookieHeader;
|
|
1326
|
+
exports.createDpopKey = createDpopKey;
|
|
1327
|
+
exports.createDpopProof = createDpopProof;
|
|
1027
1328
|
exports.createJwksCache = createJwksCache;
|
|
1329
|
+
exports.createMemoryReplayStore = createMemoryReplayStore;
|
|
1028
1330
|
exports.createSsoClient = createSsoClient;
|
|
1331
|
+
exports.createTicketClient = createTicketClient;
|
|
1332
|
+
exports.exchangeUserToken = exchangeUserToken;
|
|
1029
1333
|
exports.expiresAtFrom = expiresAtFrom;
|
|
1334
|
+
exports.fetchTicket = fetchTicket;
|
|
1030
1335
|
exports.loadSsoConfig = loadSsoConfig;
|
|
1031
1336
|
exports.mediaType = mediaType;
|
|
1032
1337
|
exports.memoryTokenStore = memoryTokenStore;
|
|
1033
1338
|
exports.newSid = newSid;
|
|
1339
|
+
exports.normalizeHtu = normalizeHtu;
|
|
1034
1340
|
exports.openEmbeddedFlow = openEmbeddedFlow;
|
|
1035
1341
|
exports.readCookie = readCookie;
|
|
1036
1342
|
exports.sealEmbeddedFlow = sealEmbeddedFlow;
|
|
1037
1343
|
exports.signSession = signSession;
|
|
1038
1344
|
exports.signValue = signValue;
|
|
1345
|
+
exports.verifyDpopProof = verifyDpopProof;
|
|
1039
1346
|
exports.verifySession = verifySession;
|
|
1040
1347
|
exports.verifyValue = verifyValue;
|
|
1041
|
-
//# sourceMappingURL=chunk-
|
|
1042
|
-
//# sourceMappingURL=chunk-
|
|
1348
|
+
//# sourceMappingURL=chunk-2XMKZWC6.cjs.map
|
|
1349
|
+
//# sourceMappingURL=chunk-2XMKZWC6.cjs.map
|