@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/dist/hono.d.cts CHANGED
@@ -439,7 +439,28 @@ interface SsoRoutesOptions {
439
439
  * Without it, login behaves exactly as in 0.8.0 and the tokens are dropped.
440
440
  */
441
441
  tokenStore?: TokenStore;
442
+ /**
443
+ * What the BROWSER is told when /callback refuses a login (F084.55, 0.12.0).
444
+ * "single" (default): an expired login and an unreadable cookie get the SAME
445
+ * answer, `login_failed`. The two differ only in whether the cookie's
446
+ * signature held against the current secret, and a stranger holding a
447
+ * FOUND cookie must not be able to ask that.
448
+ * "granular": the 0.3.0–0.11.0 behaviour — `login_expired` and
449
+ * `bad_login_cookie` told apart. For an app that knows its threat model
450
+ * (e.g. internal, behind a VPN) and wants «dit login udløb» in the browser.
451
+ * `no_login_in_progress` is its own answer either way: it depends on no secret.
452
+ */
453
+ callbackErrors?: "single" | "granular";
454
+ /**
455
+ * Receives the PRECISE cause of every refused callback, whatever the browser
456
+ * was told — this is the operator's channel. `unreadable` on a real user's
457
+ * browser is what a rotated SSO_COOKIE_SECRET looks like from outside.
458
+ * Without it, the cause is written with console.warn so the log still has it.
459
+ */
460
+ onCallbackRefused?: (cause: CallbackRefusal, c: Context) => void | Promise<void>;
442
461
  }
462
+ /** Why /callback refused: no login started here · too old · signature did not hold. */
463
+ type CallbackRefusal = "absent" | "expired" | "unreadable";
443
464
  declare function ssoRoutes(options?: SsoRoutesOptions): {
444
465
  app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
445
466
  attach: MiddlewareHandler;
@@ -447,6 +468,10 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
447
468
  client: SsoClient;
448
469
  config: SsoConfig;
449
470
  getAccessToken: (c: Context) => Promise<string>;
471
+ refreshSessionProfile: (c: Context, profile: {
472
+ name: string | null;
473
+ picture: string | null;
474
+ }) => Promise<void>;
450
475
  };
451
476
  /** Why a login transaction could not be read back. Three states, three answers. */
452
477
  type TransactionFailure = "absent" | "expired" | "unreadable";
@@ -456,6 +481,15 @@ declare function getSession(c: Context): SessionPayload | null;
456
481
  interface AccountRoutesInput {
457
482
  client: Pick<SsoClient, "getProfile" | "updateProfile" | "uploadAvatar" | "removeAvatar">;
458
483
  getAccessToken(c: Context): Promise<string>;
484
+ /**
485
+ * F095.5 — re-sign the session cookie with the profile BID answered, so the
486
+ * user menu shows the new name and picture on the next page load. ssoRoutes
487
+ * provides it; optional so an older or hand-built input still works.
488
+ */
489
+ refreshSessionProfile?(c: Context, profile: {
490
+ name: string | null;
491
+ picture: string | null;
492
+ }): Promise<void>;
459
493
  }
460
494
  /**
461
495
  * The signed-in user's own name and picture, for the app's account page.
@@ -476,4 +510,4 @@ interface AccountRoutesInput {
476
510
  */
477
511
  declare function accountRoutes(sso: AccountRoutesInput): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
478
512
 
479
- export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
513
+ export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type CallbackRefusal, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
package/dist/hono.d.ts CHANGED
@@ -439,7 +439,28 @@ interface SsoRoutesOptions {
439
439
  * Without it, login behaves exactly as in 0.8.0 and the tokens are dropped.
440
440
  */
441
441
  tokenStore?: TokenStore;
442
+ /**
443
+ * What the BROWSER is told when /callback refuses a login (F084.55, 0.12.0).
444
+ * "single" (default): an expired login and an unreadable cookie get the SAME
445
+ * answer, `login_failed`. The two differ only in whether the cookie's
446
+ * signature held against the current secret, and a stranger holding a
447
+ * FOUND cookie must not be able to ask that.
448
+ * "granular": the 0.3.0–0.11.0 behaviour — `login_expired` and
449
+ * `bad_login_cookie` told apart. For an app that knows its threat model
450
+ * (e.g. internal, behind a VPN) and wants «dit login udløb» in the browser.
451
+ * `no_login_in_progress` is its own answer either way: it depends on no secret.
452
+ */
453
+ callbackErrors?: "single" | "granular";
454
+ /**
455
+ * Receives the PRECISE cause of every refused callback, whatever the browser
456
+ * was told — this is the operator's channel. `unreadable` on a real user's
457
+ * browser is what a rotated SSO_COOKIE_SECRET looks like from outside.
458
+ * Without it, the cause is written with console.warn so the log still has it.
459
+ */
460
+ onCallbackRefused?: (cause: CallbackRefusal, c: Context) => void | Promise<void>;
442
461
  }
462
+ /** Why /callback refused: no login started here · too old · signature did not hold. */
463
+ type CallbackRefusal = "absent" | "expired" | "unreadable";
443
464
  declare function ssoRoutes(options?: SsoRoutesOptions): {
444
465
  app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
445
466
  attach: MiddlewareHandler;
@@ -447,6 +468,10 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
447
468
  client: SsoClient;
448
469
  config: SsoConfig;
449
470
  getAccessToken: (c: Context) => Promise<string>;
471
+ refreshSessionProfile: (c: Context, profile: {
472
+ name: string | null;
473
+ picture: string | null;
474
+ }) => Promise<void>;
450
475
  };
451
476
  /** Why a login transaction could not be read back. Three states, three answers. */
452
477
  type TransactionFailure = "absent" | "expired" | "unreadable";
@@ -456,6 +481,15 @@ declare function getSession(c: Context): SessionPayload | null;
456
481
  interface AccountRoutesInput {
457
482
  client: Pick<SsoClient, "getProfile" | "updateProfile" | "uploadAvatar" | "removeAvatar">;
458
483
  getAccessToken(c: Context): Promise<string>;
484
+ /**
485
+ * F095.5 — re-sign the session cookie with the profile BID answered, so the
486
+ * user menu shows the new name and picture on the next page load. ssoRoutes
487
+ * provides it; optional so an older or hand-built input still works.
488
+ */
489
+ refreshSessionProfile?(c: Context, profile: {
490
+ name: string | null;
491
+ picture: string | null;
492
+ }): Promise<void>;
459
493
  }
460
494
  /**
461
495
  * The signed-in user's own name and picture, for the app's account page.
@@ -476,4 +510,4 @@ interface AccountRoutesInput {
476
510
  */
477
511
  declare function accountRoutes(sso: AccountRoutesInput): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
478
512
 
479
- export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
513
+ export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type CallbackRefusal, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
package/dist/hono.js CHANGED
@@ -819,6 +819,8 @@ function ssoRoutes(options = {}) {
819
819
  }
820
820
  const onStoreError = backchannel?.onStoreError ?? "reject";
821
821
  const tokenStore = options.tokenStore;
822
+ const callbackErrors = options.callbackErrors ?? "single";
823
+ const onCallbackRefused = options.onCallbackRefused ?? ((cause) => console.warn(`[@broberg/sso] /callback refused: ${cause}`));
822
824
  async function liveSession(c) {
823
825
  const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
824
826
  if (!session || !backchannel) return session;
@@ -905,7 +907,13 @@ function ssoRoutes(options = {}) {
905
907
  message: "This browser's login cookie could not be read. Begin again from the login page."
906
908
  }
907
909
  };
908
- const { status, ...body } = failures[parsed.reason];
910
+ await onCallbackRefused(parsed.reason, c);
911
+ const shown = callbackErrors === "single" && parsed.reason !== "absent" ? {
912
+ status: 400,
913
+ error: "login_failed",
914
+ message: "This login could not be completed. Begin again from the login page."
915
+ } : failures[parsed.reason];
916
+ const { status, ...body } = shown;
909
917
  c.header("Set-Cookie", cookieHeader(txCookie, "", { maxAge: 0, secure: isSecure(c) }));
910
918
  return c.json(body, status);
911
919
  }
@@ -1046,7 +1054,24 @@ function ssoRoutes(options = {}) {
1046
1054
  refreshing.delete(sid);
1047
1055
  }
1048
1056
  }
1049
- return { app, attach, require: require2, client, config, getAccessToken };
1057
+ async function refreshSessionProfile(c, profile) {
1058
+ const live = await liveSession(c);
1059
+ if (!live) return;
1060
+ const next = { ...live };
1061
+ if (profile.name) next.name = profile.name;
1062
+ else delete next.name;
1063
+ if (profile.picture) next.picture = profile.picture;
1064
+ else delete next.picture;
1065
+ if (next.name === live.name && next.picture === live.picture) return;
1066
+ const left = live.exp - Math.floor(Date.now() / 1e3);
1067
+ if (left <= 0) return;
1068
+ c.header(
1069
+ "Set-Cookie",
1070
+ cookieHeader(config.cookieName, await signSession(next, config.cookieSecret), { maxAge: left, secure: isSecure(c) }),
1071
+ { append: true }
1072
+ );
1073
+ }
1074
+ return { app, attach, require: require2, client, config, getAccessToken, refreshSessionProfile };
1050
1075
  }
1051
1076
  async function parseTransaction(raw, secret) {
1052
1077
  if (raw === void 0) return { ok: false, reason: "absent" };
@@ -1107,14 +1132,18 @@ function accountRoutes(sso) {
1107
1132
  }
1108
1133
  await next();
1109
1134
  });
1110
- app.get("/profile", async (c) => c.json(await client.getProfile(await sso.getAccessToken(c))));
1135
+ const answer = async (c, profile) => {
1136
+ if (sso.refreshSessionProfile) await sso.refreshSessionProfile(c, profile);
1137
+ return c.json(profile);
1138
+ };
1139
+ app.get("/profile", async (c) => answer(c, await client.getProfile(await sso.getAccessToken(c))));
1111
1140
  app.post("/profile", async (c) => {
1112
1141
  const token = await sso.getAccessToken(c);
1113
1142
  const body = await c.req.json().catch(() => null);
1114
1143
  if (typeof body?.name !== "string") {
1115
1144
  return c.json({ error: "invalid_request", error_description: '"name" must be a string' }, 400);
1116
1145
  }
1117
- return c.json(await client.updateProfile(token, body.name));
1146
+ return answer(c, await client.updateProfile(token, body.name));
1118
1147
  });
1119
1148
  app.post("/profile/avatar", async (c) => {
1120
1149
  const token = await sso.getAccessToken(c);
@@ -1128,9 +1157,9 @@ function accountRoutes(sso) {
1128
1157
  }
1129
1158
  const bytes = await readCapped(c, MAX_AVATAR_BYTES);
1130
1159
  if (!bytes) return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
1131
- return c.json(await client.uploadAvatar(token, bytes, type));
1160
+ return answer(c, await client.uploadAvatar(token, bytes, type));
1132
1161
  });
1133
- app.post("/profile/avatar/remove", async (c) => c.json(await client.removeAvatar(await sso.getAccessToken(c))));
1162
+ app.post("/profile/avatar/remove", async (c) => answer(c, await client.removeAvatar(await sso.getAccessToken(c))));
1134
1163
  app.onError((err, c) => {
1135
1164
  c.header("Cache-Control", "no-store");
1136
1165
  const e = err;