@broberg/sso 0.12.1 → 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/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,239 @@ 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
+ }
807
+ var PRINCIPAL_TYPES = ["human", "service", "agent"];
808
+ function createTicketVerifier(options) {
809
+ const {
810
+ issuer,
811
+ audience,
812
+ maxLifetimeSec = 15 * 60,
813
+ clockToleranceSec = 30,
814
+ fetchImpl = fetch
815
+ } = options;
816
+ if (!issuer) throw new SsoError("createTicketVerifier needs an issuer");
817
+ if (!audience) throw new SsoError("createTicketVerifier needs this receiver's audience");
818
+ let cachePromise = null;
819
+ const cacheFor = (jwksUri) => createJwksCache({
820
+ jwksUri,
821
+ fetchImpl,
822
+ ...options.warmUp ? { warmUp: true } : {},
823
+ ...options.minRefetchIntervalMs !== void 0 ? { minRefetchIntervalMs: options.minRefetchIntervalMs } : {}
824
+ });
825
+ async function keys() {
826
+ cachePromise ??= (async () => {
827
+ if (options.jwksUri) return cacheFor(options.jwksUri);
828
+ const url = `${issuer}/.well-known/openid-configuration`;
829
+ let doc;
830
+ try {
831
+ const res = await fetchImpl(url);
832
+ if (!res.ok) throw new Error(`answered ${res.status}`);
833
+ doc = await res.json();
834
+ } catch (cause) {
835
+ throw new JwksUnavailableError(`${url}: ${cause instanceof Error ? cause.message : String(cause)}`);
836
+ }
837
+ if (doc.issuer !== issuer) {
838
+ throw new SsoError(`issuer is ${issuer} but ${url} says ${doc.issuer} \u2014 they must match exactly`);
839
+ }
840
+ if (!doc.jwks_uri) throw new SsoError(`${url} names no jwks_uri`);
841
+ return cacheFor(doc.jwks_uri);
842
+ })().catch((err) => {
843
+ cachePromise = null;
844
+ throw err;
845
+ });
846
+ return cachePromise;
847
+ }
848
+ if (options.warmUp) void keys().catch(() => {
849
+ });
850
+ return {
851
+ async verify(token, opts = {}) {
852
+ let header;
853
+ try {
854
+ header = jose.decodeProtectedHeader(token);
855
+ } catch {
856
+ throw new SsoError("ticket is not a JWS");
857
+ }
858
+ if (!header.kid) throw new SsoError("ticket has no kid \u2014 cannot pick a signing key");
859
+ if (header.typ === "logout+jwt") throw new SsoError("a logout token is not a ticket");
860
+ const cache = await keys();
861
+ let payload;
862
+ try {
863
+ ({ payload } = await jose.jwtVerify(token, async () => cache.getKey(header.kid, header.alg ?? "EdDSA"), {
864
+ issuer,
865
+ audience,
866
+ algorithms: ALLOWED_ALGS,
867
+ clockTolerance: clockToleranceSec,
868
+ requiredClaims: ["exp", "iat", "jti", "sub"]
869
+ }));
870
+ } catch (e) {
871
+ if (e instanceof JwksUnavailableError) throw e;
872
+ throw new SsoError(`ticket rejected: ${e instanceof Error ? e.message : String(e)}`);
873
+ }
874
+ if ("events" in payload) throw new SsoError("a logout token is not a ticket");
875
+ if ("nonce" in payload) throw new SsoError("an ID token is not a ticket");
876
+ const type = payload.principal_type;
877
+ if (typeof type !== "string" || !PRINCIPAL_TYPES.includes(type)) {
878
+ throw new SsoError(`ticket has no valid principal_type (got ${JSON.stringify(type)})`);
879
+ }
880
+ const lifetime = payload.exp - payload.iat;
881
+ if (lifetime > maxLifetimeSec) {
882
+ throw new SsoError(`ticket claims a ${lifetime}s lifetime; at most ${maxLifetimeSec}s is accepted`);
883
+ }
884
+ if (typeof payload.sub !== "string" || payload.sub === "") throw new SsoError("ticket has no sub");
885
+ const scopes = typeof payload.scope === "string" ? payload.scope.split(" ").filter(Boolean) : [];
886
+ const required2 = opts.scope === void 0 ? [] : Array.isArray(opts.scope) ? opts.scope : [opts.scope];
887
+ const missing = required2.filter((s) => !scopes.includes(s));
888
+ if (missing.length) throw new SsoError(`ticket lacks scope ${missing.join(", ")}`);
889
+ const actRaw = payload.act;
890
+ const act = actRaw && typeof actRaw.sub === "string" && actRaw.sub ? { sub: actRaw.sub } : null;
891
+ return {
892
+ principal: payload.sub,
893
+ type,
894
+ clientId: typeof payload.client_id === "string" && payload.client_id ? payload.client_id : payload.sub,
895
+ org: typeof payload.org === "string" && payload.org ? payload.org : null,
896
+ act,
897
+ scopes,
898
+ exp: payload.exp,
899
+ jti: String(payload.jti)
900
+ };
901
+ }
902
+ };
903
+ }
669
904
 
670
905
  // src/session.ts
671
906
  var enc = new TextEncoder();
@@ -787,21 +1022,28 @@ function memoryTokenStore(options = {}) {
787
1022
  }
788
1023
 
789
1024
  exports.AVATAR_TYPES = AVATAR_TYPES;
1025
+ exports.DEFAULT_ISSUER = DEFAULT_ISSUER;
790
1026
  exports.DEFAULT_SESSION_MAX_AGE = DEFAULT_SESSION_MAX_AGE;
791
1027
  exports.JwksError = JwksError;
792
1028
  exports.JwksUnavailableError = JwksUnavailableError;
793
1029
  exports.JwksUnknownKeyError = JwksUnknownKeyError;
794
1030
  exports.MAX_AVATAR_BYTES = MAX_AVATAR_BYTES;
795
1031
  exports.MAX_INVITATIONS = MAX_INVITATIONS;
1032
+ exports.NoWorkloadIdentityError = NoWorkloadIdentityError;
796
1033
  exports.SsoAppApiError = SsoAppApiError;
797
1034
  exports.SsoAvatarRejectedError = SsoAvatarRejectedError;
798
1035
  exports.SsoConfigError = SsoConfigError;
799
1036
  exports.SsoError = SsoError;
800
1037
  exports.SsoInsufficientScopeError = SsoInsufficientScopeError;
801
1038
  exports.SsoReauthError = SsoReauthError;
1039
+ exports.TicketExchangeError = TicketExchangeError;
1040
+ exports.TicketUnavailableError = TicketUnavailableError;
802
1041
  exports.cookieHeader = cookieHeader;
803
1042
  exports.createJwksCache = createJwksCache;
804
1043
  exports.createSsoClient = createSsoClient;
1044
+ exports.createTicketClient = createTicketClient;
1045
+ exports.createTicketVerifier = createTicketVerifier;
1046
+ exports.fetchTicket = fetchTicket;
805
1047
  exports.loadSsoConfig = loadSsoConfig;
806
1048
  exports.memoryTokenStore = memoryTokenStore;
807
1049
  exports.readCookie = readCookie;