@broberg/sso 0.13.0 → 0.14.2
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 +18 -0
- package/dist/chunk-JGIO7XPR.cjs +833 -0
- package/dist/chunk-JGIO7XPR.cjs.map +1 -0
- package/dist/chunk-ZPRWYUFG.js +805 -0
- package/dist/chunk-ZPRWYUFG.js.map +1 -0
- package/dist/hono.cjs +37 -815
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +2 -386
- package/dist/hono.d.ts +2 -386
- package/dist/hono.js +1 -779
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +225 -794
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -569
- package/dist/index.d.ts +39 -569
- package/dist/index.js +112 -755
- package/dist/index.js.map +1 -1
- package/dist/session-DsjOyCJb.d.cts +580 -0
- package/dist/session-DsjOyCJb.d.ts +580 -0
- package/package.json +1 -1
package/dist/hono.cjs
CHANGED
|
@@ -1,786 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var chunkJGIO7XPR_cjs = require('./chunk-JGIO7XPR.cjs');
|
|
3
4
|
var hono = require('hono');
|
|
4
|
-
var jose = require('jose');
|
|
5
5
|
|
|
6
|
-
// src/hono.ts
|
|
7
|
-
|
|
8
|
-
// src/config.ts
|
|
9
|
-
var SsoConfigError = class extends Error {
|
|
10
|
-
constructor(message) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.name = "SsoConfigError";
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
function required(env, name, hint) {
|
|
16
|
-
const raw = env[name];
|
|
17
|
-
if (typeof raw !== "string" || raw.trim() === "") {
|
|
18
|
-
throw new SsoConfigError(`${name} is not set (or is blank). ${hint}`);
|
|
19
|
-
}
|
|
20
|
-
return raw.trim();
|
|
21
|
-
}
|
|
22
|
-
var DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 7;
|
|
23
|
-
function loadSsoConfig(env = process.env) {
|
|
24
|
-
const rawIssuer = required(
|
|
25
|
-
env,
|
|
26
|
-
"BID_ISSUER",
|
|
27
|
-
"It is Broberg ID's bare origin, e.g. https://id.broberg.ai"
|
|
28
|
-
);
|
|
29
|
-
let issuer;
|
|
30
|
-
try {
|
|
31
|
-
const url = new URL(rawIssuer);
|
|
32
|
-
if (url.protocol !== "https:" && url.hostname !== "localhost") {
|
|
33
|
-
throw new SsoConfigError(
|
|
34
|
-
`BID_ISSUER must be https (got ${url.protocol}//). Only localhost may be http.`
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
issuer = url.origin;
|
|
38
|
-
} catch (err) {
|
|
39
|
-
if (err instanceof SsoConfigError) throw err;
|
|
40
|
-
throw new SsoConfigError(`BID_ISSUER is not a valid URL: ${rawIssuer}`);
|
|
41
|
-
}
|
|
42
|
-
const cookieSecret = required(
|
|
43
|
-
env,
|
|
44
|
-
"SSO_COOKIE_SECRET",
|
|
45
|
-
"Generate one with `openssl rand -hex 32`. It signs this app's session cookie."
|
|
46
|
-
);
|
|
47
|
-
if (cookieSecret.length < 32) {
|
|
48
|
-
throw new SsoConfigError(
|
|
49
|
-
`SSO_COOKIE_SECRET is ${cookieSecret.length} characters; it must be at least 32. A short secret is a forgeable session, and a forged session is any user you like.`
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
const rawMaxAge = env.SSO_SESSION_MAX_AGE?.trim();
|
|
53
|
-
let sessionMaxAge = DEFAULT_SESSION_MAX_AGE;
|
|
54
|
-
if (rawMaxAge) {
|
|
55
|
-
const n = Number(rawMaxAge);
|
|
56
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
57
|
-
throw new SsoConfigError(
|
|
58
|
-
`SSO_SESSION_MAX_AGE must be a positive number of seconds (got ${JSON.stringify(rawMaxAge)}).`
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
sessionMaxAge = Math.floor(n);
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
issuer,
|
|
65
|
-
clientId: required(env, "SSO_CLIENT_ID", "The id this app is registered under in Broberg ID."),
|
|
66
|
-
redirectUri: required(
|
|
67
|
-
env,
|
|
68
|
-
"SSO_REDIRECT_URI",
|
|
69
|
-
"Must match the registered redirect EXACTLY \u2014 one trailing slash is a different address."
|
|
70
|
-
),
|
|
71
|
-
scopes: (env.SSO_SCOPES?.trim() || "openid profile email").split(/\s+/),
|
|
72
|
-
cookieSecret,
|
|
73
|
-
cookieName: env.SSO_COOKIE_NAME?.trim() || "bid_session",
|
|
74
|
-
sessionMaxAge,
|
|
75
|
-
postLogoutRedirectUri: env.SSO_POST_LOGOUT_REDIRECT_URI?.trim() || void 0,
|
|
76
|
-
clientSecret: env.SSO_CLIENT_SECRET?.trim() || void 0
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
var JwksError = class extends Error {
|
|
80
|
-
constructor(message) {
|
|
81
|
-
super(message);
|
|
82
|
-
this.name = "JwksError";
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
var JwksUnavailableError = class extends JwksError {
|
|
86
|
-
constructor(message) {
|
|
87
|
-
super(message);
|
|
88
|
-
this.name = "JwksUnavailableError";
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
var JwksUnknownKeyError = class extends JwksError {
|
|
92
|
-
constructor(message) {
|
|
93
|
-
super(message);
|
|
94
|
-
this.name = "JwksUnknownKeyError";
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
function createJwksCache(options) {
|
|
98
|
-
const {
|
|
99
|
-
jwksUri,
|
|
100
|
-
minRefetchIntervalMs = 1e4,
|
|
101
|
-
warmUp = false,
|
|
102
|
-
fetchImpl = fetch,
|
|
103
|
-
now = () => Date.now()
|
|
104
|
-
} = options;
|
|
105
|
-
let keys = [];
|
|
106
|
-
let lastFetchAt = -Infinity;
|
|
107
|
-
let fetchCount = 0;
|
|
108
|
-
let inFlight = null;
|
|
109
|
-
async function refresh() {
|
|
110
|
-
if (inFlight) return inFlight;
|
|
111
|
-
inFlight = (async () => {
|
|
112
|
-
let res;
|
|
113
|
-
try {
|
|
114
|
-
res = await fetchImpl(jwksUri);
|
|
115
|
-
} catch (cause) {
|
|
116
|
-
throw new JwksUnavailableError(
|
|
117
|
-
`${jwksUri} could not be reached: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
if (!res.ok) {
|
|
121
|
-
throw new JwksUnavailableError(
|
|
122
|
-
`${jwksUri} answered ${res.status} \u2014 cannot verify any token right now`
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
let body;
|
|
126
|
-
try {
|
|
127
|
-
body = await res.json();
|
|
128
|
-
} catch {
|
|
129
|
-
throw new JwksUnavailableError(`${jwksUri} answered 200 with a body that is not JSON`);
|
|
130
|
-
}
|
|
131
|
-
if (!Array.isArray(body?.keys)) {
|
|
132
|
-
throw new JwksUnavailableError(`${jwksUri} returned no "keys" array`);
|
|
133
|
-
}
|
|
134
|
-
keys = body.keys;
|
|
135
|
-
lastFetchAt = now();
|
|
136
|
-
fetchCount++;
|
|
137
|
-
})().finally(() => {
|
|
138
|
-
inFlight = null;
|
|
139
|
-
});
|
|
140
|
-
return inFlight;
|
|
141
|
-
}
|
|
142
|
-
if (warmUp) {
|
|
143
|
-
void refresh().catch(() => {
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
return {
|
|
147
|
-
get fetchCount() {
|
|
148
|
-
return fetchCount;
|
|
149
|
-
},
|
|
150
|
-
async getKey(kid, alg) {
|
|
151
|
-
let jwk = keys.find((k) => k.kid === kid);
|
|
152
|
-
if (!jwk) {
|
|
153
|
-
const sinceLast = now() - lastFetchAt;
|
|
154
|
-
if (sinceLast < minRefetchIntervalMs) {
|
|
155
|
-
throw new JwksUnavailableError(
|
|
156
|
-
`kid ${kid} is not in the key set we hold, fetched ${sinceLast}ms ago (we do not ask the issuer again within ${minRefetchIntervalMs}ms). A key set this fresh that lacks the kid means the token was not signed by this issuer \u2014 reject it. Only a key rotation within the last ${minRefetchIntervalMs}ms could make that wrong.`
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
await refresh();
|
|
160
|
-
jwk = keys.find((k) => k.kid === kid);
|
|
161
|
-
}
|
|
162
|
-
if (!jwk) {
|
|
163
|
-
throw new JwksUnknownKeyError(
|
|
164
|
-
`Broberg ID does not publish a signing key with kid ${kid}. The token was not signed by this issuer.`
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
return jose.importJWK(jwk, alg);
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// src/client.ts
|
|
173
|
-
var SsoError = class extends Error {
|
|
174
|
-
constructor(message) {
|
|
175
|
-
super(message);
|
|
176
|
-
this.name = "SsoError";
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
var SsoReauthError = class extends SsoError {
|
|
180
|
-
constructor(message, reason) {
|
|
181
|
-
super(message);
|
|
182
|
-
this.reason = reason;
|
|
183
|
-
this.name = "SsoReauthError";
|
|
184
|
-
}
|
|
185
|
-
reason;
|
|
186
|
-
};
|
|
187
|
-
var SsoAppApiError = class extends SsoError {
|
|
188
|
-
constructor(message, status, code) {
|
|
189
|
-
super(message);
|
|
190
|
-
this.status = status;
|
|
191
|
-
this.code = code;
|
|
192
|
-
this.name = "SsoAppApiError";
|
|
193
|
-
}
|
|
194
|
-
status;
|
|
195
|
-
code;
|
|
196
|
-
};
|
|
197
|
-
var SsoInsufficientScopeError = class extends SsoAppApiError {
|
|
198
|
-
constructor(message, scope) {
|
|
199
|
-
super(message, 403, "insufficient_scope");
|
|
200
|
-
this.scope = scope;
|
|
201
|
-
this.name = "SsoInsufficientScopeError";
|
|
202
|
-
}
|
|
203
|
-
scope;
|
|
204
|
-
};
|
|
205
|
-
var SsoAvatarRejectedError = class extends SsoError {
|
|
206
|
-
constructor(message, reason) {
|
|
207
|
-
super(message);
|
|
208
|
-
this.reason = reason;
|
|
209
|
-
this.name = "SsoAvatarRejectedError";
|
|
210
|
-
}
|
|
211
|
-
reason;
|
|
212
|
-
};
|
|
213
|
-
var MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
|
214
|
-
var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
215
|
-
function mediaType(contentType) {
|
|
216
|
-
return (contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
217
|
-
}
|
|
218
|
-
var b64url = (bytes) => {
|
|
219
|
-
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
220
|
-
let s = "";
|
|
221
|
-
for (const b of view) s += String.fromCharCode(b);
|
|
222
|
-
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
223
|
-
};
|
|
224
|
-
var randomToken = (bytes = 32) => b64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
225
|
-
async function challengeFor(verifier) {
|
|
226
|
-
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
227
|
-
}
|
|
228
|
-
var ADDRESS_OWNERSHIP = /* @__PURE__ */ new Set(["verified", "unverified", "not_on_account"]);
|
|
229
|
-
var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
|
|
230
|
-
"invited",
|
|
231
|
-
"already_invited",
|
|
232
|
-
"existing",
|
|
233
|
-
"invalid",
|
|
234
|
-
"too_soon",
|
|
235
|
-
"too_many_today",
|
|
236
|
-
"send_failed"
|
|
237
|
-
]);
|
|
238
|
-
var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
|
|
239
|
-
var BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
240
|
-
var MAX_INVITATIONS = 500;
|
|
241
|
-
function excerptForLog(raw, clientSecret) {
|
|
242
|
-
let text = raw.replace(/\s+/g, " ").trim();
|
|
243
|
-
if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
|
|
244
|
-
if (text === "") return "(empty body)";
|
|
245
|
-
return text.length > 200 ? `${text.slice(0, 200)}\u2026` : text;
|
|
246
|
-
}
|
|
247
|
-
function invalidClientHint(error, weSentASecret) {
|
|
248
|
-
if (error !== "invalid_client") return "";
|
|
249
|
-
return weSentASecret ? " \u2014 we DID send a client_secret, so either it is wrong or this client is registered as PUBLIC (in which case unset SSO_CLIENT_SECRET)." : " \u2014 we sent NO client_secret, so this client is likely registered as CONFIDENTIAL (set SSO_CLIENT_SECRET to the value it was registered with).";
|
|
250
|
-
}
|
|
251
|
-
var ALLOWED_ALGS = [
|
|
252
|
-
// FIRST, because it is what Broberg ID actually signs with. Measured on the
|
|
253
|
-
// live issuer 2026-09-20:
|
|
254
|
-
// /jwks kid=Iodh… kty=OKP alg=EdDSA crv=Ed25519
|
|
255
|
-
// discovery id_token_signing_alg_values_supported: ["EdDSA"]
|
|
256
|
-
//
|
|
257
|
-
// 0.2.1 SHIPPED THIS LIST WITHOUT IT and rejected every real token for an
|
|
258
|
-
// hour. The list was written from what an OIDC client usually allows rather
|
|
259
|
-
// than from what our issuer uses — and the whole suite missed it because the
|
|
260
|
-
// fake issuer in the tests signs RS256. A fake agrees with whoever wrote it.
|
|
261
|
-
// There is now a test that mints EdDSA, the algorithm production uses.
|
|
262
|
-
"EdDSA",
|
|
263
|
-
"RS256",
|
|
264
|
-
"RS384",
|
|
265
|
-
"RS512",
|
|
266
|
-
"PS256",
|
|
267
|
-
"PS384",
|
|
268
|
-
"PS512",
|
|
269
|
-
"ES256",
|
|
270
|
-
"ES384",
|
|
271
|
-
"ES512"
|
|
272
|
-
];
|
|
273
|
-
function createSsoClient(config, options = {}) {
|
|
274
|
-
const fetchImpl = options.fetchImpl ?? fetch;
|
|
275
|
-
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
276
|
-
async function appPost(path, bearer, payload, what) {
|
|
277
|
-
const url = new URL(path, config.issuer).toString();
|
|
278
|
-
const ctrl = new AbortController();
|
|
279
|
-
let timer;
|
|
280
|
-
const timedOut = new Promise((_, reject) => {
|
|
281
|
-
timer = setTimeout(() => {
|
|
282
|
-
ctrl.abort();
|
|
283
|
-
reject(new SsoError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`));
|
|
284
|
-
}, timeoutMs);
|
|
285
|
-
});
|
|
286
|
-
try {
|
|
287
|
-
const res = await Promise.race([
|
|
288
|
-
fetchImpl(url, {
|
|
289
|
-
method: "POST",
|
|
290
|
-
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
291
|
-
body: JSON.stringify(payload),
|
|
292
|
-
signal: ctrl.signal
|
|
293
|
-
}),
|
|
294
|
-
timedOut
|
|
295
|
-
]);
|
|
296
|
-
if (!res.ok) {
|
|
297
|
-
const detail = await res.text().catch(() => "");
|
|
298
|
-
const hint = detail.includes("invalid_app_key") ? " (invalid_app_key: this app's bidk_ key was refused)" : "";
|
|
299
|
-
throw new SsoError(`${url} answered ${res.status}${hint} \u2014 ${what} is unknown.`);
|
|
300
|
-
}
|
|
301
|
-
return await Promise.race([res.json().catch(() => null), timedOut]);
|
|
302
|
-
} catch (e) {
|
|
303
|
-
if (e instanceof SsoError) throw e;
|
|
304
|
-
throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} \u2014 ${what} is unknown.`);
|
|
305
|
-
} finally {
|
|
306
|
-
clearTimeout(timer);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
async function appRequest(path, bearer, init, what) {
|
|
310
|
-
const url = new URL(path, config.issuer).toString();
|
|
311
|
-
const ctrl = new AbortController();
|
|
312
|
-
let timer;
|
|
313
|
-
const timedOut = new Promise((_, reject) => {
|
|
314
|
-
timer = setTimeout(() => {
|
|
315
|
-
reject(new SsoAppApiError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`, null, void 0));
|
|
316
|
-
ctrl.abort();
|
|
317
|
-
}, timeoutMs);
|
|
318
|
-
});
|
|
319
|
-
const headers = { authorization: `Bearer ${bearer}` };
|
|
320
|
-
let body;
|
|
321
|
-
if (init.json !== void 0) {
|
|
322
|
-
headers["content-type"] = "application/json";
|
|
323
|
-
body = JSON.stringify(init.json);
|
|
324
|
-
} else if (init.bytes !== void 0) {
|
|
325
|
-
headers["content-type"] = init.contentType ?? "application/octet-stream";
|
|
326
|
-
body = init.bytes;
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
const res = await Promise.race([
|
|
330
|
-
fetchImpl(url, { method: init.method, headers, ...body !== void 0 ? { body } : {}, signal: ctrl.signal }),
|
|
331
|
-
timedOut
|
|
332
|
-
]);
|
|
333
|
-
const raw = await Promise.race([res.text(), timedOut]);
|
|
334
|
-
let parsed;
|
|
335
|
-
try {
|
|
336
|
-
parsed = raw.trim() === "" ? void 0 : JSON.parse(raw);
|
|
337
|
-
} catch {
|
|
338
|
-
parsed = void 0;
|
|
339
|
-
}
|
|
340
|
-
const fields = parsed && typeof parsed === "object" ? parsed : {};
|
|
341
|
-
const code = typeof fields.error === "string" ? fields.error : void 0;
|
|
342
|
-
if (!res.ok) {
|
|
343
|
-
if (res.status === 403 && code === "insufficient_scope") {
|
|
344
|
-
const scope = typeof fields.scope === "string" && fields.scope ? fields.scope : init.method === "GET" ? "profile" : "profile:write";
|
|
345
|
-
throw new SsoInsufficientScopeError(
|
|
346
|
-
`${url} answered 403 insufficient_scope \u2014 the user's token lacks scope "${scope}"; log in again with it requested.`,
|
|
347
|
-
scope
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
throw new SsoAppApiError(`${url} answered ${res.status}${code ? ` (${code})` : ""} \u2014 ${what} is unknown.`, res.status, code);
|
|
351
|
-
}
|
|
352
|
-
if (parsed === void 0) {
|
|
353
|
-
throw new SsoAppApiError(`${url} answered ${res.status} with a body that is not JSON \u2014 ${what} is unknown.`, res.status, void 0);
|
|
354
|
-
}
|
|
355
|
-
return parsed;
|
|
356
|
-
} catch (e) {
|
|
357
|
-
if (e instanceof SsoError) throw e;
|
|
358
|
-
throw new SsoAppApiError(`${url} could not be reached (${e instanceof Error ? e.name : "error"}) \u2014 ${what} is unknown.`, null, void 0);
|
|
359
|
-
} finally {
|
|
360
|
-
clearTimeout(timer);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
function asProfile(body, path) {
|
|
364
|
-
const b = body ?? {};
|
|
365
|
-
if (typeof b.sub !== "string" || b.sub === "" || typeof b.account_url !== "string") {
|
|
366
|
-
throw new SsoAppApiError(`${path} returned no "sub" or "account_url" \u2014 the profile is unknown.`, 200, void 0);
|
|
367
|
-
}
|
|
368
|
-
const opt = (v) => typeof v === "string" ? v : null;
|
|
369
|
-
return { sub: b.sub, name: opt(b.name), picture: opt(b.picture), email: opt(b.email), account_url: b.account_url };
|
|
370
|
-
}
|
|
371
|
-
let discoveryPromise = null;
|
|
372
|
-
let jwksCache = null;
|
|
373
|
-
async function discovery() {
|
|
374
|
-
discoveryPromise ??= (async () => {
|
|
375
|
-
const url = `${config.issuer}/.well-known/openid-configuration`;
|
|
376
|
-
const res = await fetchImpl(url);
|
|
377
|
-
if (!res.ok) throw new SsoError(`${url} answered ${res.status}`);
|
|
378
|
-
const doc = await res.json();
|
|
379
|
-
const advertised = doc.id_token_signing_alg_values_supported;
|
|
380
|
-
if (Array.isArray(advertised) && advertised.length > 0) {
|
|
381
|
-
const usable = advertised.filter((a) => ALLOWED_ALGS.includes(a));
|
|
382
|
-
if (usable.length === 0) {
|
|
383
|
-
throw new SsoError(
|
|
384
|
-
`${config.issuer} signs ID tokens with ${advertised.join(", ")}, and this client accepts none of those (it allows ${ALLOWED_ALGS.join(", ")}). Every token would be rejected. This is a mismatch between the package and the issuer \u2014 report it rather than working around it.`
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
if (doc.issuer !== config.issuer) {
|
|
389
|
-
throw new SsoError(
|
|
390
|
-
`BID_ISSUER is ${config.issuer} but ${url} says its issuer is ${doc.issuer}. These must match exactly \u2014 tokens are validated against the issuer string.`
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
return doc;
|
|
394
|
-
})().catch((err) => {
|
|
395
|
-
discoveryPromise = null;
|
|
396
|
-
throw err;
|
|
397
|
-
});
|
|
398
|
-
return discoveryPromise;
|
|
399
|
-
}
|
|
400
|
-
async function keys() {
|
|
401
|
-
if (!jwksCache) {
|
|
402
|
-
const { jwks_uri } = await discovery();
|
|
403
|
-
jwksCache = createJwksCache({
|
|
404
|
-
jwksUri: jwks_uri,
|
|
405
|
-
fetchImpl,
|
|
406
|
-
...options.minRefetchIntervalMs !== void 0 ? { minRefetchIntervalMs: options.minRefetchIntervalMs } : {}
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
return jwksCache;
|
|
410
|
-
}
|
|
411
|
-
async function verifyLogoutToken(token) {
|
|
412
|
-
const cache = await keys();
|
|
413
|
-
let header;
|
|
414
|
-
try {
|
|
415
|
-
header = jose.decodeProtectedHeader(token);
|
|
416
|
-
} catch {
|
|
417
|
-
throw new SsoError("logout_token is not a JWS");
|
|
418
|
-
}
|
|
419
|
-
if (!header.kid) throw new SsoError("logout_token has no kid \u2014 cannot pick a signing key");
|
|
420
|
-
let payload;
|
|
421
|
-
try {
|
|
422
|
-
({ payload } = await jose.jwtVerify(token, async () => cache.getKey(header.kid, header.alg ?? "RS256"), {
|
|
423
|
-
issuer: config.issuer,
|
|
424
|
-
audience: config.clientId,
|
|
425
|
-
algorithms: ALLOWED_ALGS,
|
|
426
|
-
typ: "logout+jwt",
|
|
427
|
-
requiredClaims: ["iat", "exp", "jti"]
|
|
428
|
-
}));
|
|
429
|
-
} catch (e) {
|
|
430
|
-
throw new SsoError(`logout_token rejected: ${e instanceof Error ? e.message : String(e)}`);
|
|
431
|
-
}
|
|
432
|
-
const events = payload.events;
|
|
433
|
-
if (!events || typeof events !== "object" || !(BACKCHANNEL_EVENT in events)) {
|
|
434
|
-
throw new SsoError("logout_token has no back-channel logout event");
|
|
435
|
-
}
|
|
436
|
-
if ("nonce" in payload) throw new SsoError("logout_token carries a nonce \u2014 that is an ID token, not a logout token");
|
|
437
|
-
if ("sid" in payload) {
|
|
438
|
-
throw new SsoError("logout_token carries sid \u2014 only sub-wide \xABLog ud overalt\xBB is accepted (D-376ffa)");
|
|
439
|
-
}
|
|
440
|
-
if (typeof payload.sub !== "string" || payload.sub === "") throw new SsoError("logout_token has no sub");
|
|
441
|
-
return { sub: payload.sub, iat: payload.iat, jti: String(payload.jti), exp: payload.exp };
|
|
442
|
-
}
|
|
443
|
-
async function verifyIdToken(idToken, opts = {}) {
|
|
444
|
-
const cache = await keys();
|
|
445
|
-
const header = jose.decodeProtectedHeader(idToken);
|
|
446
|
-
if (!header.kid) throw new SsoError("ID token has no kid \u2014 cannot pick a signing key");
|
|
447
|
-
const { payload } = await jose.jwtVerify(
|
|
448
|
-
idToken,
|
|
449
|
-
async () => cache.getKey(header.kid, header.alg ?? "RS256"),
|
|
450
|
-
{ issuer: config.issuer, audience: config.clientId, algorithms: ALLOWED_ALGS }
|
|
451
|
-
);
|
|
452
|
-
if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) {
|
|
453
|
-
throw new SsoError(
|
|
454
|
-
"ID token nonce does not match this login request \u2014 refusing a token minted for another sign-in."
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
if (typeof payload.sub !== "string" || payload.sub === "") {
|
|
458
|
-
throw new SsoError("ID token has no sub \u2014 there is no user to be");
|
|
459
|
-
}
|
|
460
|
-
return payload;
|
|
461
|
-
}
|
|
462
|
-
async function withUserInfo(claims, accessToken) {
|
|
463
|
-
const doc = await discovery();
|
|
464
|
-
if (!doc.userinfo_endpoint) return { claims, unverifiedClaims: [] };
|
|
465
|
-
const res = await fetchImpl(doc.userinfo_endpoint, {
|
|
466
|
-
headers: { authorization: `Bearer ${accessToken}` }
|
|
467
|
-
});
|
|
468
|
-
if (!res.ok) return { claims, unverifiedClaims: [] };
|
|
469
|
-
const info = await res.json();
|
|
470
|
-
if (info.sub !== claims.sub) {
|
|
471
|
-
throw new SsoError(
|
|
472
|
-
`userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
const unverifiedClaims = Object.keys(info).filter((k) => k !== "sub" && info[k] !== void 0 && !(k in claims)).sort();
|
|
476
|
-
return { claims: { ...info, ...claims, sub: claims.sub }, unverifiedClaims };
|
|
477
|
-
}
|
|
478
|
-
return {
|
|
479
|
-
discovery,
|
|
480
|
-
get jwks() {
|
|
481
|
-
if (!jwksCache) throw new SsoError("the key cache is created on first verification");
|
|
482
|
-
return jwksCache;
|
|
483
|
-
},
|
|
484
|
-
verifyIdToken,
|
|
485
|
-
async beginLogin(opts = {}) {
|
|
486
|
-
const { authorization_endpoint } = await discovery();
|
|
487
|
-
const codeVerifier = randomToken();
|
|
488
|
-
const state = randomToken(16);
|
|
489
|
-
const nonce = randomToken(16);
|
|
490
|
-
const params = new URLSearchParams({
|
|
491
|
-
response_type: "code",
|
|
492
|
-
client_id: config.clientId,
|
|
493
|
-
redirect_uri: config.redirectUri,
|
|
494
|
-
scope: [.../* @__PURE__ */ new Set([...config.scopes, ...opts.scopes ?? []])].join(" "),
|
|
495
|
-
state,
|
|
496
|
-
nonce,
|
|
497
|
-
code_challenge: await challengeFor(codeVerifier),
|
|
498
|
-
code_challenge_method: "S256"
|
|
499
|
-
});
|
|
500
|
-
if (opts.prompt) params.set("prompt", opts.prompt);
|
|
501
|
-
return { url: `${authorization_endpoint}?${params}`, state, codeVerifier, nonce };
|
|
502
|
-
},
|
|
503
|
-
async completeLogin({ params, state, codeVerifier, nonce }) {
|
|
504
|
-
const error = params.get("error");
|
|
505
|
-
if (error) {
|
|
506
|
-
throw new SsoError(
|
|
507
|
-
`Broberg ID refused this login: ${error}` + (params.get("error_description") ? ` \u2014 ${params.get("error_description")}` : "")
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
const returned = params.get("state");
|
|
511
|
-
if (!returned || returned !== state) {
|
|
512
|
-
throw new SsoError(
|
|
513
|
-
"state does not match the login this browser started \u2014 refusing the callback."
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
const code = params.get("code");
|
|
517
|
-
if (!code) throw new SsoError("callback carried neither an error nor a code");
|
|
518
|
-
const { token_endpoint } = await discovery();
|
|
519
|
-
const res = await fetchImpl(token_endpoint, {
|
|
520
|
-
method: "POST",
|
|
521
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
522
|
-
body: new URLSearchParams({
|
|
523
|
-
grant_type: "authorization_code",
|
|
524
|
-
code,
|
|
525
|
-
redirect_uri: config.redirectUri,
|
|
526
|
-
client_id: config.clientId,
|
|
527
|
-
// ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two
|
|
528
|
-
// prove different things, so a confidential client keeps PKCE.
|
|
529
|
-
code_verifier: codeVerifier,
|
|
530
|
-
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
531
|
-
})
|
|
532
|
-
});
|
|
533
|
-
const raw = await res.text();
|
|
534
|
-
let body;
|
|
535
|
-
try {
|
|
536
|
-
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
537
|
-
} catch {
|
|
538
|
-
throw new SsoError(
|
|
539
|
-
`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)}`
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
if (!res.ok || !body.id_token) {
|
|
543
|
-
throw new SsoError(
|
|
544
|
-
`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)
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
const claims = await verifyIdToken(body.id_token, { nonce });
|
|
548
|
-
const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
|
|
549
|
-
return {
|
|
550
|
-
claims: merged.claims,
|
|
551
|
-
unverifiedClaims: merged.unverifiedClaims,
|
|
552
|
-
idToken: body.id_token,
|
|
553
|
-
...body.access_token ? { accessToken: body.access_token } : {},
|
|
554
|
-
...body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
555
|
-
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
556
|
-
};
|
|
557
|
-
},
|
|
558
|
-
async refreshTokens(refreshToken) {
|
|
559
|
-
const { token_endpoint } = await discovery();
|
|
560
|
-
const res = await fetchImpl(token_endpoint, {
|
|
561
|
-
method: "POST",
|
|
562
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
563
|
-
body: new URLSearchParams({
|
|
564
|
-
grant_type: "refresh_token",
|
|
565
|
-
refresh_token: refreshToken,
|
|
566
|
-
client_id: config.clientId,
|
|
567
|
-
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
568
|
-
})
|
|
569
|
-
});
|
|
570
|
-
const raw = await res.text();
|
|
571
|
-
let body;
|
|
572
|
-
try {
|
|
573
|
-
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
574
|
-
} catch {
|
|
575
|
-
throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
|
|
576
|
-
}
|
|
577
|
-
if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
|
|
578
|
-
throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
|
|
579
|
-
}
|
|
580
|
-
return {
|
|
581
|
-
accessToken: body.access_token,
|
|
582
|
-
...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
583
|
-
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
584
|
-
};
|
|
585
|
-
},
|
|
586
|
-
async logoutUrl(opts = {}) {
|
|
587
|
-
const doc = await discovery();
|
|
588
|
-
if (!doc.end_session_endpoint) {
|
|
589
|
-
throw new SsoError(
|
|
590
|
-
`${config.issuer} does not advertise end_session_endpoint \u2014 central logout is unavailable.`
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
|
-
const params = new URLSearchParams();
|
|
594
|
-
if (opts.idTokenHint) params.set("id_token_hint", opts.idTokenHint);
|
|
595
|
-
const post = opts.postLogoutRedirectUri ?? config.postLogoutRedirectUri;
|
|
596
|
-
if (post) params.set("post_logout_redirect_uri", post);
|
|
597
|
-
params.set("client_id", config.clientId);
|
|
598
|
-
return `${doc.end_session_endpoint}?${params}`;
|
|
599
|
-
},
|
|
600
|
-
async addressOwnership(accessToken, address) {
|
|
601
|
-
const body = await appPost(
|
|
602
|
-
"/api/app/address-ownership",
|
|
603
|
-
accessToken,
|
|
604
|
-
{ address },
|
|
605
|
-
'address ownership (not "unverified")'
|
|
606
|
-
);
|
|
607
|
-
const status = body?.status;
|
|
608
|
-
if (typeof status !== "string" || !ADDRESS_OWNERSHIP.has(status)) {
|
|
609
|
-
throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
|
|
610
|
-
}
|
|
611
|
-
return status;
|
|
612
|
-
},
|
|
613
|
-
verifyLogoutToken,
|
|
614
|
-
async getProfile(accessToken) {
|
|
615
|
-
const path = "/api/app/profile";
|
|
616
|
-
return asProfile(await appRequest(path, accessToken, { method: "GET" }, "the profile"), path);
|
|
617
|
-
},
|
|
618
|
-
async updateProfile(accessToken, name) {
|
|
619
|
-
const path = "/api/app/profile";
|
|
620
|
-
return asProfile(await appRequest(path, accessToken, { method: "POST", json: { name } }, "the new name"), path);
|
|
621
|
-
},
|
|
622
|
-
async uploadAvatar(accessToken, bytes, contentType) {
|
|
623
|
-
const type = mediaType(contentType);
|
|
624
|
-
if (!AVATAR_TYPES.includes(type)) {
|
|
625
|
-
throw new SsoAvatarRejectedError(
|
|
626
|
-
`uploadAvatar: "${type}" is not accepted \u2014 BID takes ${AVATAR_TYPES.join(", ")}.`,
|
|
627
|
-
"unsupported_type"
|
|
628
|
-
);
|
|
629
|
-
}
|
|
630
|
-
if (bytes.byteLength > MAX_AVATAR_BYTES) {
|
|
631
|
-
throw new SsoAvatarRejectedError(
|
|
632
|
-
`uploadAvatar: ${bytes.byteLength} bytes \u2014 BID takes at most ${MAX_AVATAR_BYTES}.`,
|
|
633
|
-
"too_large"
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
const path = "/api/app/profile/avatar";
|
|
637
|
-
return asProfile(await appRequest(path, accessToken, { method: "POST", bytes, contentType: type }, "the new picture"), path);
|
|
638
|
-
},
|
|
639
|
-
async removeAvatar(accessToken) {
|
|
640
|
-
const path = "/api/app/profile/avatar/remove";
|
|
641
|
-
return asProfile(await appRequest(path, accessToken, { method: "POST" }, "the removed picture"), path);
|
|
642
|
-
},
|
|
643
|
-
async inviteUsers(appKey, input) {
|
|
644
|
-
if (input.users.length > MAX_INVITATIONS) {
|
|
645
|
-
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
646
|
-
}
|
|
647
|
-
const body = await appPost("/api/app/invitations", appKey, input, "the invitation outcome");
|
|
648
|
-
if (!Array.isArray(body?.results)) {
|
|
649
|
-
throw new SsoError(`invitations returned no "results" array \u2014 the invitation outcome is unknown.`);
|
|
650
|
-
}
|
|
651
|
-
for (const r of body.results) {
|
|
652
|
-
if (typeof r?.outcome !== "string" || !INVITATION_OUTCOMES.has(r.outcome)) {
|
|
653
|
-
throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} \u2014 not one this client knows.`);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
return body.results;
|
|
657
|
-
},
|
|
658
|
-
async migrationStatus(appKey, emails) {
|
|
659
|
-
const body = await appPost("/api/app/migration-status", appKey, { emails }, "the migration state");
|
|
660
|
-
if (!Array.isArray(body?.users) || typeof body.complete !== "boolean") {
|
|
661
|
-
throw new SsoError(`migration-status returned no "users" array or no "complete" flag \u2014 the migration state is unknown.`);
|
|
662
|
-
}
|
|
663
|
-
for (const u of body.users) {
|
|
664
|
-
if (typeof u?.state !== "string" || !MIGRATION_STATES.has(u.state)) {
|
|
665
|
-
throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} \u2014 never read as "ready".`);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
return body;
|
|
669
|
-
}
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// src/tokens.ts
|
|
674
|
-
function expiresAtFrom(expiresIn, accessToken, now) {
|
|
675
|
-
if (typeof expiresIn === "number" && expiresIn > 0) return now + Math.floor(expiresIn);
|
|
676
|
-
const parts = accessToken.split(".");
|
|
677
|
-
if (parts.length === 3) {
|
|
678
|
-
try {
|
|
679
|
-
const json = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
680
|
-
if (typeof json.exp === "number" && json.exp > now) return json.exp;
|
|
681
|
-
} catch {
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return now + 300;
|
|
685
|
-
}
|
|
686
|
-
function newSid() {
|
|
687
|
-
const b = crypto.getRandomValues(new Uint8Array(16));
|
|
688
|
-
let s = "";
|
|
689
|
-
for (const x of b) s += String.fromCharCode(x);
|
|
690
|
-
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// src/session.ts
|
|
694
|
-
var enc = new TextEncoder();
|
|
695
|
-
var b64url2 = (bytes) => {
|
|
696
|
-
let s = "";
|
|
697
|
-
for (const b of bytes) s += String.fromCharCode(b);
|
|
698
|
-
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
699
|
-
};
|
|
700
|
-
var fromB64url = (s) => {
|
|
701
|
-
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
702
|
-
const bin = atob(pad + "=".repeat((4 - pad.length % 4) % 4));
|
|
703
|
-
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
704
|
-
};
|
|
705
|
-
async function hmacKey(secret) {
|
|
706
|
-
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
707
|
-
"sign",
|
|
708
|
-
"verify"
|
|
709
|
-
]);
|
|
710
|
-
}
|
|
711
|
-
async function signValue(value, secret, options = {}) {
|
|
712
|
-
const payload = b64url2(enc.encode(value));
|
|
713
|
-
const body = options.maxAgeSeconds === void 0 ? payload : `t${Math.floor((options.now ?? Date.now)() / 1e3)}~${payload}`;
|
|
714
|
-
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
|
|
715
|
-
return `${body}.${b64url2(sig)}`;
|
|
716
|
-
}
|
|
717
|
-
var STAMPED = /^t(\d+)~(.*)$/s;
|
|
718
|
-
async function verifyValue(token, secret, options = {}) {
|
|
719
|
-
if (!token) return null;
|
|
720
|
-
const dot = token.lastIndexOf(".");
|
|
721
|
-
if (dot <= 0) return null;
|
|
722
|
-
const body = token.slice(0, dot);
|
|
723
|
-
try {
|
|
724
|
-
const ok = await crypto.subtle.verify(
|
|
725
|
-
"HMAC",
|
|
726
|
-
await hmacKey(secret),
|
|
727
|
-
fromB64url(token.slice(dot + 1)),
|
|
728
|
-
enc.encode(body)
|
|
729
|
-
);
|
|
730
|
-
if (!ok) return null;
|
|
731
|
-
const stamped = STAMPED.exec(body);
|
|
732
|
-
if (options.maxAgeSeconds !== void 0) {
|
|
733
|
-
if (!stamped) return null;
|
|
734
|
-
const issuedAt = Number(stamped[1]);
|
|
735
|
-
if ((options.now ?? Date.now)() / 1e3 - issuedAt > options.maxAgeSeconds) return null;
|
|
736
|
-
}
|
|
737
|
-
return new TextDecoder().decode(fromB64url(stamped?.[2] ?? body));
|
|
738
|
-
} catch {
|
|
739
|
-
return null;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
async function signSession(payload, secret) {
|
|
743
|
-
return signValue(JSON.stringify(payload), secret);
|
|
744
|
-
}
|
|
745
|
-
async function verifySession(token, secret, now = Date.now) {
|
|
746
|
-
const body = await verifyValue(token, secret);
|
|
747
|
-
if (body === null) return null;
|
|
748
|
-
try {
|
|
749
|
-
const payload = JSON.parse(body);
|
|
750
|
-
if (typeof payload.sub !== "string" || payload.sub === "") return null;
|
|
751
|
-
if (typeof payload.exp !== "number" || payload.exp * 1e3 <= now()) return null;
|
|
752
|
-
return payload;
|
|
753
|
-
} catch {
|
|
754
|
-
return null;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
function cookieHeader(name, value, opts) {
|
|
758
|
-
const parts = [
|
|
759
|
-
`${name}=${value}`,
|
|
760
|
-
`Path=${opts.path ?? "/"}`,
|
|
761
|
-
`Max-Age=${opts.maxAge}`,
|
|
762
|
-
"HttpOnly",
|
|
763
|
-
// Lax, NOT Strict, and this is load-bearing: the callback from Broberg ID
|
|
764
|
-
// is a top-level GET navigation from another site. Strict withholds the
|
|
765
|
-
// cookie on exactly that navigation, so the login transaction cookie would
|
|
766
|
-
// be missing when it is needed and every sign-in would fail with "state
|
|
767
|
-
// does not match" — a message that sends you looking in the wrong place.
|
|
768
|
-
`SameSite=${opts.sameSite ?? "Lax"}`
|
|
769
|
-
];
|
|
770
|
-
if (opts.secure) parts.push("Secure");
|
|
771
|
-
return parts.join("; ");
|
|
772
|
-
}
|
|
773
|
-
function readCookie(header, name) {
|
|
774
|
-
if (!header) return void 0;
|
|
775
|
-
for (const part of header.split(";")) {
|
|
776
|
-
const eq = part.indexOf("=");
|
|
777
|
-
if (eq === -1) continue;
|
|
778
|
-
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
779
|
-
}
|
|
780
|
-
return void 0;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// src/hono.ts
|
|
784
6
|
var SESSION_KEY = "bidSession";
|
|
785
7
|
var TRANSACTION_MAX_AGE = 300;
|
|
786
8
|
var TRANSACTION_COOKIE_MAX_AGE = TRANSACTION_MAX_AGE * 3;
|
|
@@ -809,8 +31,8 @@ function safeReturnTo(raw, fallback) {
|
|
|
809
31
|
return normalised;
|
|
810
32
|
}
|
|
811
33
|
function ssoRoutes(options = {}) {
|
|
812
|
-
const config = options.config ?? loadSsoConfig();
|
|
813
|
-
const client = options.client ?? createSsoClient(config);
|
|
34
|
+
const config = options.config ?? chunkJGIO7XPR_cjs.loadSsoConfig();
|
|
35
|
+
const client = options.client ?? chunkJGIO7XPR_cjs.createSsoClient(config);
|
|
814
36
|
const loginPath = options.loginPath ?? "/auth";
|
|
815
37
|
const defaultReturnTo = options.defaultReturnTo ?? "/";
|
|
816
38
|
const txCookie = `${config.cookieName}_tx`;
|
|
@@ -824,7 +46,7 @@ function ssoRoutes(options = {}) {
|
|
|
824
46
|
const callbackErrors = options.callbackErrors ?? "single";
|
|
825
47
|
const onCallbackRefused = options.onCallbackRefused ?? ((cause) => console.warn(`[@broberg/sso] /callback refused: ${cause}`));
|
|
826
48
|
async function liveSession(c) {
|
|
827
|
-
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
49
|
+
const session = await chunkJGIO7XPR_cjs.verifySession(chunkJGIO7XPR_cjs.readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
828
50
|
if (!session || !backchannel) return session;
|
|
829
51
|
try {
|
|
830
52
|
const before = await backchannel.store.revokedBefore(session.sub);
|
|
@@ -850,7 +72,7 @@ function ssoRoutes(options = {}) {
|
|
|
850
72
|
try {
|
|
851
73
|
verified = await client.verifyLogoutToken(token);
|
|
852
74
|
} catch (e) {
|
|
853
|
-
const msg = e instanceof SsoError ? e.message : "logout_token could not be verified";
|
|
75
|
+
const msg = e instanceof chunkJGIO7XPR_cjs.SsoError ? e.message : "logout_token could not be verified";
|
|
854
76
|
return c.json({ error: "invalid_request", error_description: msg }, 400);
|
|
855
77
|
}
|
|
856
78
|
try {
|
|
@@ -879,7 +101,7 @@ function ssoRoutes(options = {}) {
|
|
|
879
101
|
// The signed stamp carries the SERVER's window; the cookie carries the
|
|
880
102
|
// longer browser one, so an expired transaction still reaches us and can
|
|
881
103
|
// be named instead of vanishing.
|
|
882
|
-
cookieHeader(txCookie, await signValue(tx, config.cookieSecret, { maxAgeSeconds: TRANSACTION_MAX_AGE }), {
|
|
104
|
+
chunkJGIO7XPR_cjs.cookieHeader(txCookie, await chunkJGIO7XPR_cjs.signValue(tx, config.cookieSecret, { maxAgeSeconds: TRANSACTION_MAX_AGE }), {
|
|
883
105
|
maxAge: TRANSACTION_COOKIE_MAX_AGE,
|
|
884
106
|
secure: isSecure(c)
|
|
885
107
|
})
|
|
@@ -888,7 +110,7 @@ function ssoRoutes(options = {}) {
|
|
|
888
110
|
});
|
|
889
111
|
app.get("/callback", async (c) => {
|
|
890
112
|
const parsed = await parseTransaction(
|
|
891
|
-
readCookie(c.req.header("cookie"), txCookie),
|
|
113
|
+
chunkJGIO7XPR_cjs.readCookie(c.req.header("cookie"), txCookie),
|
|
892
114
|
config.cookieSecret
|
|
893
115
|
);
|
|
894
116
|
if (!parsed.ok) {
|
|
@@ -920,7 +142,7 @@ function ssoRoutes(options = {}) {
|
|
|
920
142
|
message: "This login could not be completed. Begin again from the login page."
|
|
921
143
|
} : failures[parsed.reason];
|
|
922
144
|
const { status, ...body } = shown;
|
|
923
|
-
c.header("Set-Cookie", cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }));
|
|
145
|
+
c.header("Set-Cookie", chunkJGIO7XPR_cjs.cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }));
|
|
924
146
|
return c.json(body, status);
|
|
925
147
|
}
|
|
926
148
|
const result = await client.completeLogin({
|
|
@@ -933,12 +155,12 @@ function ssoRoutes(options = {}) {
|
|
|
933
155
|
const exp = nowS + config.sessionMaxAge;
|
|
934
156
|
let sid;
|
|
935
157
|
if (tokenStore && result.accessToken) {
|
|
936
|
-
sid = newSid();
|
|
158
|
+
sid = chunkJGIO7XPR_cjs.newSid();
|
|
937
159
|
await tokenStore.set(sid, {
|
|
938
160
|
sub: result.claims.sub,
|
|
939
161
|
accessToken: result.accessToken,
|
|
940
162
|
...result.refreshToken ? { refreshToken: result.refreshToken } : {},
|
|
941
|
-
expiresAt: expiresAtFrom(result.expiresIn, result.accessToken, nowS)
|
|
163
|
+
expiresAt: chunkJGIO7XPR_cjs.expiresAtFrom(result.expiresIn, result.accessToken, nowS)
|
|
942
164
|
});
|
|
943
165
|
}
|
|
944
166
|
const session = {
|
|
@@ -954,14 +176,14 @@ function ssoRoutes(options = {}) {
|
|
|
954
176
|
};
|
|
955
177
|
c.header(
|
|
956
178
|
"Set-Cookie",
|
|
957
|
-
cookieHeader(config.cookieName, await signSession(session, config.cookieSecret), {
|
|
179
|
+
chunkJGIO7XPR_cjs.cookieHeader(config.cookieName, await chunkJGIO7XPR_cjs.signSession(session, config.cookieSecret), {
|
|
958
180
|
maxAge: config.sessionMaxAge,
|
|
959
181
|
secure: isSecure(c)
|
|
960
182
|
})
|
|
961
183
|
);
|
|
962
184
|
c.header(
|
|
963
185
|
"Set-Cookie",
|
|
964
|
-
cookieHeader(idTokenCookie, await signValue(result.idToken, config.cookieSecret), {
|
|
186
|
+
chunkJGIO7XPR_cjs.cookieHeader(idTokenCookie, await chunkJGIO7XPR_cjs.signValue(result.idToken, config.cookieSecret), {
|
|
965
187
|
maxAge: config.sessionMaxAge,
|
|
966
188
|
secure: isSecure(c)
|
|
967
189
|
}),
|
|
@@ -969,27 +191,27 @@ function ssoRoutes(options = {}) {
|
|
|
969
191
|
);
|
|
970
192
|
c.header(
|
|
971
193
|
"Set-Cookie",
|
|
972
|
-
cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }),
|
|
194
|
+
chunkJGIO7XPR_cjs.cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }),
|
|
973
195
|
{ append: true }
|
|
974
196
|
);
|
|
975
197
|
return c.redirect(parsed.tx.returnTo, 302);
|
|
976
198
|
});
|
|
977
199
|
app.get("/logout", async (c) => {
|
|
978
|
-
const idTokenHint = await verifyValue(
|
|
979
|
-
readCookie(c.req.header("cookie"), idTokenCookie),
|
|
200
|
+
const idTokenHint = await chunkJGIO7XPR_cjs.verifyValue(
|
|
201
|
+
chunkJGIO7XPR_cjs.readCookie(c.req.header("cookie"), idTokenCookie),
|
|
980
202
|
config.cookieSecret
|
|
981
203
|
);
|
|
982
204
|
if (tokenStore) {
|
|
983
|
-
const leaving = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
205
|
+
const leaving = await chunkJGIO7XPR_cjs.verifySession(chunkJGIO7XPR_cjs.readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
984
206
|
if (leaving?.sid) await tokenStore.delete(leaving.sid);
|
|
985
207
|
}
|
|
986
208
|
c.header(
|
|
987
209
|
"Set-Cookie",
|
|
988
|
-
cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
|
|
210
|
+
chunkJGIO7XPR_cjs.cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
|
|
989
211
|
);
|
|
990
212
|
c.header(
|
|
991
213
|
"Set-Cookie",
|
|
992
|
-
cookieHeader(idTokenCookie, "", { maxAge: 0, secure: isSecure(c) }),
|
|
214
|
+
chunkJGIO7XPR_cjs.cookieHeader(idTokenCookie, "", { maxAge: 0, secure: isSecure(c) }),
|
|
993
215
|
{ append: true }
|
|
994
216
|
);
|
|
995
217
|
if ((options.logout ?? "app") === "app") {
|
|
@@ -1016,23 +238,23 @@ function ssoRoutes(options = {}) {
|
|
|
1016
238
|
const refreshing = /* @__PURE__ */ new Map();
|
|
1017
239
|
async function getAccessToken(c) {
|
|
1018
240
|
if (!tokenStore) {
|
|
1019
|
-
throw new SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
|
|
241
|
+
throw new chunkJGIO7XPR_cjs.SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
|
|
1020
242
|
}
|
|
1021
|
-
const raw = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
243
|
+
const raw = await chunkJGIO7XPR_cjs.verifySession(chunkJGIO7XPR_cjs.readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
1022
244
|
const live = await liveSession(c);
|
|
1023
245
|
if (!live) {
|
|
1024
246
|
if (raw?.sid) await tokenStore.delete(raw.sid);
|
|
1025
|
-
throw new SsoReauthError("no live session \u2014 log in again.", "no_session");
|
|
247
|
+
throw new chunkJGIO7XPR_cjs.SsoReauthError("no live session \u2014 log in again.", "no_session");
|
|
1026
248
|
}
|
|
1027
249
|
const sid = live.sid;
|
|
1028
|
-
if (!sid) throw new SsoReauthError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.", "no_tokens");
|
|
250
|
+
if (!sid) throw new chunkJGIO7XPR_cjs.SsoReauthError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.", "no_tokens");
|
|
1029
251
|
const tokens = await tokenStore.get(sid);
|
|
1030
|
-
if (!tokens) throw new SsoReauthError("no tokens stored for this session \u2014 log in again.", "no_tokens");
|
|
252
|
+
if (!tokens) throw new chunkJGIO7XPR_cjs.SsoReauthError("no tokens stored for this session \u2014 log in again.", "no_tokens");
|
|
1031
253
|
const now = Math.floor(Date.now() / 1e3);
|
|
1032
254
|
if (tokens.expiresAt - 30 > now) return tokens.accessToken;
|
|
1033
255
|
if (!tokens.refreshToken) {
|
|
1034
256
|
await tokenStore.delete(sid);
|
|
1035
|
-
throw new SsoReauthError("the access token has expired and there is no refresh token \u2014 log in again.", "expired");
|
|
257
|
+
throw new chunkJGIO7XPR_cjs.SsoReauthError("the access token has expired and there is no refresh token \u2014 log in again.", "expired");
|
|
1036
258
|
}
|
|
1037
259
|
const inFlight = refreshing.get(sid);
|
|
1038
260
|
if (inFlight) return inFlight;
|
|
@@ -1042,14 +264,14 @@ function ssoRoutes(options = {}) {
|
|
|
1042
264
|
next = await client.refreshTokens(tokens.refreshToken);
|
|
1043
265
|
} catch (e) {
|
|
1044
266
|
await tokenStore.delete(sid);
|
|
1045
|
-
throw new SsoReauthError(e instanceof SsoError ? e.message : "token refresh failed", "refresh_failed");
|
|
267
|
+
throw new chunkJGIO7XPR_cjs.SsoReauthError(e instanceof chunkJGIO7XPR_cjs.SsoError ? e.message : "token refresh failed", "refresh_failed");
|
|
1046
268
|
}
|
|
1047
269
|
await tokenStore.set(sid, {
|
|
1048
270
|
sub: tokens.sub,
|
|
1049
271
|
accessToken: next.accessToken,
|
|
1050
272
|
// Rotation: use the new one if BID issued one, else keep the old.
|
|
1051
273
|
...next.refreshToken ?? tokens.refreshToken ? { refreshToken: next.refreshToken ?? tokens.refreshToken } : {},
|
|
1052
|
-
expiresAt: expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
|
|
274
|
+
expiresAt: chunkJGIO7XPR_cjs.expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
|
|
1053
275
|
});
|
|
1054
276
|
return next.accessToken;
|
|
1055
277
|
})();
|
|
@@ -1073,7 +295,7 @@ function ssoRoutes(options = {}) {
|
|
|
1073
295
|
if (left <= 0) return;
|
|
1074
296
|
c.header(
|
|
1075
297
|
"Set-Cookie",
|
|
1076
|
-
cookieHeader(config.cookieName, await signSession(next, config.cookieSecret), { maxAge: left, secure: isSecure(c) }),
|
|
298
|
+
chunkJGIO7XPR_cjs.cookieHeader(config.cookieName, await chunkJGIO7XPR_cjs.signSession(next, config.cookieSecret), { maxAge: left, secure: isSecure(c) }),
|
|
1077
299
|
{ append: true }
|
|
1078
300
|
);
|
|
1079
301
|
}
|
|
@@ -1081,9 +303,9 @@ function ssoRoutes(options = {}) {
|
|
|
1081
303
|
}
|
|
1082
304
|
async function parseTransaction(raw, secret) {
|
|
1083
305
|
if (raw === void 0) return { ok: false, reason: "absent" };
|
|
1084
|
-
const signed = await verifyValue(raw, secret);
|
|
306
|
+
const signed = await chunkJGIO7XPR_cjs.verifyValue(raw, secret);
|
|
1085
307
|
if (signed === null) return { ok: false, reason: "unreadable" };
|
|
1086
|
-
const fresh = await verifyValue(raw, secret, { maxAgeSeconds: TRANSACTION_MAX_AGE });
|
|
308
|
+
const fresh = await chunkJGIO7XPR_cjs.verifyValue(raw, secret, { maxAgeSeconds: TRANSACTION_MAX_AGE });
|
|
1087
309
|
if (fresh === null) return { ok: false, reason: "expired" };
|
|
1088
310
|
try {
|
|
1089
311
|
const tx = JSON.parse(fresh);
|
|
@@ -1153,16 +375,16 @@ function accountRoutes(sso) {
|
|
|
1153
375
|
});
|
|
1154
376
|
app.post("/profile/avatar", async (c) => {
|
|
1155
377
|
const token = await sso.getAccessToken(c);
|
|
1156
|
-
const type = mediaType(c.req.header("content-type"));
|
|
1157
|
-
if (!AVATAR_TYPES.includes(type)) {
|
|
1158
|
-
return c.json({ error: "unsupported_type", accepted: AVATAR_TYPES }, 415);
|
|
378
|
+
const type = chunkJGIO7XPR_cjs.mediaType(c.req.header("content-type"));
|
|
379
|
+
if (!chunkJGIO7XPR_cjs.AVATAR_TYPES.includes(type)) {
|
|
380
|
+
return c.json({ error: "unsupported_type", accepted: chunkJGIO7XPR_cjs.AVATAR_TYPES }, 415);
|
|
1159
381
|
}
|
|
1160
382
|
const declared = Number(c.req.header("content-length"));
|
|
1161
|
-
if (Number.isFinite(declared) && declared > MAX_AVATAR_BYTES) {
|
|
1162
|
-
return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
|
|
383
|
+
if (Number.isFinite(declared) && declared > chunkJGIO7XPR_cjs.MAX_AVATAR_BYTES) {
|
|
384
|
+
return c.json({ error: "too_large", max_bytes: chunkJGIO7XPR_cjs.MAX_AVATAR_BYTES }, 413);
|
|
1163
385
|
}
|
|
1164
|
-
const bytes = await readCapped(c, MAX_AVATAR_BYTES);
|
|
1165
|
-
if (!bytes) return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
|
|
386
|
+
const bytes = await readCapped(c, chunkJGIO7XPR_cjs.MAX_AVATAR_BYTES);
|
|
387
|
+
if (!bytes) return c.json({ error: "too_large", max_bytes: chunkJGIO7XPR_cjs.MAX_AVATAR_BYTES }, 413);
|
|
1166
388
|
return answer(c, await client.uploadAvatar(token, bytes, type));
|
|
1167
389
|
});
|
|
1168
390
|
app.post("/profile/avatar/remove", async (c) => answer(c, await client.removeAvatar(await sso.getAccessToken(c))));
|
|
@@ -1175,7 +397,7 @@ function accountRoutes(sso) {
|
|
|
1175
397
|
case "SsoInsufficientScopeError":
|
|
1176
398
|
return c.json({ error: "insufficient_scope", scope: e.scope }, 403);
|
|
1177
399
|
case "SsoAvatarRejectedError":
|
|
1178
|
-
return e.reason === "too_large" ? c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413) : c.json({ error: "unsupported_type", accepted: AVATAR_TYPES }, 415);
|
|
400
|
+
return e.reason === "too_large" ? c.json({ error: "too_large", max_bytes: chunkJGIO7XPR_cjs.MAX_AVATAR_BYTES }, 413) : c.json({ error: "unsupported_type", accepted: chunkJGIO7XPR_cjs.AVATAR_TYPES }, 415);
|
|
1179
401
|
case "SsoAppApiError": {
|
|
1180
402
|
if (e.status === 401) return c.json({ error: "reauth" }, 401);
|
|
1181
403
|
if (typeof e.status === "number" && e.status >= 400 && e.status < 500) {
|