@broberg/sso 0.11.0 → 0.12.1

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
@@ -484,3 +485,42 @@ picks up a change the user made in BID itself.
484
485
 
485
486
  Mail, password, passkeys, two-factor and sessions stay in BID: link to the
486
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. A hook that throws is reported with `console.error` and the refusal still stands (0.12.1); it never becomes a 500.
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,17 @@ 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
+ try {
913
+ await onCallbackRefused(parsed.reason, c);
914
+ } catch (err) {
915
+ console.error("[@broberg/sso] onCallbackRefused threw; the refusal stands", err);
916
+ }
917
+ const shown = callbackErrors === "single" && parsed.reason !== "absent" ? {
918
+ status: 400,
919
+ error: "login_failed",
920
+ message: "This login could not be completed. Begin again from the login page."
921
+ } : failures[parsed.reason];
922
+ const { status, ...body } = shown;
911
923
  c.header("Set-Cookie", cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }));
912
924
  return c.json(body, status);
913
925
  }