@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/README.md +46 -0
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +242 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +94 -1
- package/dist/index.d.ts +94 -1
- package/dist/index.js +236 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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;
|