@broberg/sso 0.18.3 → 0.19.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.
@@ -71,6 +71,127 @@ function loadSsoConfig(env = process.env) {
71
71
  clientSecret: env.SSO_CLIENT_SECRET?.trim() || void 0
72
72
  };
73
73
  }
74
+
75
+ // src/session.ts
76
+ var enc = new TextEncoder();
77
+ var b64url = (bytes) => {
78
+ let s = "";
79
+ for (const b of bytes) s += String.fromCharCode(b);
80
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
81
+ };
82
+ var fromB64url = (s) => {
83
+ const pad = s.replace(/-/g, "+").replace(/_/g, "/");
84
+ const bin = atob(pad + "=".repeat((4 - pad.length % 4) % 4));
85
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
86
+ };
87
+ async function hmacKey(secret) {
88
+ return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
89
+ "sign",
90
+ "verify"
91
+ ]);
92
+ }
93
+ async function signValue(value, secret, options = {}) {
94
+ const payload = b64url(enc.encode(value));
95
+ const body = options.maxAgeSeconds === void 0 ? payload : `t${Math.floor((options.now ?? Date.now)() / 1e3)}~${payload}`;
96
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
97
+ return `${body}.${b64url(sig)}`;
98
+ }
99
+ var STAMPED = /^t(\d+)~(.*)$/s;
100
+ async function verifyValue(token, secret, options = {}) {
101
+ if (!token) return null;
102
+ const dot = token.lastIndexOf(".");
103
+ if (dot <= 0) return null;
104
+ const body = token.slice(0, dot);
105
+ try {
106
+ const ok = await crypto.subtle.verify(
107
+ "HMAC",
108
+ await hmacKey(secret),
109
+ fromB64url(token.slice(dot + 1)),
110
+ enc.encode(body)
111
+ );
112
+ if (!ok) return null;
113
+ const stamped = STAMPED.exec(body);
114
+ if (options.maxAgeSeconds !== void 0) {
115
+ if (!stamped) return null;
116
+ const issuedAt = Number(stamped[1]);
117
+ if ((options.now ?? Date.now)() / 1e3 - issuedAt > options.maxAgeSeconds) return null;
118
+ }
119
+ return new TextDecoder().decode(fromB64url(stamped?.[2] ?? body));
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ async function signSession(payload, secret) {
125
+ return signValue(JSON.stringify(payload), secret);
126
+ }
127
+ async function verifySession(token, secret, now = Date.now) {
128
+ const body = await verifyValue(token, secret);
129
+ if (body === null) return null;
130
+ try {
131
+ const payload = JSON.parse(body);
132
+ if (typeof payload.sub !== "string" || payload.sub === "") return null;
133
+ if (typeof payload.exp !== "number" || payload.exp * 1e3 <= now()) return null;
134
+ return payload;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+ function cookieHeader(name, value, opts) {
140
+ const parts = [
141
+ `${name}=${value}`,
142
+ `Path=${opts.path ?? "/"}`,
143
+ `Max-Age=${opts.maxAge}`,
144
+ "HttpOnly",
145
+ // Lax, NOT Strict, and this is load-bearing: the callback from BrobergID
146
+ // is a top-level GET navigation from another site. Strict withholds the
147
+ // cookie on exactly that navigation, so the login transaction cookie would
148
+ // be missing when it is needed and every sign-in would fail with "state
149
+ // does not match" — a message that sends you looking in the wrong place.
150
+ `SameSite=${opts.sameSite ?? "Lax"}`
151
+ ];
152
+ if (opts.secure) parts.push("Secure");
153
+ return parts.join("; ");
154
+ }
155
+ function readCookie(header, name) {
156
+ if (!header) return void 0;
157
+ for (const part of header.split(";")) {
158
+ const eq = part.indexOf("=");
159
+ if (eq === -1) continue;
160
+ if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
161
+ }
162
+ return void 0;
163
+ }
164
+
165
+ // src/embedded.ts
166
+ var EMBEDDED_FLOW_MAX_AGE = 600;
167
+ function sealEmbeddedFlow(flow, secret, options = {}) {
168
+ return signValue(JSON.stringify(flow), secret, { maxAgeSeconds: EMBEDDED_FLOW_MAX_AGE, ...options });
169
+ }
170
+ async function openEmbeddedFlow(raw, secret, options = {}) {
171
+ if (!raw) return { ok: false, reason: "absent" };
172
+ if (await verifyValue(raw, secret) === null) return { ok: false, reason: "unreadable" };
173
+ const fresh = await verifyValue(raw, secret, { maxAgeSeconds: EMBEDDED_FLOW_MAX_AGE, ...options });
174
+ if (fresh === null) return { ok: false, reason: "expired" };
175
+ try {
176
+ const f = JSON.parse(fresh);
177
+ if (typeof f.flowId !== "string" || typeof f.codeVerifier !== "string" || typeof f.nonce !== "string" || typeof f.email !== "string") return { ok: false, reason: "unreadable" };
178
+ return { ok: true, flow: f };
179
+ } catch {
180
+ return { ok: false, reason: "unreadable" };
181
+ }
182
+ }
183
+ function embeddedError(status, body) {
184
+ const code = typeof body.error === "string" ? body.error : "";
185
+ const num = (v) => typeof v === "number" ? v : void 0;
186
+ if (status === 400 && code === "invalid_code") return { kind: "invalid_code", ...num(body.attempts_left) !== void 0 ? { attemptsLeft: num(body.attempts_left) } : {} };
187
+ if (status === 400 && code === "expired") return { kind: "expired" };
188
+ if (status === 400 && code === "terms_outdated") return { kind: "terms_outdated", ...typeof body.version === "string" ? { version: body.version } : {} };
189
+ if (status === 400) return { kind: "invalid_request" };
190
+ if (status === 403 && code === "signup_not_enabled") return { kind: "signup_not_enabled" };
191
+ if (status === 403 || status === 404 || status === 405) return { kind: "not_embedded" };
192
+ if (status === 429) return { kind: code === "locked" ? "locked" : "too_many_requests", ...num(body.retry_after) !== void 0 ? { retryAfter: num(body.retry_after) } : {} };
193
+ return { kind: "unavailable", message: `BID answered ${status}${code ? ` (${code})` : ""}` };
194
+ }
74
195
  var JwksError = class extends Error {
75
196
  constructor(message) {
76
197
  super(message);
@@ -224,15 +345,15 @@ var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
224
345
  function mediaType(contentType) {
225
346
  return (contentType ?? "").split(";")[0].trim().toLowerCase();
226
347
  }
227
- var b64url = (bytes) => {
348
+ var b64url2 = (bytes) => {
228
349
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
229
350
  let s = "";
230
351
  for (const b of view) s += String.fromCharCode(b);
231
352
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
232
353
  };
233
- var randomToken = (bytes = 32) => b64url(crypto.getRandomValues(new Uint8Array(bytes)));
354
+ var randomToken = (bytes = 32) => b64url2(crypto.getRandomValues(new Uint8Array(bytes)));
234
355
  async function challengeFor(verifier) {
235
- return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
356
+ return b64url2(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
236
357
  }
237
358
  var ADDRESS_OWNERSHIP = /* @__PURE__ */ new Set(["verified", "unverified", "not_on_account"]);
238
359
  var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
@@ -552,9 +673,133 @@ function createSsoClient(config, options = {}) {
552
673
  if (options2.cacheMs && options2.cacheMs > 0) introspectCache.set(key, { result, until: Math.min(now() + options2.cacheMs, exp * 1e3) });
553
674
  return result;
554
675
  }
676
+ async function exchangeCode(code, codeVerifier, nonce) {
677
+ const { token_endpoint } = await discovery();
678
+ const res = await fetchImpl(token_endpoint, {
679
+ method: "POST",
680
+ headers: { "content-type": "application/x-www-form-urlencoded" },
681
+ body: new URLSearchParams({
682
+ grant_type: "authorization_code",
683
+ code,
684
+ redirect_uri: config.redirectUri,
685
+ client_id: config.clientId,
686
+ // ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two
687
+ // prove different things, so a confidential client keeps PKCE.
688
+ code_verifier: codeVerifier,
689
+ ...config.clientSecret ? { client_secret: config.clientSecret } : {}
690
+ })
691
+ });
692
+ const raw = await res.text();
693
+ let body;
694
+ try {
695
+ body = raw.trim() === "" ? {} : JSON.parse(raw);
696
+ } catch {
697
+ throw new SsoError(
698
+ `token exchange failed (${res.status}): the response body is not JSON${res.headers.get("content-type") ? ` (content-type: ${res.headers.get("content-type")})` : ""} \u2014 ${excerptForLog(raw, config.clientSecret)}`
699
+ );
700
+ }
701
+ if (!res.ok || !body.id_token) {
702
+ throw new SsoError(
703
+ `token exchange failed (${res.status}): ${body.error ?? "no id_token in response"}` + (body.error_description ? ` \u2014 ${body.error_description}` : "") + invalidClientHint(body.error, config.clientSecret !== void 0)
704
+ );
705
+ }
706
+ const claims = await verifyIdToken(body.id_token, { nonce });
707
+ const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
708
+ return {
709
+ claims: merged.claims,
710
+ unverifiedClaims: merged.unverifiedClaims,
711
+ idToken: body.id_token,
712
+ ...body.access_token ? { accessToken: body.access_token } : {},
713
+ ...body.refresh_token ? { refreshToken: body.refresh_token } : {},
714
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
715
+ };
716
+ }
717
+ async function embeddedCall(method, path, body) {
718
+ if (!config.clientSecret) throw new SsoError("the embedded login runs from the server as a confidential client \u2014 set clientSecret (SSO_CLIENT_SECRET)");
719
+ const url = new URL(`/embedded${path}`, config.issuer).toString();
720
+ let res;
721
+ try {
722
+ res = await fetchImpl(url, {
723
+ method,
724
+ headers: {
725
+ authorization: `Basic ${btoa(`${config.clientId}:${config.clientSecret}`)}`,
726
+ accept: "application/json",
727
+ ...body ? { "content-type": "application/json" } : {}
728
+ },
729
+ ...body ? { body: JSON.stringify({ client_id: config.clientId, ...body }) } : {}
730
+ });
731
+ } catch (e) {
732
+ return { ok: false, status: 0, body: { error: "network", message: e instanceof Error ? e.message : String(e) } };
733
+ }
734
+ const raw = await res.text();
735
+ let json = {};
736
+ try {
737
+ json = raw.trim() ? JSON.parse(raw) : {};
738
+ } catch {
739
+ }
740
+ if (res.status === 401) {
741
+ throw new SsoError(`BID refused this app's client credentials on /embedded${path} (401 ${typeof json.error === "string" ? json.error : "invalid_client"}) \u2014 check SSO_CLIENT_ID / SSO_CLIENT_SECRET`);
742
+ }
743
+ return res.ok ? { ok: true, body: json } : { ok: false, status: res.status, body: json };
744
+ }
745
+ const toError = (r) => r.status === 0 ? { kind: "unavailable", message: `BID did not answer: ${String(r.body.message ?? "")}` } : embeddedError(r.status, r.body);
746
+ async function embeddedNext(r, flow) {
747
+ if (!r.ok) return { kind: "error", error: toError(r) };
748
+ if (r.body.next === "done" && typeof r.body.code === "string") {
749
+ return { kind: "done", result: await exchangeCode(r.body.code, flow.codeVerifier, flow.nonce) };
750
+ }
751
+ if (r.body.next === "second_factor") {
752
+ const methods = (Array.isArray(r.body.methods) ? r.body.methods : []).filter((m) => m === "totp" || m === "sms");
753
+ return { kind: "second_factor", methods };
754
+ }
755
+ return { kind: "error", error: { kind: "unavailable", message: "BID's answer names no next step" } };
756
+ }
757
+ const embedded = {
758
+ async start(input) {
759
+ const codeVerifier = randomToken();
760
+ const nonce = randomToken(16);
761
+ const r = await embeddedCall("POST", "/start", {
762
+ email: input.email,
763
+ code_challenge: await challengeFor(codeVerifier),
764
+ code_challenge_method: "S256",
765
+ nonce,
766
+ ...input.intent === "signup" ? { intent: "signup", name: input.name, terms_version: input.termsVersion } : {},
767
+ ...input.endUserIp ? { end_user_ip: input.endUserIp } : {}
768
+ });
769
+ if (!r.ok) return { ok: false, error: toError(r) };
770
+ if (typeof r.body.flow_id !== "string") return { ok: false, error: { kind: "unavailable", message: "BID's answer has no flow_id" } };
771
+ return {
772
+ ok: true,
773
+ flow: { flowId: r.body.flow_id, codeVerifier, nonce, email: input.email },
774
+ expiresIn: typeof r.body.expires_in === "number" ? r.body.expires_in : 600
775
+ };
776
+ },
777
+ async verify(flow, code) {
778
+ return embeddedNext(await embeddedCall("POST", "/verify", { flow_id: flow.flowId, code }), flow);
779
+ },
780
+ async sendSms(flow) {
781
+ const r = await embeddedCall("POST", "/second-factor/sms", { flow_id: flow.flowId });
782
+ return r.ok ? { ok: true } : { ok: false, error: toError(r) };
783
+ },
784
+ async verifySecondFactor(flow, method, code) {
785
+ return embeddedNext(await embeddedCall("POST", "/second-factor", { flow_id: flow.flowId, method, code }), flow);
786
+ },
787
+ async terms(lang) {
788
+ const r = await embeddedCall("GET", `/terms?lang=${lang}`);
789
+ const b = r.body;
790
+ if (!r.ok || typeof b.version !== "string" || typeof b.text !== "string") {
791
+ throw new SsoError(`BID did not answer the signup terms (${r.ok ? "no version/text" : r.status})`);
792
+ }
793
+ const links = (Array.isArray(b.links) ? b.links : []).filter(
794
+ (l) => typeof l === "object" && l !== null && typeof l.label === "string" && typeof l.url === "string"
795
+ );
796
+ return { version: b.version, text: b.text, links: links.map((l) => ({ label: l.label, url: l.url })) };
797
+ }
798
+ };
555
799
  return {
556
800
  discovery,
557
801
  introspectToken,
802
+ embedded,
558
803
  get jwks() {
559
804
  if (!jwksCache) throw new SsoError("the key cache is created on first verification");
560
805
  return jwksCache;
@@ -593,45 +838,7 @@ function createSsoClient(config, options = {}) {
593
838
  }
594
839
  const code = params.get("code");
595
840
  if (!code) throw new SsoError("callback carried neither an error nor a code");
596
- const { token_endpoint } = await discovery();
597
- const res = await fetchImpl(token_endpoint, {
598
- method: "POST",
599
- headers: { "content-type": "application/x-www-form-urlencoded" },
600
- body: new URLSearchParams({
601
- grant_type: "authorization_code",
602
- code,
603
- redirect_uri: config.redirectUri,
604
- client_id: config.clientId,
605
- // ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two
606
- // prove different things, so a confidential client keeps PKCE.
607
- code_verifier: codeVerifier,
608
- ...config.clientSecret ? { client_secret: config.clientSecret } : {}
609
- })
610
- });
611
- const raw = await res.text();
612
- let body;
613
- try {
614
- body = raw.trim() === "" ? {} : JSON.parse(raw);
615
- } catch {
616
- throw new SsoError(
617
- `token exchange failed (${res.status}): the response body is not JSON${res.headers.get("content-type") ? ` (content-type: ${res.headers.get("content-type")})` : ""} \u2014 ${excerptForLog(raw, config.clientSecret)}`
618
- );
619
- }
620
- if (!res.ok || !body.id_token) {
621
- throw new SsoError(
622
- `token exchange failed (${res.status}): ${body.error ?? "no id_token in response"}` + (body.error_description ? ` \u2014 ${body.error_description}` : "") + invalidClientHint(body.error, config.clientSecret !== void 0)
623
- );
624
- }
625
- const claims = await verifyIdToken(body.id_token, { nonce });
626
- const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
627
- return {
628
- claims: merged.claims,
629
- unverifiedClaims: merged.unverifiedClaims,
630
- idToken: body.id_token,
631
- ...body.access_token ? { accessToken: body.access_token } : {},
632
- ...body.refresh_token ? { refreshToken: body.refresh_token } : {},
633
- ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
634
- };
841
+ return exchangeCode(code, codeVerifier, nonce);
635
842
  },
636
843
  async refreshTokens(refreshToken) {
637
844
  const { token_endpoint } = await discovery();
@@ -748,96 +955,6 @@ function createSsoClient(config, options = {}) {
748
955
  };
749
956
  }
750
957
 
751
- // src/session.ts
752
- var enc = new TextEncoder();
753
- var b64url2 = (bytes) => {
754
- let s = "";
755
- for (const b of bytes) s += String.fromCharCode(b);
756
- return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
757
- };
758
- var fromB64url = (s) => {
759
- const pad = s.replace(/-/g, "+").replace(/_/g, "/");
760
- const bin = atob(pad + "=".repeat((4 - pad.length % 4) % 4));
761
- return Uint8Array.from(bin, (c) => c.charCodeAt(0));
762
- };
763
- async function hmacKey(secret) {
764
- return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
765
- "sign",
766
- "verify"
767
- ]);
768
- }
769
- async function signValue(value, secret, options = {}) {
770
- const payload = b64url2(enc.encode(value));
771
- const body = options.maxAgeSeconds === void 0 ? payload : `t${Math.floor((options.now ?? Date.now)() / 1e3)}~${payload}`;
772
- const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
773
- return `${body}.${b64url2(sig)}`;
774
- }
775
- var STAMPED = /^t(\d+)~(.*)$/s;
776
- async function verifyValue(token, secret, options = {}) {
777
- if (!token) return null;
778
- const dot = token.lastIndexOf(".");
779
- if (dot <= 0) return null;
780
- const body = token.slice(0, dot);
781
- try {
782
- const ok = await crypto.subtle.verify(
783
- "HMAC",
784
- await hmacKey(secret),
785
- fromB64url(token.slice(dot + 1)),
786
- enc.encode(body)
787
- );
788
- if (!ok) return null;
789
- const stamped = STAMPED.exec(body);
790
- if (options.maxAgeSeconds !== void 0) {
791
- if (!stamped) return null;
792
- const issuedAt = Number(stamped[1]);
793
- if ((options.now ?? Date.now)() / 1e3 - issuedAt > options.maxAgeSeconds) return null;
794
- }
795
- return new TextDecoder().decode(fromB64url(stamped?.[2] ?? body));
796
- } catch {
797
- return null;
798
- }
799
- }
800
- async function signSession(payload, secret) {
801
- return signValue(JSON.stringify(payload), secret);
802
- }
803
- async function verifySession(token, secret, now = Date.now) {
804
- const body = await verifyValue(token, secret);
805
- if (body === null) return null;
806
- try {
807
- const payload = JSON.parse(body);
808
- if (typeof payload.sub !== "string" || payload.sub === "") return null;
809
- if (typeof payload.exp !== "number" || payload.exp * 1e3 <= now()) return null;
810
- return payload;
811
- } catch {
812
- return null;
813
- }
814
- }
815
- function cookieHeader(name, value, opts) {
816
- const parts = [
817
- `${name}=${value}`,
818
- `Path=${opts.path ?? "/"}`,
819
- `Max-Age=${opts.maxAge}`,
820
- "HttpOnly",
821
- // Lax, NOT Strict, and this is load-bearing: the callback from BrobergID
822
- // is a top-level GET navigation from another site. Strict withholds the
823
- // cookie on exactly that navigation, so the login transaction cookie would
824
- // be missing when it is needed and every sign-in would fail with "state
825
- // does not match" — a message that sends you looking in the wrong place.
826
- `SameSite=${opts.sameSite ?? "Lax"}`
827
- ];
828
- if (opts.secure) parts.push("Secure");
829
- return parts.join("; ");
830
- }
831
- function readCookie(header, name) {
832
- if (!header) return void 0;
833
- for (const part of header.split(";")) {
834
- const eq = part.indexOf("=");
835
- if (eq === -1) continue;
836
- if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
837
- }
838
- return void 0;
839
- }
840
-
841
958
  // src/tokens.ts
842
959
  var warned = false;
843
960
  function memoryTokenStore(options = {}) {
@@ -885,6 +1002,6 @@ function newSid() {
885
1002
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
886
1003
  }
887
1004
 
888
- export { ALLOWED_ALGS, AVATAR_TYPES, DEFAULT_SESSION_MAX_AGE, JwksError, JwksUnavailableError, JwksUnknownKeyError, MAX_AVATAR_BYTES, MAX_INVITATIONS, SsoAppApiError, SsoAvatarRejectedError, SsoConfigError, SsoError, SsoInsufficientScopeError, SsoIntrospectionUnavailableError, SsoReauthError, SsoTokenInactiveError, cookieHeader, createJwksCache, createSsoClient, expiresAtFrom, loadSsoConfig, mediaType, memoryTokenStore, newSid, readCookie, signSession, signValue, verifySession, verifyValue };
889
- //# sourceMappingURL=chunk-XKKKS2RO.js.map
890
- //# sourceMappingURL=chunk-XKKKS2RO.js.map
1005
+ export { ALLOWED_ALGS, AVATAR_TYPES, DEFAULT_SESSION_MAX_AGE, EMBEDDED_FLOW_MAX_AGE, JwksError, JwksUnavailableError, JwksUnknownKeyError, MAX_AVATAR_BYTES, MAX_INVITATIONS, SsoAppApiError, SsoAvatarRejectedError, SsoConfigError, SsoError, SsoInsufficientScopeError, SsoIntrospectionUnavailableError, SsoReauthError, SsoTokenInactiveError, cookieHeader, createJwksCache, createSsoClient, expiresAtFrom, loadSsoConfig, mediaType, memoryTokenStore, newSid, openEmbeddedFlow, readCookie, sealEmbeddedFlow, signSession, signValue, verifySession, verifyValue };
1006
+ //# sourceMappingURL=chunk-PQTT74BA.js.map
1007
+ //# sourceMappingURL=chunk-PQTT74BA.js.map