@broberg/sso 0.10.1 → 0.12.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 +50 -2
- package/dist/hono.cjs +35 -6
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +35 -1
- package/dist/hono.d.ts +35 -1
- package/dist/hono.js +35 -6
- package/dist/hono.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -204,8 +204,9 @@ run 3× in production; this package's own Hono adapter now does the same, after
|
|
|
204
204
|
answering every failed callback with one message containing the word *"or"*.
|
|
205
205
|
|
|
206
206
|
**Mount the Hono adapter on 0.3.0 or later and you get all of this for free** —
|
|
207
|
-
the signed window, the longer cookie, and three distinct
|
|
208
|
-
(`
|
|
207
|
+
the signed window, the longer cookie, and three distinct causes
|
|
208
|
+
(`expired` · `absent` · `unreadable`). **Since 0.12.0 the browser no longer sees
|
|
209
|
+
all three by default**: see *What a refused login tells the browser* below. **On 0.2.3–0.2.5
|
|
209
210
|
the adapter had the signed window but still used ONE number for both**, so it
|
|
210
211
|
answered every failed callback with a single message containing the word *"or"*.
|
|
211
212
|
Take the core and nothing is passed on your behalf, on any version. Reported by broberg-id, who found their own framework-free
|
|
@@ -474,5 +475,52 @@ access token itself (a 401 from BID). It is never a 500.
|
|
|
474
475
|
`SsoAppApiError` (`.status`, BID's `.code`; `status` is null when BID did not
|
|
475
476
|
answer). All extend `SsoError`. No message ever contains a token.
|
|
476
477
|
|
|
478
|
+
**The user menu follows (since 0.11.0, F095.5).** Every successful
|
|
479
|
+
`accountRoutes` answer re-signs the session cookie with the name and picture BID
|
|
480
|
+
returned (`ssoRoutes().refreshSessionProfile`). sub, sid, iat and exp are kept,
|
|
481
|
+
and Max-Age is what is left of the session: the lifetime is never extended.
|
|
482
|
+
Nothing is written when nothing changed, and nothing is written on a refusal.
|
|
483
|
+
A reload therefore shows the new name in the menu without a new login. A GET also
|
|
484
|
+
picks up a change the user made in BID itself.
|
|
485
|
+
|
|
477
486
|
Mail, password, passkeys, two-factor and sessions stay in BID: link to the
|
|
478
487
|
profile's `account_url` in a new tab.
|
|
488
|
+
|
|
489
|
+
## What a refused login tells the browser (changed in 0.12.0)
|
|
490
|
+
|
|
491
|
+
**Changed default: the browser now gets ONE answer, `login_failed`, for an expired
|
|
492
|
+
login AND an unreadable cookie.** Through 0.11.0 it got `login_expired` and
|
|
493
|
+
`bad_login_cookie` separately. `no_login_in_progress` is unchanged.
|
|
494
|
+
|
|
495
|
+
| cause | your app / log (`onCallbackRefused`) | browser, default `"single"` | browser, `"granular"` |
|
|
496
|
+
|---|---|---|---|
|
|
497
|
+
| no login started here | `absent` | `no_login_in_progress` | `no_login_in_progress` |
|
|
498
|
+
| took too long | `expired` | `login_failed` | `login_expired` |
|
|
499
|
+
| signature did not hold | `unreadable` | `login_failed` | `bad_login_cookie` |
|
|
500
|
+
|
|
501
|
+
**Why.** The order in `parseTransaction` is what makes this matter, and it is not
|
|
502
|
+
obvious until you read the function: the signature is checked FIRST, and the age
|
|
503
|
+
only after it held. So `login_expired` is a positive confirmation that the
|
|
504
|
+
cookie's HMAC held against the CURRENT secret, and `bad_login_cookie` denies it.
|
|
505
|
+
Someone holding a cookie they FOUND (a log, a shared machine, a backup) could ask
|
|
506
|
+
whether it is still live, see the moment the secret rotates, and see whether two
|
|
507
|
+
environments share a secret. It does not help anyone forge anything. It is a
|
|
508
|
+
fact the server gave away for free. Reported by helpdesk, 22 September 2026.
|
|
509
|
+
|
|
510
|
+
**The operator still gets all three.** `onCallbackRefused(cause, c)` receives the
|
|
511
|
+
precise cause on every refusal; without it the adapter writes
|
|
512
|
+
`[@broberg/sso] /callback refused: <cause>` with `console.warn`. Watch for
|
|
513
|
+
`unreadable` on real users: that is what a rotated `SSO_COOKIE_SECRET` looks like.
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
ssoRoutes({
|
|
517
|
+
onCallbackRefused: (cause) => log.warn({ cause }, "sso callback refused"),
|
|
518
|
+
// callbackErrors: "granular", // opt in: «dit login udløb» in the browser
|
|
519
|
+
});
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
**What the opt-in costs.** `callbackErrors: "granular"` gives the browser the
|
|
523
|
+
three codes back, so it can say *"your login expired, try again"*. The price is
|
|
524
|
+
the one bit above, for anyone with a found cookie. Fine for an internal app behind
|
|
525
|
+
a VPN; think twice on the open internet.
|
|
526
|
+
|
package/dist/hono.cjs
CHANGED
|
@@ -821,6 +821,8 @@ function ssoRoutes(options = {}) {
|
|
|
821
821
|
}
|
|
822
822
|
const onStoreError = backchannel?.onStoreError ?? "reject";
|
|
823
823
|
const tokenStore = options.tokenStore;
|
|
824
|
+
const callbackErrors = options.callbackErrors ?? "single";
|
|
825
|
+
const onCallbackRefused = options.onCallbackRefused ?? ((cause) => console.warn(`[@broberg/sso] /callback refused: ${cause}`));
|
|
824
826
|
async function liveSession(c) {
|
|
825
827
|
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
826
828
|
if (!session || !backchannel) return session;
|
|
@@ -907,7 +909,13 @@ function ssoRoutes(options = {}) {
|
|
|
907
909
|
message: "This browser's login cookie could not be read. Begin again from the login page."
|
|
908
910
|
}
|
|
909
911
|
};
|
|
910
|
-
|
|
912
|
+
await onCallbackRefused(parsed.reason, c);
|
|
913
|
+
const shown = callbackErrors === "single" && parsed.reason !== "absent" ? {
|
|
914
|
+
status: 400,
|
|
915
|
+
error: "login_failed",
|
|
916
|
+
message: "This login could not be completed. Begin again from the login page."
|
|
917
|
+
} : failures[parsed.reason];
|
|
918
|
+
const { status, ...body } = shown;
|
|
911
919
|
c.header("Set-Cookie", cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }));
|
|
912
920
|
return c.json(body, status);
|
|
913
921
|
}
|
|
@@ -1048,7 +1056,24 @@ function ssoRoutes(options = {}) {
|
|
|
1048
1056
|
refreshing.delete(sid);
|
|
1049
1057
|
}
|
|
1050
1058
|
}
|
|
1051
|
-
|
|
1059
|
+
async function refreshSessionProfile(c, profile) {
|
|
1060
|
+
const live = await liveSession(c);
|
|
1061
|
+
if (!live) return;
|
|
1062
|
+
const next = { ...live };
|
|
1063
|
+
if (profile.name) next.name = profile.name;
|
|
1064
|
+
else delete next.name;
|
|
1065
|
+
if (profile.picture) next.picture = profile.picture;
|
|
1066
|
+
else delete next.picture;
|
|
1067
|
+
if (next.name === live.name && next.picture === live.picture) return;
|
|
1068
|
+
const left = live.exp - Math.floor(Date.now() / 1e3);
|
|
1069
|
+
if (left <= 0) return;
|
|
1070
|
+
c.header(
|
|
1071
|
+
"Set-Cookie",
|
|
1072
|
+
cookieHeader(config.cookieName, await signSession(next, config.cookieSecret), { maxAge: left, secure: isSecure(c) }),
|
|
1073
|
+
{ append: true }
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
return { app, attach, require: require2, client, config, getAccessToken, refreshSessionProfile };
|
|
1052
1077
|
}
|
|
1053
1078
|
async function parseTransaction(raw, secret) {
|
|
1054
1079
|
if (raw === void 0) return { ok: false, reason: "absent" };
|
|
@@ -1109,14 +1134,18 @@ function accountRoutes(sso) {
|
|
|
1109
1134
|
}
|
|
1110
1135
|
await next();
|
|
1111
1136
|
});
|
|
1112
|
-
|
|
1137
|
+
const answer = async (c, profile) => {
|
|
1138
|
+
if (sso.refreshSessionProfile) await sso.refreshSessionProfile(c, profile);
|
|
1139
|
+
return c.json(profile);
|
|
1140
|
+
};
|
|
1141
|
+
app.get("/profile", async (c) => answer(c, await client.getProfile(await sso.getAccessToken(c))));
|
|
1113
1142
|
app.post("/profile", async (c) => {
|
|
1114
1143
|
const token = await sso.getAccessToken(c);
|
|
1115
1144
|
const body = await c.req.json().catch(() => null);
|
|
1116
1145
|
if (typeof body?.name !== "string") {
|
|
1117
1146
|
return c.json({ error: "invalid_request", error_description: '"name" must be a string' }, 400);
|
|
1118
1147
|
}
|
|
1119
|
-
return c
|
|
1148
|
+
return answer(c, await client.updateProfile(token, body.name));
|
|
1120
1149
|
});
|
|
1121
1150
|
app.post("/profile/avatar", async (c) => {
|
|
1122
1151
|
const token = await sso.getAccessToken(c);
|
|
@@ -1130,9 +1159,9 @@ function accountRoutes(sso) {
|
|
|
1130
1159
|
}
|
|
1131
1160
|
const bytes = await readCapped(c, MAX_AVATAR_BYTES);
|
|
1132
1161
|
if (!bytes) return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
|
|
1133
|
-
return c
|
|
1162
|
+
return answer(c, await client.uploadAvatar(token, bytes, type));
|
|
1134
1163
|
});
|
|
1135
|
-
app.post("/profile/avatar/remove", async (c) => c
|
|
1164
|
+
app.post("/profile/avatar/remove", async (c) => answer(c, await client.removeAvatar(await sso.getAccessToken(c))));
|
|
1136
1165
|
app.onError((err, c) => {
|
|
1137
1166
|
c.header("Cache-Control", "no-store");
|
|
1138
1167
|
const e = err;
|