@broberg/sso 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -552,3 +552,21 @@ try {
552
552
  - **Never accepted as a ticket:** an ID token or a logout token, even though BID signed both.
553
553
  - **BID briefly down:** a key already in the cache still verifies, with no network call. An unknown key while BID is down throws `JwksUnavailableError` — answer 503, do not reject the caller.
554
554
  - Not a login client: a service that only receives tickets needs no `client_id`, no redirect and no session.
555
+
556
+ ## Getting a ticket — `fetchTicket` (since 0.14.1 — 0.14.0 was tagged but never reached npm)
557
+
558
+ The sending half: a workload on **Fly** or in **GitHub Actions** gets a Broberg ID ticket with no key at all. It proves who it is with the identity its platform already gives it, and BID exchanges that (RFC 8693). Nothing secret is stored anywhere.
559
+
560
+ ```ts
561
+ import { fetchTicket, NoWorkloadIdentityError, TicketExchangeError, TicketUnavailableError } from "@broberg/sso";
562
+
563
+ const ticket = await fetchTicket({ audience: "discovery", scope: "discovery:read" });
564
+ await fetch("https://discovery.broberg.ai/api/fleet", { headers: { authorization: `Bearer ${ticket}` } });
565
+ ```
566
+
567
+ - **Fly:** asks the machine API (`unix:/.fly/api`, `POST /v1/tokens/oidc`, `aud` = the issuer). The ticket's subject is `svc:<fly app name>` — BID derives it from the app name, so the rule in BID must name the app exactly.
568
+ - **GitHub Actions:** uses `ACTIONS_ID_TOKEN_REQUEST_URL` / `_TOKEN`; the workflow needs `permissions: id-token: write`. Subject `svc:gh:<owner>/<repo>`, and BID's rule names ONE workflow file (a new file cannot get a ticket).
569
+ - **Anywhere else** (a Mac session, a laptop): `NoWorkloadIdentityError` — keep using the session key there.
570
+ - **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.
571
+ - **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`.
572
+ - The ticket is never logged or put in an error message.
package/dist/index.cjs CHANGED
@@ -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 {
@@ -666,6 +668,142 @@ function createSsoClient(config, options = {}) {
666
668
  }
667
669
  };
668
670
  }
671
+ var DEFAULT_ISSUER = "https://id.broberg.ai";
672
+ var FLY_SOCKET = "/.fly/api";
673
+ var NoWorkloadIdentityError = class extends Error {
674
+ constructor(message) {
675
+ super(message);
676
+ this.name = "NoWorkloadIdentityError";
677
+ }
678
+ };
679
+ var TicketExchangeError = class extends Error {
680
+ constructor(message, code, status) {
681
+ super(message);
682
+ this.code = code;
683
+ this.status = status;
684
+ this.name = "TicketExchangeError";
685
+ }
686
+ code;
687
+ status;
688
+ };
689
+ var TicketUnavailableError = class extends Error {
690
+ constructor(message) {
691
+ super(message);
692
+ this.name = "TicketUnavailableError";
693
+ }
694
+ };
695
+ function flyOidcOverSocket(aud) {
696
+ return new Promise((resolve, reject) => {
697
+ const body = JSON.stringify({ aud });
698
+ const req = http.request(
699
+ { socketPath: FLY_SOCKET, path: "/v1/tokens/oidc", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } },
700
+ (res) => {
701
+ let data = "";
702
+ res.setEncoding("utf8");
703
+ res.on("data", (c) => data += c);
704
+ res.on("end", () => {
705
+ if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(data.trim());
706
+ else reject(new TicketUnavailableError(`Fly OIDC answered ${res.statusCode}`));
707
+ });
708
+ }
709
+ );
710
+ req.on("error", (e) => reject(new TicketUnavailableError(`Fly OIDC socket: ${e.message}`)));
711
+ req.end(body);
712
+ });
713
+ }
714
+ function jwtExp(token) {
715
+ try {
716
+ const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
717
+ return typeof payload.exp === "number" ? payload.exp : null;
718
+ } catch {
719
+ return null;
720
+ }
721
+ }
722
+ function createTicketClient(options = {}) {
723
+ const issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
724
+ const renewBeforeSec = options.renewBeforeSec ?? 30;
725
+ const env = options.env ?? process.env;
726
+ const fetchImpl = options.fetchImpl ?? fetch;
727
+ const now = options.now ?? (() => Date.now());
728
+ const flyOidc = options.flyOidc ?? flyOidcOverSocket;
729
+ const onFly = options.onFly ?? (() => !!env.FLY_APP_NAME && fs.existsSync(FLY_SOCKET));
730
+ const cache = /* @__PURE__ */ new Map();
731
+ const inFlight = /* @__PURE__ */ new Map();
732
+ async function platformToken() {
733
+ if (onFly()) return flyOidc(issuer);
734
+ const url = env.ACTIONS_ID_TOKEN_REQUEST_URL;
735
+ const bearer = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
736
+ if (url && bearer) {
737
+ let res;
738
+ try {
739
+ res = await fetchImpl(`${url}&audience=${encodeURIComponent(issuer)}`, { headers: { authorization: `Bearer ${bearer}` } });
740
+ } catch (e) {
741
+ throw new TicketUnavailableError(`GitHub OIDC endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
742
+ }
743
+ if (!res.ok) throw new TicketUnavailableError(`GitHub OIDC endpoint answered ${res.status}`);
744
+ const { value } = await res.json();
745
+ if (!value) throw new TicketUnavailableError("GitHub OIDC endpoint returned no token");
746
+ return value;
747
+ }
748
+ throw new NoWorkloadIdentityError(
749
+ "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."
750
+ );
751
+ }
752
+ async function exchange(audience, scope) {
753
+ const subject = await platformToken();
754
+ const form = new URLSearchParams({
755
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
756
+ subject_token: subject,
757
+ subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
758
+ audience
759
+ });
760
+ if (scope) form.set("scope", scope);
761
+ let res;
762
+ try {
763
+ res = await fetchImpl(`${issuer}/oauth2/token`, {
764
+ method: "POST",
765
+ headers: { "content-type": "application/x-www-form-urlencoded" },
766
+ body: form
767
+ });
768
+ } catch (e) {
769
+ throw new TicketUnavailableError(`${issuer} unreachable: ${e instanceof Error ? e.message : String(e)}`);
770
+ }
771
+ let body = {};
772
+ try {
773
+ body = await res.json();
774
+ } catch {
775
+ }
776
+ if (res.status >= 500) throw new TicketUnavailableError(`${issuer} answered ${res.status}`);
777
+ if (!res.ok || !body.access_token) {
778
+ const desc = body.error_description ?? "";
779
+ const code = desc.includes(":") ? desc.slice(0, desc.indexOf(":")).trim() : body.error ?? "exchange_refused";
780
+ throw new TicketExchangeError(`Broberg ID refused the ticket for ${audience}: ${desc || body.error || res.status}`, code, res.status);
781
+ }
782
+ const nowSec = Math.floor(now() / 1e3);
783
+ const exp = jwtExp(body.access_token) ?? nowSec + (body.expires_in ?? 300);
784
+ return { token: body.access_token, exp };
785
+ }
786
+ return {
787
+ async get({ audience, scope }) {
788
+ const key = `${audience} ${scope ?? ""}`;
789
+ const hit = cache.get(key);
790
+ if (hit && hit.exp - renewBeforeSec > now() / 1e3) return hit.token;
791
+ const pending = inFlight.get(key);
792
+ if (pending) return pending;
793
+ const p = exchange(audience, scope).then((t) => {
794
+ cache.set(key, t);
795
+ return t.token;
796
+ }).finally(() => inFlight.delete(key));
797
+ inFlight.set(key, p);
798
+ return p;
799
+ }
800
+ };
801
+ }
802
+ var shared = null;
803
+ function fetchTicket(opts) {
804
+ shared ??= createTicketClient();
805
+ return shared.get(opts);
806
+ }
669
807
  var PRINCIPAL_TYPES = ["human", "service", "agent"];
670
808
  function createTicketVerifier(options) {
671
809
  const {
@@ -884,22 +1022,28 @@ function memoryTokenStore(options = {}) {
884
1022
  }
885
1023
 
886
1024
  exports.AVATAR_TYPES = AVATAR_TYPES;
1025
+ exports.DEFAULT_ISSUER = DEFAULT_ISSUER;
887
1026
  exports.DEFAULT_SESSION_MAX_AGE = DEFAULT_SESSION_MAX_AGE;
888
1027
  exports.JwksError = JwksError;
889
1028
  exports.JwksUnavailableError = JwksUnavailableError;
890
1029
  exports.JwksUnknownKeyError = JwksUnknownKeyError;
891
1030
  exports.MAX_AVATAR_BYTES = MAX_AVATAR_BYTES;
892
1031
  exports.MAX_INVITATIONS = MAX_INVITATIONS;
1032
+ exports.NoWorkloadIdentityError = NoWorkloadIdentityError;
893
1033
  exports.SsoAppApiError = SsoAppApiError;
894
1034
  exports.SsoAvatarRejectedError = SsoAvatarRejectedError;
895
1035
  exports.SsoConfigError = SsoConfigError;
896
1036
  exports.SsoError = SsoError;
897
1037
  exports.SsoInsufficientScopeError = SsoInsufficientScopeError;
898
1038
  exports.SsoReauthError = SsoReauthError;
1039
+ exports.TicketExchangeError = TicketExchangeError;
1040
+ exports.TicketUnavailableError = TicketUnavailableError;
899
1041
  exports.cookieHeader = cookieHeader;
900
1042
  exports.createJwksCache = createJwksCache;
901
1043
  exports.createSsoClient = createSsoClient;
1044
+ exports.createTicketClient = createTicketClient;
902
1045
  exports.createTicketVerifier = createTicketVerifier;
1046
+ exports.fetchTicket = fetchTicket;
903
1047
  exports.loadSsoConfig = loadSsoConfig;
904
1048
  exports.memoryTokenStore = memoryTokenStore;
905
1049
  exports.readCookie = readCookie;