@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 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 answers
208
- (`login_expired` · `no_login_in_progress` · `bad_login_cookie`). **On 0.2.3–0.2.5
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
- const { status, ...body } = failures[parsed.reason];
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
- return { app, attach, require: require2, client, config, getAccessToken };
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
- app.get("/profile", async (c) => c.json(await client.getProfile(await sso.getAccessToken(c))));
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.json(await client.updateProfile(token, body.name));
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.json(await client.uploadAvatar(token, bytes, type));
1162
+ return answer(c, await client.uploadAvatar(token, bytes, type));
1134
1163
  });
1135
- app.post("/profile/avatar/remove", async (c) => c.json(await client.removeAvatar(await sso.getAccessToken(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;