@broberg/sso 0.1.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 +76 -0
- package/dist/hono.cjs +510 -0
- package/dist/hono.cjs.map +1 -0
- package/dist/hono.d.cts +199 -0
- package/dist/hono.d.ts +199 -0
- package/dist/hono.js +507 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.cjs +400 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +253 -0
- package/dist/index.d.ts +253 -0
- package/dist/index.js +386 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @broberg/sso
|
|
2
|
+
|
|
3
|
+
The thin client for **Broberg ID** (`id.broberg.ai`). Send a user to central
|
|
4
|
+
login, verify the ID token against JWKS, keep a local session. That is all it
|
|
5
|
+
does, and the list of what it deliberately cannot do is part of the design.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @broberg/sso
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configure it with environment variables only
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
BID_ISSUER=https://id.broberg.ai # the BARE origin
|
|
15
|
+
SSO_CLIENT_ID=my-app # registered administratively in BID
|
|
16
|
+
SSO_REDIRECT_URI=https://my.app/auth/callback # EXACT match, one slash decides
|
|
17
|
+
SSO_COOKIE_SECRET=$(openssl rand -hex 32)
|
|
18
|
+
# optional
|
|
19
|
+
SSO_SCOPES="openid profile email"
|
|
20
|
+
SSO_COOKIE_NAME=bid_session
|
|
21
|
+
SSO_SESSION_MAX_AGE=604800 # 7 days (fleet default, F084.7)
|
|
22
|
+
SSO_POST_LOGOUT_REDIRECT_URI=https://my.app/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Handling personal or health data? Set `SSO_SESSION_MAX_AGE=43200`.** The
|
|
26
|
+
default is twelve hours' worth of convenience too long for that case — F084.7
|
|
27
|
+
decided 12 hours plus a 30-minute inactivity cut for anything holding personal
|
|
28
|
+
or health data. The default is a normal-app default, not a safe-for-everything
|
|
29
|
+
one.
|
|
30
|
+
|
|
31
|
+
## Mount it (Hono)
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { Hono } from "hono";
|
|
35
|
+
import { ssoRoutes, getSession } from "@broberg/sso/hono";
|
|
36
|
+
|
|
37
|
+
const app = new Hono();
|
|
38
|
+
const sso = ssoRoutes({ loginPath: "/auth" });
|
|
39
|
+
|
|
40
|
+
app.route("/auth", sso.app); // /auth/login · /auth/callback · /auth/logout
|
|
41
|
+
app.use("*", sso.attach); // read the session, never block
|
|
42
|
+
app.get("/me", sso.require, (c) => c.json(getSession(c))); // block + redirect
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That is the whole integration. No other code changes.
|
|
46
|
+
|
|
47
|
+
## What it will never do
|
|
48
|
+
|
|
49
|
+
No passwords. No passkey registration. No social-provider keys. No email
|
|
50
|
+
verification. **No client secret** — it is a public client and PKCE carries the
|
|
51
|
+
exchange.
|
|
52
|
+
|
|
53
|
+
That last one is safe for a specific, measured reason rather than a hopeful
|
|
54
|
+
one: BID matches redirect addresses exactly (a single trailing slash is
|
|
55
|
+
refused), so an authorization code is delivered to your own server and nowhere
|
|
56
|
+
else. An attacker who knows your client id can start a flow; they cannot
|
|
57
|
+
receive its result. And a secret that does not exist cannot be committed,
|
|
58
|
+
logged, copied into a second app, or left in a repo someone later opens.
|
|
59
|
+
|
|
60
|
+
## Key rotation costs you nothing
|
|
61
|
+
|
|
62
|
+
The key cache refetches when it sees an **unknown key id** — not on a timer. A
|
|
63
|
+
timer is a guess about when somebody else will rotate; an unknown kid is the
|
|
64
|
+
event itself. Refetches are rate-limited (10s by default) so a stream of tokens
|
|
65
|
+
with invented kids cannot be used to aim traffic at BID.
|
|
66
|
+
|
|
67
|
+
## One thing that is load-bearing and easy to undo
|
|
68
|
+
|
|
69
|
+
The session cookie is `SameSite=Lax`, not `Strict`. The callback from Broberg ID
|
|
70
|
+
is a top-level navigation from another site, and `Strict` withholds the cookie
|
|
71
|
+
on exactly that navigation — every sign-in would fail with "state does not
|
|
72
|
+
match", which sends you looking at the OAuth flow instead of at a cookie flag.
|
|
73
|
+
|
|
74
|
+
## Licence
|
|
75
|
+
|
|
76
|
+
MIT
|
package/dist/hono.cjs
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var hono = require('hono');
|
|
4
|
+
var jose = require('jose');
|
|
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
|
+
};
|
|
77
|
+
}
|
|
78
|
+
var JwksError = class extends Error {
|
|
79
|
+
constructor(message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "JwksError";
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
function createJwksCache(options) {
|
|
85
|
+
const {
|
|
86
|
+
jwksUri,
|
|
87
|
+
minRefetchIntervalMs = 1e4,
|
|
88
|
+
fetchImpl = fetch,
|
|
89
|
+
now = () => Date.now()
|
|
90
|
+
} = options;
|
|
91
|
+
let keys = [];
|
|
92
|
+
let lastFetchAt = -Infinity;
|
|
93
|
+
let fetchCount = 0;
|
|
94
|
+
let inFlight = null;
|
|
95
|
+
async function refresh() {
|
|
96
|
+
if (inFlight) return inFlight;
|
|
97
|
+
inFlight = (async () => {
|
|
98
|
+
const res = await fetchImpl(jwksUri);
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
throw new JwksError(`${jwksUri} answered ${res.status} \u2014 cannot verify any token`);
|
|
101
|
+
}
|
|
102
|
+
const body = await res.json();
|
|
103
|
+
if (!Array.isArray(body.keys)) {
|
|
104
|
+
throw new JwksError(`${jwksUri} returned no "keys" array`);
|
|
105
|
+
}
|
|
106
|
+
keys = body.keys;
|
|
107
|
+
lastFetchAt = now();
|
|
108
|
+
fetchCount++;
|
|
109
|
+
})().finally(() => {
|
|
110
|
+
inFlight = null;
|
|
111
|
+
});
|
|
112
|
+
return inFlight;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
get fetchCount() {
|
|
116
|
+
return fetchCount;
|
|
117
|
+
},
|
|
118
|
+
async getKey(kid, alg) {
|
|
119
|
+
let jwk = keys.find((k) => k.kid === kid);
|
|
120
|
+
if (!jwk) {
|
|
121
|
+
const sinceLast = now() - lastFetchAt;
|
|
122
|
+
if (sinceLast < minRefetchIntervalMs) {
|
|
123
|
+
throw new JwksError(
|
|
124
|
+
`no signing key with kid ${kid}, and the key set was refreshed ${sinceLast}ms ago (floor is ${minRefetchIntervalMs}ms). Refusing to refetch \u2014 retry shortly.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
await refresh();
|
|
128
|
+
jwk = keys.find((k) => k.kid === kid);
|
|
129
|
+
}
|
|
130
|
+
if (!jwk) {
|
|
131
|
+
throw new JwksError(
|
|
132
|
+
`Broberg ID does not publish a signing key with kid ${kid}. The token was not signed by this issuer.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return jose.importJWK(jwk, alg);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/client.ts
|
|
141
|
+
var SsoError = class extends Error {
|
|
142
|
+
constructor(message) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = "SsoError";
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
var b64url = (bytes) => {
|
|
148
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
149
|
+
let s = "";
|
|
150
|
+
for (const b of view) s += String.fromCharCode(b);
|
|
151
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
152
|
+
};
|
|
153
|
+
var randomToken = (bytes = 32) => b64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
154
|
+
async function challengeFor(verifier) {
|
|
155
|
+
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
156
|
+
}
|
|
157
|
+
function createSsoClient(config, options = {}) {
|
|
158
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
159
|
+
let discoveryPromise = null;
|
|
160
|
+
let jwksCache = null;
|
|
161
|
+
async function discovery() {
|
|
162
|
+
discoveryPromise ??= (async () => {
|
|
163
|
+
const url = `${config.issuer}/.well-known/openid-configuration`;
|
|
164
|
+
const res = await fetchImpl(url);
|
|
165
|
+
if (!res.ok) throw new SsoError(`${url} answered ${res.status}`);
|
|
166
|
+
const doc = await res.json();
|
|
167
|
+
if (doc.issuer !== config.issuer) {
|
|
168
|
+
throw new SsoError(
|
|
169
|
+
`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.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return doc;
|
|
173
|
+
})().catch((err) => {
|
|
174
|
+
discoveryPromise = null;
|
|
175
|
+
throw err;
|
|
176
|
+
});
|
|
177
|
+
return discoveryPromise;
|
|
178
|
+
}
|
|
179
|
+
async function keys() {
|
|
180
|
+
if (!jwksCache) {
|
|
181
|
+
const { jwks_uri } = await discovery();
|
|
182
|
+
jwksCache = createJwksCache({
|
|
183
|
+
jwksUri: jwks_uri,
|
|
184
|
+
fetchImpl,
|
|
185
|
+
...options.minRefetchIntervalMs !== void 0 ? { minRefetchIntervalMs: options.minRefetchIntervalMs } : {}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return jwksCache;
|
|
189
|
+
}
|
|
190
|
+
async function verifyIdToken(idToken, opts = {}) {
|
|
191
|
+
const cache = await keys();
|
|
192
|
+
const header = jose.decodeProtectedHeader(idToken);
|
|
193
|
+
if (!header.kid) throw new SsoError("ID token has no kid \u2014 cannot pick a signing key");
|
|
194
|
+
const { payload } = await jose.jwtVerify(
|
|
195
|
+
idToken,
|
|
196
|
+
async () => cache.getKey(header.kid, header.alg ?? "RS256"),
|
|
197
|
+
{ issuer: config.issuer, audience: config.clientId }
|
|
198
|
+
);
|
|
199
|
+
if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) {
|
|
200
|
+
throw new SsoError(
|
|
201
|
+
"ID token nonce does not match this login request \u2014 refusing a token minted for another sign-in."
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (typeof payload.sub !== "string" || payload.sub === "") {
|
|
205
|
+
throw new SsoError("ID token has no sub \u2014 there is no user to be");
|
|
206
|
+
}
|
|
207
|
+
return payload;
|
|
208
|
+
}
|
|
209
|
+
async function withUserInfo(claims, accessToken) {
|
|
210
|
+
const doc = await discovery();
|
|
211
|
+
if (!doc.userinfo_endpoint) return claims;
|
|
212
|
+
const res = await fetchImpl(doc.userinfo_endpoint, {
|
|
213
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
214
|
+
});
|
|
215
|
+
if (!res.ok) return claims;
|
|
216
|
+
const info = await res.json();
|
|
217
|
+
if (info.sub !== claims.sub) {
|
|
218
|
+
throw new SsoError(
|
|
219
|
+
`userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return { ...claims, ...info, sub: claims.sub };
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
discovery,
|
|
226
|
+
get jwks() {
|
|
227
|
+
if (!jwksCache) throw new SsoError("the key cache is created on first verification");
|
|
228
|
+
return jwksCache;
|
|
229
|
+
},
|
|
230
|
+
verifyIdToken,
|
|
231
|
+
async beginLogin(opts = {}) {
|
|
232
|
+
const { authorization_endpoint } = await discovery();
|
|
233
|
+
const codeVerifier = randomToken();
|
|
234
|
+
const state = randomToken(16);
|
|
235
|
+
const nonce = randomToken(16);
|
|
236
|
+
const params = new URLSearchParams({
|
|
237
|
+
response_type: "code",
|
|
238
|
+
client_id: config.clientId,
|
|
239
|
+
redirect_uri: config.redirectUri,
|
|
240
|
+
scope: [.../* @__PURE__ */ new Set([...config.scopes, ...opts.scopes ?? []])].join(" "),
|
|
241
|
+
state,
|
|
242
|
+
nonce,
|
|
243
|
+
code_challenge: await challengeFor(codeVerifier),
|
|
244
|
+
code_challenge_method: "S256"
|
|
245
|
+
});
|
|
246
|
+
if (opts.prompt) params.set("prompt", opts.prompt);
|
|
247
|
+
return { url: `${authorization_endpoint}?${params}`, state, codeVerifier, nonce };
|
|
248
|
+
},
|
|
249
|
+
async completeLogin({ params, state, codeVerifier, nonce }) {
|
|
250
|
+
const error = params.get("error");
|
|
251
|
+
if (error) {
|
|
252
|
+
throw new SsoError(
|
|
253
|
+
`Broberg ID refused this login: ${error}` + (params.get("error_description") ? ` \u2014 ${params.get("error_description")}` : "")
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const returned = params.get("state");
|
|
257
|
+
if (!returned || returned !== state) {
|
|
258
|
+
throw new SsoError(
|
|
259
|
+
"state does not match the login this browser started \u2014 refusing the callback."
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const code = params.get("code");
|
|
263
|
+
if (!code) throw new SsoError("callback carried neither an error nor a code");
|
|
264
|
+
const { token_endpoint } = await discovery();
|
|
265
|
+
const res = await fetchImpl(token_endpoint, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
268
|
+
body: new URLSearchParams({
|
|
269
|
+
grant_type: "authorization_code",
|
|
270
|
+
code,
|
|
271
|
+
redirect_uri: config.redirectUri,
|
|
272
|
+
client_id: config.clientId,
|
|
273
|
+
code_verifier: codeVerifier
|
|
274
|
+
})
|
|
275
|
+
});
|
|
276
|
+
const body = await res.json();
|
|
277
|
+
if (!res.ok || !body.id_token) {
|
|
278
|
+
throw new SsoError(
|
|
279
|
+
`token exchange failed (${res.status}): ${body.error ?? "no id_token in response"}` + (body.error_description ? ` \u2014 ${body.error_description}` : "")
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const claims = await verifyIdToken(body.id_token, { nonce });
|
|
283
|
+
return {
|
|
284
|
+
claims: body.access_token ? await withUserInfo(claims, body.access_token) : claims,
|
|
285
|
+
idToken: body.id_token,
|
|
286
|
+
...body.access_token ? { accessToken: body.access_token } : {},
|
|
287
|
+
...body.refresh_token ? { refreshToken: body.refresh_token } : {}
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
async logoutUrl(opts = {}) {
|
|
291
|
+
const doc = await discovery();
|
|
292
|
+
if (!doc.end_session_endpoint) {
|
|
293
|
+
throw new SsoError(
|
|
294
|
+
`${config.issuer} does not advertise end_session_endpoint \u2014 central logout is unavailable.`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const params = new URLSearchParams();
|
|
298
|
+
if (opts.idTokenHint) params.set("id_token_hint", opts.idTokenHint);
|
|
299
|
+
const post = opts.postLogoutRedirectUri ?? config.postLogoutRedirectUri;
|
|
300
|
+
if (post) params.set("post_logout_redirect_uri", post);
|
|
301
|
+
params.set("client_id", config.clientId);
|
|
302
|
+
return `${doc.end_session_endpoint}?${params}`;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/session.ts
|
|
308
|
+
var enc = new TextEncoder();
|
|
309
|
+
var b64url2 = (bytes) => {
|
|
310
|
+
let s = "";
|
|
311
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
312
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
313
|
+
};
|
|
314
|
+
var fromB64url = (s) => {
|
|
315
|
+
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
316
|
+
const bin = atob(pad + "=".repeat((4 - pad.length % 4) % 4));
|
|
317
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
318
|
+
};
|
|
319
|
+
async function hmacKey(secret) {
|
|
320
|
+
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
321
|
+
"sign",
|
|
322
|
+
"verify"
|
|
323
|
+
]);
|
|
324
|
+
}
|
|
325
|
+
async function signValue(value, secret) {
|
|
326
|
+
const body = b64url2(enc.encode(value));
|
|
327
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
|
|
328
|
+
return `${body}.${b64url2(sig)}`;
|
|
329
|
+
}
|
|
330
|
+
async function verifyValue(token, secret) {
|
|
331
|
+
if (!token) return null;
|
|
332
|
+
const dot = token.lastIndexOf(".");
|
|
333
|
+
if (dot <= 0) return null;
|
|
334
|
+
const body = token.slice(0, dot);
|
|
335
|
+
try {
|
|
336
|
+
const ok = await crypto.subtle.verify(
|
|
337
|
+
"HMAC",
|
|
338
|
+
await hmacKey(secret),
|
|
339
|
+
fromB64url(token.slice(dot + 1)),
|
|
340
|
+
enc.encode(body)
|
|
341
|
+
);
|
|
342
|
+
if (!ok) return null;
|
|
343
|
+
return new TextDecoder().decode(fromB64url(body));
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function signSession(payload, secret) {
|
|
349
|
+
return signValue(JSON.stringify(payload), secret);
|
|
350
|
+
}
|
|
351
|
+
async function verifySession(token, secret, now = Date.now) {
|
|
352
|
+
const body = await verifyValue(token, secret);
|
|
353
|
+
if (body === null) return null;
|
|
354
|
+
try {
|
|
355
|
+
const payload = JSON.parse(body);
|
|
356
|
+
if (typeof payload.sub !== "string" || payload.sub === "") return null;
|
|
357
|
+
if (typeof payload.exp !== "number" || payload.exp * 1e3 <= now()) return null;
|
|
358
|
+
return payload;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function cookieHeader(name, value, opts) {
|
|
364
|
+
const parts = [
|
|
365
|
+
`${name}=${value}`,
|
|
366
|
+
`Path=${opts.path ?? "/"}`,
|
|
367
|
+
`Max-Age=${opts.maxAge}`,
|
|
368
|
+
"HttpOnly",
|
|
369
|
+
// Lax, NOT Strict, and this is load-bearing: the callback from Broberg ID
|
|
370
|
+
// is a top-level GET navigation from another site. Strict withholds the
|
|
371
|
+
// cookie on exactly that navigation, so the login transaction cookie would
|
|
372
|
+
// be missing when it is needed and every sign-in would fail with "state
|
|
373
|
+
// does not match" — a message that sends you looking in the wrong place.
|
|
374
|
+
`SameSite=${opts.sameSite ?? "Lax"}`
|
|
375
|
+
];
|
|
376
|
+
if (opts.secure) parts.push("Secure");
|
|
377
|
+
return parts.join("; ");
|
|
378
|
+
}
|
|
379
|
+
function readCookie(header, name) {
|
|
380
|
+
if (!header) return void 0;
|
|
381
|
+
for (const part of header.split(";")) {
|
|
382
|
+
const eq = part.indexOf("=");
|
|
383
|
+
if (eq === -1) continue;
|
|
384
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
385
|
+
}
|
|
386
|
+
return void 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/hono.ts
|
|
390
|
+
var SESSION_KEY = "bidSession";
|
|
391
|
+
var TRANSACTION_MAX_AGE = 300;
|
|
392
|
+
function isSecure(c) {
|
|
393
|
+
return new URL(c.req.url).protocol === "https:";
|
|
394
|
+
}
|
|
395
|
+
function safeReturnTo(raw, fallback) {
|
|
396
|
+
if (!raw) return fallback;
|
|
397
|
+
if (!raw.startsWith("/") || raw.startsWith("//")) return fallback;
|
|
398
|
+
return raw;
|
|
399
|
+
}
|
|
400
|
+
function ssoRoutes(options = {}) {
|
|
401
|
+
const config = options.config ?? loadSsoConfig();
|
|
402
|
+
const client = options.client ?? createSsoClient(config);
|
|
403
|
+
const loginPath = options.loginPath ?? "/auth";
|
|
404
|
+
const defaultReturnTo = options.defaultReturnTo ?? "/";
|
|
405
|
+
const txCookie = `${config.cookieName}_tx`;
|
|
406
|
+
const app = new hono.Hono();
|
|
407
|
+
app.get("/login", async (c) => {
|
|
408
|
+
const prompt = c.req.query("prompt");
|
|
409
|
+
const start = await client.beginLogin(
|
|
410
|
+
prompt === "none" ? { prompt: "none" } : {}
|
|
411
|
+
);
|
|
412
|
+
const tx = JSON.stringify({
|
|
413
|
+
state: start.state,
|
|
414
|
+
codeVerifier: start.codeVerifier,
|
|
415
|
+
nonce: start.nonce,
|
|
416
|
+
returnTo: safeReturnTo(c.req.query("returnTo"), defaultReturnTo)
|
|
417
|
+
});
|
|
418
|
+
c.header(
|
|
419
|
+
"Set-Cookie",
|
|
420
|
+
cookieHeader(txCookie, await signValue(tx, config.cookieSecret), {
|
|
421
|
+
maxAge: TRANSACTION_MAX_AGE,
|
|
422
|
+
secure: isSecure(c)
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
return c.redirect(start.url, 302);
|
|
426
|
+
});
|
|
427
|
+
app.get("/callback", async (c) => {
|
|
428
|
+
const parsed = await parseTransaction(
|
|
429
|
+
readCookie(c.req.header("cookie"), txCookie),
|
|
430
|
+
config.cookieSecret
|
|
431
|
+
);
|
|
432
|
+
if (!parsed) {
|
|
433
|
+
return c.json(
|
|
434
|
+
{ error: "no_login_in_progress", message: "This browser did not start a login here, or it expired." },
|
|
435
|
+
400
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
const result = await client.completeLogin({
|
|
439
|
+
params: new URL(c.req.url).searchParams,
|
|
440
|
+
state: parsed.state,
|
|
441
|
+
codeVerifier: parsed.codeVerifier,
|
|
442
|
+
nonce: parsed.nonce
|
|
443
|
+
});
|
|
444
|
+
const exp = Math.floor(Date.now() / 1e3) + config.sessionMaxAge;
|
|
445
|
+
const session = {
|
|
446
|
+
sub: result.claims.sub,
|
|
447
|
+
exp,
|
|
448
|
+
...result.claims.email ? { email: result.claims.email } : {},
|
|
449
|
+
...result.claims.name ? { name: result.claims.name } : {}
|
|
450
|
+
};
|
|
451
|
+
c.header(
|
|
452
|
+
"Set-Cookie",
|
|
453
|
+
cookieHeader(config.cookieName, await signSession(session, config.cookieSecret), {
|
|
454
|
+
maxAge: config.sessionMaxAge,
|
|
455
|
+
secure: isSecure(c)
|
|
456
|
+
})
|
|
457
|
+
);
|
|
458
|
+
c.header(
|
|
459
|
+
"Set-Cookie",
|
|
460
|
+
cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }),
|
|
461
|
+
{ append: true }
|
|
462
|
+
);
|
|
463
|
+
return c.redirect(parsed.returnTo, 302);
|
|
464
|
+
});
|
|
465
|
+
app.get("/logout", async (c) => {
|
|
466
|
+
c.header(
|
|
467
|
+
"Set-Cookie",
|
|
468
|
+
cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
|
|
469
|
+
);
|
|
470
|
+
return c.redirect(await client.logoutUrl(), 302);
|
|
471
|
+
});
|
|
472
|
+
const attach = async (c, next) => {
|
|
473
|
+
const cookie = readCookie(c.req.header("cookie"), config.cookieName);
|
|
474
|
+
c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));
|
|
475
|
+
await next();
|
|
476
|
+
};
|
|
477
|
+
const require2 = async (c, next) => {
|
|
478
|
+
const cookie = readCookie(c.req.header("cookie"), config.cookieName);
|
|
479
|
+
const session = await verifySession(cookie, config.cookieSecret);
|
|
480
|
+
if (!session) {
|
|
481
|
+
const url = new URL(c.req.url);
|
|
482
|
+
return c.redirect(
|
|
483
|
+
`${loginPath}/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`,
|
|
484
|
+
302
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
c.set(SESSION_KEY, session);
|
|
488
|
+
await next();
|
|
489
|
+
};
|
|
490
|
+
return { app, attach, require: require2, client, config };
|
|
491
|
+
}
|
|
492
|
+
async function parseTransaction(raw, secret) {
|
|
493
|
+
const body = await verifyValue(raw, secret);
|
|
494
|
+
if (body === null) return null;
|
|
495
|
+
try {
|
|
496
|
+
const tx = JSON.parse(body);
|
|
497
|
+
if (!tx.state || !tx.codeVerifier || !tx.nonce) return null;
|
|
498
|
+
return tx;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function getSession(c) {
|
|
504
|
+
return c.get(SESSION_KEY) ?? null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
exports.getSession = getSession;
|
|
508
|
+
exports.ssoRoutes = ssoRoutes;
|
|
509
|
+
//# sourceMappingURL=hono.cjs.map
|
|
510
|
+
//# sourceMappingURL=hono.cjs.map
|