@broberg/sso 0.18.2 → 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.
- package/README.md +25 -0
- package/dist/{chunk-FWFXRNDG.cjs → chunk-MZE4TPFU.cjs} +258 -138
- package/dist/chunk-MZE4TPFU.cjs.map +1 -0
- package/dist/{chunk-RWTUJPV3.js → chunk-PQTT74BA.js} +256 -139
- package/dist/chunk-PQTT74BA.js.map +1 -0
- package/dist/hono.cjs +37 -37
- package/dist/hono.d.cts +1 -1
- package/dist/hono.d.ts +1 -1
- package/dist/hono.js +1 -1
- package/dist/index.cjs +78 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/{session-eya6Bon5.d.cts → session-qvhMg3ID.d.cts} +106 -1
- package/dist/{session-eya6Bon5.d.ts → session-qvhMg3ID.d.ts} +106 -1
- package/package.json +1 -1
- package/dist/chunk-FWFXRNDG.cjs.map +0 -1
- package/dist/chunk-RWTUJPV3.js.map +0 -1
package/README.md
CHANGED
|
@@ -240,6 +240,31 @@ try {
|
|
|
240
240
|
- **Needs `SSO_CLIENT_SECRET`** — a public client cannot introspect. Since 0.18.0 (F084.160) that is `SsoConfigError`, thrown before any request (0.17.0 called it `SsoIntrospectionUnavailableError`, which sent callers down the 503-and-retry path for a fault that never goes away). Answer 500 and fix the setup; never retry. A wrong secret is reported as unavailable with "client id/secret was refused" — measured against id.broberg.ai.
|
|
241
241
|
- **Cache** (optional, `cacheMs`): an active answer is reused up to that long and never past the token's `exp`; an inactive answer is never cached. The token itself never appears in an error message.
|
|
242
242
|
|
|
243
|
+
## Login and signup in YOUR design — `client.embedded` (0.19.0, F084.165)
|
|
244
|
+
|
|
245
|
+
For an app BID has allowed to draw its own login (BID F084.166 v1.1 + F084.167 — owner's exception,
|
|
246
|
+
Jam first). Runs on YOUR SERVER as a confidential client (`clientSecret` required — BID gets
|
|
247
|
+
`Authorization: Basic client_id:client_secret`). Ends in the SAME `LoginResult` as `completeLogin`,
|
|
248
|
+
so your `signSession` step does not change.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const s = await client.embedded.start({ email, endUserIp, intent: "signup", name, termsVersion }); // intent/name/termsVersion only for signup
|
|
252
|
+
if (s.ok) setCookie("bid_flow", await sealEmbeddedFlow(s.flow, cookieSecret), { httpOnly: true, maxAge: EMBEDDED_FLOW_MAX_AGE });
|
|
253
|
+
// later, with the code from the mail:
|
|
254
|
+
const f = await openEmbeddedFlow(getCookie("bid_flow"), cookieSecret); // { ok, flow } | { ok:false, reason: absent|expired|unreadable }
|
|
255
|
+
const step = f.ok ? await client.embedded.verify(f.flow, code) : null; // done → step.result is a LoginResult
|
|
256
|
+
// second_factor → client.embedded.sendSms(flow) / verifySecondFactor(flow, "totp"|"sms", code)
|
|
257
|
+
const terms = await client.embedded.terms("da"); // show terms.text + links VERBATIM at the signup button
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
- The PKCE verifier and nonce stay server-side in the sealed flow cookie (10 min, same signing as the login
|
|
261
|
+
transaction); the id_token's `nonce` must match the flow's, or `verify` throws.
|
|
262
|
+
- Errors are data: `invalid_code {attemptsLeft}`, `expired`, `locked {retryAfter}`, `too_many_requests {retryAfter}`,
|
|
263
|
+
`not_embedded` (403/404 — fall back to the normal redirect login), `signup_not_enabled`, `terms_outdated {version}`
|
|
264
|
+
(re-fetch `terms()`), `invalid_request`, `unavailable`.
|
|
265
|
+
- A 401 from BID is YOUR configuration (wrong client id/secret) and THROWS `SsoError` — it is never shown to the
|
|
266
|
+
user as a wrong code.
|
|
267
|
+
|
|
243
268
|
## What it will never do
|
|
244
269
|
|
|
245
270
|
No passwords. No passkey registration. No social-provider keys. No email
|
|
@@ -21,7 +21,7 @@ function loadSsoConfig(env = process.env) {
|
|
|
21
21
|
const rawIssuer = required(
|
|
22
22
|
env,
|
|
23
23
|
"BID_ISSUER",
|
|
24
|
-
"It is
|
|
24
|
+
"It is BID's bare origin, e.g. https://id.broberg.ai"
|
|
25
25
|
);
|
|
26
26
|
let issuer;
|
|
27
27
|
try {
|
|
@@ -59,7 +59,7 @@ function loadSsoConfig(env = process.env) {
|
|
|
59
59
|
}
|
|
60
60
|
return {
|
|
61
61
|
issuer,
|
|
62
|
-
clientId: required(env, "SSO_CLIENT_ID", "The id this app is registered under in
|
|
62
|
+
clientId: required(env, "SSO_CLIENT_ID", "The id this app is registered under in BID."),
|
|
63
63
|
redirectUri: required(
|
|
64
64
|
env,
|
|
65
65
|
"SSO_REDIRECT_URI",
|
|
@@ -73,6 +73,127 @@ function loadSsoConfig(env = process.env) {
|
|
|
73
73
|
clientSecret: env.SSO_CLIENT_SECRET?.trim() || void 0
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
// src/session.ts
|
|
78
|
+
var enc = new TextEncoder();
|
|
79
|
+
var b64url = (bytes) => {
|
|
80
|
+
let s = "";
|
|
81
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
82
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
83
|
+
};
|
|
84
|
+
var fromB64url = (s) => {
|
|
85
|
+
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
86
|
+
const bin = atob(pad + "=".repeat((4 - pad.length % 4) % 4));
|
|
87
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
88
|
+
};
|
|
89
|
+
async function hmacKey(secret) {
|
|
90
|
+
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
91
|
+
"sign",
|
|
92
|
+
"verify"
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
async function signValue(value, secret, options = {}) {
|
|
96
|
+
const payload = b64url(enc.encode(value));
|
|
97
|
+
const body = options.maxAgeSeconds === void 0 ? payload : `t${Math.floor((options.now ?? Date.now)() / 1e3)}~${payload}`;
|
|
98
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
|
|
99
|
+
return `${body}.${b64url(sig)}`;
|
|
100
|
+
}
|
|
101
|
+
var STAMPED = /^t(\d+)~(.*)$/s;
|
|
102
|
+
async function verifyValue(token, secret, options = {}) {
|
|
103
|
+
if (!token) return null;
|
|
104
|
+
const dot = token.lastIndexOf(".");
|
|
105
|
+
if (dot <= 0) return null;
|
|
106
|
+
const body = token.slice(0, dot);
|
|
107
|
+
try {
|
|
108
|
+
const ok = await crypto.subtle.verify(
|
|
109
|
+
"HMAC",
|
|
110
|
+
await hmacKey(secret),
|
|
111
|
+
fromB64url(token.slice(dot + 1)),
|
|
112
|
+
enc.encode(body)
|
|
113
|
+
);
|
|
114
|
+
if (!ok) return null;
|
|
115
|
+
const stamped = STAMPED.exec(body);
|
|
116
|
+
if (options.maxAgeSeconds !== void 0) {
|
|
117
|
+
if (!stamped) return null;
|
|
118
|
+
const issuedAt = Number(stamped[1]);
|
|
119
|
+
if ((options.now ?? Date.now)() / 1e3 - issuedAt > options.maxAgeSeconds) return null;
|
|
120
|
+
}
|
|
121
|
+
return new TextDecoder().decode(fromB64url(stamped?.[2] ?? body));
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function signSession(payload, secret) {
|
|
127
|
+
return signValue(JSON.stringify(payload), secret);
|
|
128
|
+
}
|
|
129
|
+
async function verifySession(token, secret, now = Date.now) {
|
|
130
|
+
const body = await verifyValue(token, secret);
|
|
131
|
+
if (body === null) return null;
|
|
132
|
+
try {
|
|
133
|
+
const payload = JSON.parse(body);
|
|
134
|
+
if (typeof payload.sub !== "string" || payload.sub === "") return null;
|
|
135
|
+
if (typeof payload.exp !== "number" || payload.exp * 1e3 <= now()) return null;
|
|
136
|
+
return payload;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function cookieHeader(name, value, opts) {
|
|
142
|
+
const parts = [
|
|
143
|
+
`${name}=${value}`,
|
|
144
|
+
`Path=${opts.path ?? "/"}`,
|
|
145
|
+
`Max-Age=${opts.maxAge}`,
|
|
146
|
+
"HttpOnly",
|
|
147
|
+
// Lax, NOT Strict, and this is load-bearing: the callback from BrobergID
|
|
148
|
+
// is a top-level GET navigation from another site. Strict withholds the
|
|
149
|
+
// cookie on exactly that navigation, so the login transaction cookie would
|
|
150
|
+
// be missing when it is needed and every sign-in would fail with "state
|
|
151
|
+
// does not match" — a message that sends you looking in the wrong place.
|
|
152
|
+
`SameSite=${opts.sameSite ?? "Lax"}`
|
|
153
|
+
];
|
|
154
|
+
if (opts.secure) parts.push("Secure");
|
|
155
|
+
return parts.join("; ");
|
|
156
|
+
}
|
|
157
|
+
function readCookie(header, name) {
|
|
158
|
+
if (!header) return void 0;
|
|
159
|
+
for (const part of header.split(";")) {
|
|
160
|
+
const eq = part.indexOf("=");
|
|
161
|
+
if (eq === -1) continue;
|
|
162
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
163
|
+
}
|
|
164
|
+
return void 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/embedded.ts
|
|
168
|
+
var EMBEDDED_FLOW_MAX_AGE = 600;
|
|
169
|
+
function sealEmbeddedFlow(flow, secret, options = {}) {
|
|
170
|
+
return signValue(JSON.stringify(flow), secret, { maxAgeSeconds: EMBEDDED_FLOW_MAX_AGE, ...options });
|
|
171
|
+
}
|
|
172
|
+
async function openEmbeddedFlow(raw, secret, options = {}) {
|
|
173
|
+
if (!raw) return { ok: false, reason: "absent" };
|
|
174
|
+
if (await verifyValue(raw, secret) === null) return { ok: false, reason: "unreadable" };
|
|
175
|
+
const fresh = await verifyValue(raw, secret, { maxAgeSeconds: EMBEDDED_FLOW_MAX_AGE, ...options });
|
|
176
|
+
if (fresh === null) return { ok: false, reason: "expired" };
|
|
177
|
+
try {
|
|
178
|
+
const f = JSON.parse(fresh);
|
|
179
|
+
if (typeof f.flowId !== "string" || typeof f.codeVerifier !== "string" || typeof f.nonce !== "string" || typeof f.email !== "string") return { ok: false, reason: "unreadable" };
|
|
180
|
+
return { ok: true, flow: f };
|
|
181
|
+
} catch {
|
|
182
|
+
return { ok: false, reason: "unreadable" };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function embeddedError(status, body) {
|
|
186
|
+
const code = typeof body.error === "string" ? body.error : "";
|
|
187
|
+
const num = (v) => typeof v === "number" ? v : void 0;
|
|
188
|
+
if (status === 400 && code === "invalid_code") return { kind: "invalid_code", ...num(body.attempts_left) !== void 0 ? { attemptsLeft: num(body.attempts_left) } : {} };
|
|
189
|
+
if (status === 400 && code === "expired") return { kind: "expired" };
|
|
190
|
+
if (status === 400 && code === "terms_outdated") return { kind: "terms_outdated", ...typeof body.version === "string" ? { version: body.version } : {} };
|
|
191
|
+
if (status === 400) return { kind: "invalid_request" };
|
|
192
|
+
if (status === 403 && code === "signup_not_enabled") return { kind: "signup_not_enabled" };
|
|
193
|
+
if (status === 403 || status === 404 || status === 405) return { kind: "not_embedded" };
|
|
194
|
+
if (status === 429) return { kind: code === "locked" ? "locked" : "too_many_requests", ...num(body.retry_after) !== void 0 ? { retryAfter: num(body.retry_after) } : {} };
|
|
195
|
+
return { kind: "unavailable", message: `BID answered ${status}${code ? ` (${code})` : ""}` };
|
|
196
|
+
}
|
|
76
197
|
var JwksError = class extends Error {
|
|
77
198
|
constructor(message) {
|
|
78
199
|
super(message);
|
|
@@ -158,7 +279,7 @@ function createJwksCache(options) {
|
|
|
158
279
|
}
|
|
159
280
|
if (!jwk) {
|
|
160
281
|
throw new JwksUnknownKeyError(
|
|
161
|
-
`
|
|
282
|
+
`BID does not publish a signing key with kid ${kid}. The token was not signed by this issuer.`
|
|
162
283
|
);
|
|
163
284
|
}
|
|
164
285
|
return jose.importJWK(jwk, alg);
|
|
@@ -226,15 +347,15 @@ var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
|
226
347
|
function mediaType(contentType) {
|
|
227
348
|
return (contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
228
349
|
}
|
|
229
|
-
var
|
|
350
|
+
var b64url2 = (bytes) => {
|
|
230
351
|
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
231
352
|
let s = "";
|
|
232
353
|
for (const b of view) s += String.fromCharCode(b);
|
|
233
354
|
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
234
355
|
};
|
|
235
|
-
var randomToken = (bytes = 32) =>
|
|
356
|
+
var randomToken = (bytes = 32) => b64url2(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
236
357
|
async function challengeFor(verifier) {
|
|
237
|
-
return
|
|
358
|
+
return b64url2(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
238
359
|
}
|
|
239
360
|
var ADDRESS_OWNERSHIP = /* @__PURE__ */ new Set(["verified", "unverified", "not_on_account"]);
|
|
240
361
|
var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
|
|
@@ -554,9 +675,133 @@ function createSsoClient(config, options = {}) {
|
|
|
554
675
|
if (options2.cacheMs && options2.cacheMs > 0) introspectCache.set(key, { result, until: Math.min(now() + options2.cacheMs, exp * 1e3) });
|
|
555
676
|
return result;
|
|
556
677
|
}
|
|
678
|
+
async function exchangeCode(code, codeVerifier, nonce) {
|
|
679
|
+
const { token_endpoint } = await discovery();
|
|
680
|
+
const res = await fetchImpl(token_endpoint, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
683
|
+
body: new URLSearchParams({
|
|
684
|
+
grant_type: "authorization_code",
|
|
685
|
+
code,
|
|
686
|
+
redirect_uri: config.redirectUri,
|
|
687
|
+
client_id: config.clientId,
|
|
688
|
+
// ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two
|
|
689
|
+
// prove different things, so a confidential client keeps PKCE.
|
|
690
|
+
code_verifier: codeVerifier,
|
|
691
|
+
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
692
|
+
})
|
|
693
|
+
});
|
|
694
|
+
const raw = await res.text();
|
|
695
|
+
let body;
|
|
696
|
+
try {
|
|
697
|
+
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
698
|
+
} catch {
|
|
699
|
+
throw new SsoError(
|
|
700
|
+
`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)}`
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (!res.ok || !body.id_token) {
|
|
704
|
+
throw new SsoError(
|
|
705
|
+
`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)
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
const claims = await verifyIdToken(body.id_token, { nonce });
|
|
709
|
+
const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
|
|
710
|
+
return {
|
|
711
|
+
claims: merged.claims,
|
|
712
|
+
unverifiedClaims: merged.unverifiedClaims,
|
|
713
|
+
idToken: body.id_token,
|
|
714
|
+
...body.access_token ? { accessToken: body.access_token } : {},
|
|
715
|
+
...body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
716
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
async function embeddedCall(method, path, body) {
|
|
720
|
+
if (!config.clientSecret) throw new SsoError("the embedded login runs from the server as a confidential client \u2014 set clientSecret (SSO_CLIENT_SECRET)");
|
|
721
|
+
const url = new URL(`/embedded${path}`, config.issuer).toString();
|
|
722
|
+
let res;
|
|
723
|
+
try {
|
|
724
|
+
res = await fetchImpl(url, {
|
|
725
|
+
method,
|
|
726
|
+
headers: {
|
|
727
|
+
authorization: `Basic ${btoa(`${config.clientId}:${config.clientSecret}`)}`,
|
|
728
|
+
accept: "application/json",
|
|
729
|
+
...body ? { "content-type": "application/json" } : {}
|
|
730
|
+
},
|
|
731
|
+
...body ? { body: JSON.stringify({ client_id: config.clientId, ...body }) } : {}
|
|
732
|
+
});
|
|
733
|
+
} catch (e) {
|
|
734
|
+
return { ok: false, status: 0, body: { error: "network", message: e instanceof Error ? e.message : String(e) } };
|
|
735
|
+
}
|
|
736
|
+
const raw = await res.text();
|
|
737
|
+
let json = {};
|
|
738
|
+
try {
|
|
739
|
+
json = raw.trim() ? JSON.parse(raw) : {};
|
|
740
|
+
} catch {
|
|
741
|
+
}
|
|
742
|
+
if (res.status === 401) {
|
|
743
|
+
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`);
|
|
744
|
+
}
|
|
745
|
+
return res.ok ? { ok: true, body: json } : { ok: false, status: res.status, body: json };
|
|
746
|
+
}
|
|
747
|
+
const toError = (r) => r.status === 0 ? { kind: "unavailable", message: `BID did not answer: ${String(r.body.message ?? "")}` } : embeddedError(r.status, r.body);
|
|
748
|
+
async function embeddedNext(r, flow) {
|
|
749
|
+
if (!r.ok) return { kind: "error", error: toError(r) };
|
|
750
|
+
if (r.body.next === "done" && typeof r.body.code === "string") {
|
|
751
|
+
return { kind: "done", result: await exchangeCode(r.body.code, flow.codeVerifier, flow.nonce) };
|
|
752
|
+
}
|
|
753
|
+
if (r.body.next === "second_factor") {
|
|
754
|
+
const methods = (Array.isArray(r.body.methods) ? r.body.methods : []).filter((m) => m === "totp" || m === "sms");
|
|
755
|
+
return { kind: "second_factor", methods };
|
|
756
|
+
}
|
|
757
|
+
return { kind: "error", error: { kind: "unavailable", message: "BID's answer names no next step" } };
|
|
758
|
+
}
|
|
759
|
+
const embedded = {
|
|
760
|
+
async start(input) {
|
|
761
|
+
const codeVerifier = randomToken();
|
|
762
|
+
const nonce = randomToken(16);
|
|
763
|
+
const r = await embeddedCall("POST", "/start", {
|
|
764
|
+
email: input.email,
|
|
765
|
+
code_challenge: await challengeFor(codeVerifier),
|
|
766
|
+
code_challenge_method: "S256",
|
|
767
|
+
nonce,
|
|
768
|
+
...input.intent === "signup" ? { intent: "signup", name: input.name, terms_version: input.termsVersion } : {},
|
|
769
|
+
...input.endUserIp ? { end_user_ip: input.endUserIp } : {}
|
|
770
|
+
});
|
|
771
|
+
if (!r.ok) return { ok: false, error: toError(r) };
|
|
772
|
+
if (typeof r.body.flow_id !== "string") return { ok: false, error: { kind: "unavailable", message: "BID's answer has no flow_id" } };
|
|
773
|
+
return {
|
|
774
|
+
ok: true,
|
|
775
|
+
flow: { flowId: r.body.flow_id, codeVerifier, nonce, email: input.email },
|
|
776
|
+
expiresIn: typeof r.body.expires_in === "number" ? r.body.expires_in : 600
|
|
777
|
+
};
|
|
778
|
+
},
|
|
779
|
+
async verify(flow, code) {
|
|
780
|
+
return embeddedNext(await embeddedCall("POST", "/verify", { flow_id: flow.flowId, code }), flow);
|
|
781
|
+
},
|
|
782
|
+
async sendSms(flow) {
|
|
783
|
+
const r = await embeddedCall("POST", "/second-factor/sms", { flow_id: flow.flowId });
|
|
784
|
+
return r.ok ? { ok: true } : { ok: false, error: toError(r) };
|
|
785
|
+
},
|
|
786
|
+
async verifySecondFactor(flow, method, code) {
|
|
787
|
+
return embeddedNext(await embeddedCall("POST", "/second-factor", { flow_id: flow.flowId, method, code }), flow);
|
|
788
|
+
},
|
|
789
|
+
async terms(lang) {
|
|
790
|
+
const r = await embeddedCall("GET", `/terms?lang=${lang}`);
|
|
791
|
+
const b = r.body;
|
|
792
|
+
if (!r.ok || typeof b.version !== "string" || typeof b.text !== "string") {
|
|
793
|
+
throw new SsoError(`BID did not answer the signup terms (${r.ok ? "no version/text" : r.status})`);
|
|
794
|
+
}
|
|
795
|
+
const links = (Array.isArray(b.links) ? b.links : []).filter(
|
|
796
|
+
(l) => typeof l === "object" && l !== null && typeof l.label === "string" && typeof l.url === "string"
|
|
797
|
+
);
|
|
798
|
+
return { version: b.version, text: b.text, links: links.map((l) => ({ label: l.label, url: l.url })) };
|
|
799
|
+
}
|
|
800
|
+
};
|
|
557
801
|
return {
|
|
558
802
|
discovery,
|
|
559
803
|
introspectToken,
|
|
804
|
+
embedded,
|
|
560
805
|
get jwks() {
|
|
561
806
|
if (!jwksCache) throw new SsoError("the key cache is created on first verification");
|
|
562
807
|
return jwksCache;
|
|
@@ -584,7 +829,7 @@ function createSsoClient(config, options = {}) {
|
|
|
584
829
|
const error = params.get("error");
|
|
585
830
|
if (error) {
|
|
586
831
|
throw new SsoError(
|
|
587
|
-
`
|
|
832
|
+
`BID refused this login: ${error}` + (params.get("error_description") ? ` \u2014 ${params.get("error_description")}` : "")
|
|
588
833
|
);
|
|
589
834
|
}
|
|
590
835
|
const returned = params.get("state");
|
|
@@ -595,45 +840,7 @@ function createSsoClient(config, options = {}) {
|
|
|
595
840
|
}
|
|
596
841
|
const code = params.get("code");
|
|
597
842
|
if (!code) throw new SsoError("callback carried neither an error nor a code");
|
|
598
|
-
|
|
599
|
-
const res = await fetchImpl(token_endpoint, {
|
|
600
|
-
method: "POST",
|
|
601
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
602
|
-
body: new URLSearchParams({
|
|
603
|
-
grant_type: "authorization_code",
|
|
604
|
-
code,
|
|
605
|
-
redirect_uri: config.redirectUri,
|
|
606
|
-
client_id: config.clientId,
|
|
607
|
-
// ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two
|
|
608
|
-
// prove different things, so a confidential client keeps PKCE.
|
|
609
|
-
code_verifier: codeVerifier,
|
|
610
|
-
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
611
|
-
})
|
|
612
|
-
});
|
|
613
|
-
const raw = await res.text();
|
|
614
|
-
let body;
|
|
615
|
-
try {
|
|
616
|
-
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
617
|
-
} catch {
|
|
618
|
-
throw new SsoError(
|
|
619
|
-
`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)}`
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
if (!res.ok || !body.id_token) {
|
|
623
|
-
throw new SsoError(
|
|
624
|
-
`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)
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
const claims = await verifyIdToken(body.id_token, { nonce });
|
|
628
|
-
const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
|
|
629
|
-
return {
|
|
630
|
-
claims: merged.claims,
|
|
631
|
-
unverifiedClaims: merged.unverifiedClaims,
|
|
632
|
-
idToken: body.id_token,
|
|
633
|
-
...body.access_token ? { accessToken: body.access_token } : {},
|
|
634
|
-
...body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
635
|
-
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
636
|
-
};
|
|
843
|
+
return exchangeCode(code, codeVerifier, nonce);
|
|
637
844
|
},
|
|
638
845
|
async refreshTokens(refreshToken) {
|
|
639
846
|
const { token_endpoint } = await discovery();
|
|
@@ -750,96 +957,6 @@ function createSsoClient(config, options = {}) {
|
|
|
750
957
|
};
|
|
751
958
|
}
|
|
752
959
|
|
|
753
|
-
// src/session.ts
|
|
754
|
-
var enc = new TextEncoder();
|
|
755
|
-
var b64url2 = (bytes) => {
|
|
756
|
-
let s = "";
|
|
757
|
-
for (const b of bytes) s += String.fromCharCode(b);
|
|
758
|
-
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
759
|
-
};
|
|
760
|
-
var fromB64url = (s) => {
|
|
761
|
-
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
762
|
-
const bin = atob(pad + "=".repeat((4 - pad.length % 4) % 4));
|
|
763
|
-
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
764
|
-
};
|
|
765
|
-
async function hmacKey(secret) {
|
|
766
|
-
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
767
|
-
"sign",
|
|
768
|
-
"verify"
|
|
769
|
-
]);
|
|
770
|
-
}
|
|
771
|
-
async function signValue(value, secret, options = {}) {
|
|
772
|
-
const payload = b64url2(enc.encode(value));
|
|
773
|
-
const body = options.maxAgeSeconds === void 0 ? payload : `t${Math.floor((options.now ?? Date.now)() / 1e3)}~${payload}`;
|
|
774
|
-
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
|
|
775
|
-
return `${body}.${b64url2(sig)}`;
|
|
776
|
-
}
|
|
777
|
-
var STAMPED = /^t(\d+)~(.*)$/s;
|
|
778
|
-
async function verifyValue(token, secret, options = {}) {
|
|
779
|
-
if (!token) return null;
|
|
780
|
-
const dot = token.lastIndexOf(".");
|
|
781
|
-
if (dot <= 0) return null;
|
|
782
|
-
const body = token.slice(0, dot);
|
|
783
|
-
try {
|
|
784
|
-
const ok = await crypto.subtle.verify(
|
|
785
|
-
"HMAC",
|
|
786
|
-
await hmacKey(secret),
|
|
787
|
-
fromB64url(token.slice(dot + 1)),
|
|
788
|
-
enc.encode(body)
|
|
789
|
-
);
|
|
790
|
-
if (!ok) return null;
|
|
791
|
-
const stamped = STAMPED.exec(body);
|
|
792
|
-
if (options.maxAgeSeconds !== void 0) {
|
|
793
|
-
if (!stamped) return null;
|
|
794
|
-
const issuedAt = Number(stamped[1]);
|
|
795
|
-
if ((options.now ?? Date.now)() / 1e3 - issuedAt > options.maxAgeSeconds) return null;
|
|
796
|
-
}
|
|
797
|
-
return new TextDecoder().decode(fromB64url(stamped?.[2] ?? body));
|
|
798
|
-
} catch {
|
|
799
|
-
return null;
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
async function signSession(payload, secret) {
|
|
803
|
-
return signValue(JSON.stringify(payload), secret);
|
|
804
|
-
}
|
|
805
|
-
async function verifySession(token, secret, now = Date.now) {
|
|
806
|
-
const body = await verifyValue(token, secret);
|
|
807
|
-
if (body === null) return null;
|
|
808
|
-
try {
|
|
809
|
-
const payload = JSON.parse(body);
|
|
810
|
-
if (typeof payload.sub !== "string" || payload.sub === "") return null;
|
|
811
|
-
if (typeof payload.exp !== "number" || payload.exp * 1e3 <= now()) return null;
|
|
812
|
-
return payload;
|
|
813
|
-
} catch {
|
|
814
|
-
return null;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
function cookieHeader(name, value, opts) {
|
|
818
|
-
const parts = [
|
|
819
|
-
`${name}=${value}`,
|
|
820
|
-
`Path=${opts.path ?? "/"}`,
|
|
821
|
-
`Max-Age=${opts.maxAge}`,
|
|
822
|
-
"HttpOnly",
|
|
823
|
-
// Lax, NOT Strict, and this is load-bearing: the callback from BrobergID
|
|
824
|
-
// is a top-level GET navigation from another site. Strict withholds the
|
|
825
|
-
// cookie on exactly that navigation, so the login transaction cookie would
|
|
826
|
-
// be missing when it is needed and every sign-in would fail with "state
|
|
827
|
-
// does not match" — a message that sends you looking in the wrong place.
|
|
828
|
-
`SameSite=${opts.sameSite ?? "Lax"}`
|
|
829
|
-
];
|
|
830
|
-
if (opts.secure) parts.push("Secure");
|
|
831
|
-
return parts.join("; ");
|
|
832
|
-
}
|
|
833
|
-
function readCookie(header, name) {
|
|
834
|
-
if (!header) return void 0;
|
|
835
|
-
for (const part of header.split(";")) {
|
|
836
|
-
const eq = part.indexOf("=");
|
|
837
|
-
if (eq === -1) continue;
|
|
838
|
-
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
839
|
-
}
|
|
840
|
-
return void 0;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
960
|
// src/tokens.ts
|
|
844
961
|
var warned = false;
|
|
845
962
|
function memoryTokenStore(options = {}) {
|
|
@@ -890,6 +1007,7 @@ function newSid() {
|
|
|
890
1007
|
exports.ALLOWED_ALGS = ALLOWED_ALGS;
|
|
891
1008
|
exports.AVATAR_TYPES = AVATAR_TYPES;
|
|
892
1009
|
exports.DEFAULT_SESSION_MAX_AGE = DEFAULT_SESSION_MAX_AGE;
|
|
1010
|
+
exports.EMBEDDED_FLOW_MAX_AGE = EMBEDDED_FLOW_MAX_AGE;
|
|
893
1011
|
exports.JwksError = JwksError;
|
|
894
1012
|
exports.JwksUnavailableError = JwksUnavailableError;
|
|
895
1013
|
exports.JwksUnknownKeyError = JwksUnknownKeyError;
|
|
@@ -911,10 +1029,12 @@ exports.loadSsoConfig = loadSsoConfig;
|
|
|
911
1029
|
exports.mediaType = mediaType;
|
|
912
1030
|
exports.memoryTokenStore = memoryTokenStore;
|
|
913
1031
|
exports.newSid = newSid;
|
|
1032
|
+
exports.openEmbeddedFlow = openEmbeddedFlow;
|
|
914
1033
|
exports.readCookie = readCookie;
|
|
1034
|
+
exports.sealEmbeddedFlow = sealEmbeddedFlow;
|
|
915
1035
|
exports.signSession = signSession;
|
|
916
1036
|
exports.signValue = signValue;
|
|
917
1037
|
exports.verifySession = verifySession;
|
|
918
1038
|
exports.verifyValue = verifyValue;
|
|
919
|
-
//# sourceMappingURL=chunk-
|
|
920
|
-
//# sourceMappingURL=chunk-
|
|
1039
|
+
//# sourceMappingURL=chunk-MZE4TPFU.cjs.map
|
|
1040
|
+
//# sourceMappingURL=chunk-MZE4TPFU.cjs.map
|