@broberg/sso 0.10.0 → 0.11.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 +31 -9
- package/dist/hono.cjs +26 -5
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +13 -0
- package/dist/hono.d.ts +13 -0
- package/dist/hono.js +26 -5
- package/dist/hono.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -395,8 +395,9 @@ app.get("/api/something", sso.require, async (c) => {
|
|
|
395
395
|
Delete rows older than `sessionMaxAge` (`SSO_SESSION_MAX_AGE`, default 7 days)
|
|
396
396
|
with a periodic job or a TTL — e.g. store a `createdAt` beside the row and
|
|
397
397
|
`DELETE FROM tokens WHERE created_at < now - sessionMaxAge`, or a Redis
|
|
398
|
-
`EX sessionMaxAge`. It matters because the row
|
|
399
|
-
|
|
398
|
+
`EX sessionMaxAge`. It matters because with `offline_access` the row holds a
|
|
399
|
+
**refresh token**, and a forgotten refresh token is a live credential until
|
|
400
|
+
the user's BID sign-in ends.
|
|
400
401
|
- **Since 0.10.0 the failures are `SsoReauthError`** (a subclass of `SsoError`,
|
|
401
402
|
so existing `catch` blocks still match) with a `reason`: `no_session`,
|
|
402
403
|
`no_tokens`, `expired` or `refresh_failed`. Every one of them means "log in
|
|
@@ -444,13 +445,26 @@ logged in before you added it gets `403 insufficient_scope` until she logs in ag
|
|
|
444
445
|
| 4xx | `{"error":"<BID's code>"}` | BID refused the value (e.g. an invalid name) |
|
|
445
446
|
| 502 | `{"error":"bid_unavailable"}` | BID did not answer usably |
|
|
446
447
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
448
|
+
**Renewal needs `offline_access`, in two places.** BID's access token lives one
|
|
449
|
+
hour. Since 2026-10-06 (broberg-id F084.156) BID issues a **refresh token**, but
|
|
450
|
+
only when your app's BID registration has `offline_access` (ask broberg-id, per
|
|
451
|
+
app) **and** you request it at login:
|
|
452
|
+
`SSO_SCOPES="openid profile email profile:write offline_access"`. Then
|
|
453
|
+
`getAccessToken` renews by itself and the account page keeps working.
|
|
454
|
+
- **Without `offline_access`** there is no refresh token. An hour after login,
|
|
455
|
+
every account-page call answers `401 {"error":"reauth"}`.
|
|
456
|
+
- **With it, renewal still ends with the user's BID sign-in.** Log ud, «Log ud
|
|
457
|
+
overalt», a new password or expiry make BID answer the renewal with
|
|
458
|
+
`400 invalid_grant`, and that also becomes `401 {"error":"reauth"}`.
|
|
459
|
+
- **Rotation:** every renewal issues a new refresh token, and presenting the old
|
|
460
|
+
one again revokes the whole chain. `getAccessToken` renews one session at a
|
|
461
|
+
time **per process**. On several machines, a race costs one «Log ind igen»,
|
|
462
|
+
never a stale token.
|
|
463
|
+
|
|
464
|
+
So `reauth` is a normal state, not an edge case. Build the page for it: a calm
|
|
465
|
+
«Log ind igen» that goes to `/auth/login?returnTo=<the account page>` (no
|
|
466
|
+
`prompt=login`), not an error. The same answer comes back when BID refuses the
|
|
467
|
+
access token itself (a 401 from BID). It is never a 500.
|
|
454
468
|
|
|
455
469
|
**The core methods**, if you are not on Hono: `client.getProfile(token)`,
|
|
456
470
|
`updateProfile(token, name)`, `uploadAvatar(token, bytes, contentType)`,
|
|
@@ -460,5 +474,13 @@ from BID — revoked, or the session was ended in BID). It is never a 500.
|
|
|
460
474
|
`SsoAppApiError` (`.status`, BID's `.code`; `status` is null when BID did not
|
|
461
475
|
answer). All extend `SsoError`. No message ever contains a token.
|
|
462
476
|
|
|
477
|
+
**The user menu follows (since 0.11.0, F095.5).** Every successful
|
|
478
|
+
`accountRoutes` answer re-signs the session cookie with the name and picture BID
|
|
479
|
+
returned (`ssoRoutes().refreshSessionProfile`). sub, sid, iat and exp are kept,
|
|
480
|
+
and Max-Age is what is left of the session: the lifetime is never extended.
|
|
481
|
+
Nothing is written when nothing changed, and nothing is written on a refusal.
|
|
482
|
+
A reload therefore shows the new name in the menu without a new login. A GET also
|
|
483
|
+
picks up a change the user made in BID itself.
|
|
484
|
+
|
|
463
485
|
Mail, password, passkeys, two-factor and sessions stay in BID: link to the
|
|
464
486
|
profile's `account_url` in a new tab.
|
package/dist/hono.cjs
CHANGED
|
@@ -1048,7 +1048,24 @@ function ssoRoutes(options = {}) {
|
|
|
1048
1048
|
refreshing.delete(sid);
|
|
1049
1049
|
}
|
|
1050
1050
|
}
|
|
1051
|
-
|
|
1051
|
+
async function refreshSessionProfile(c, profile) {
|
|
1052
|
+
const live = await liveSession(c);
|
|
1053
|
+
if (!live) return;
|
|
1054
|
+
const next = { ...live };
|
|
1055
|
+
if (profile.name) next.name = profile.name;
|
|
1056
|
+
else delete next.name;
|
|
1057
|
+
if (profile.picture) next.picture = profile.picture;
|
|
1058
|
+
else delete next.picture;
|
|
1059
|
+
if (next.name === live.name && next.picture === live.picture) return;
|
|
1060
|
+
const left = live.exp - Math.floor(Date.now() / 1e3);
|
|
1061
|
+
if (left <= 0) return;
|
|
1062
|
+
c.header(
|
|
1063
|
+
"Set-Cookie",
|
|
1064
|
+
cookieHeader(config.cookieName, await signSession(next, config.cookieSecret), { maxAge: left, secure: isSecure(c) }),
|
|
1065
|
+
{ append: true }
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
return { app, attach, require: require2, client, config, getAccessToken, refreshSessionProfile };
|
|
1052
1069
|
}
|
|
1053
1070
|
async function parseTransaction(raw, secret) {
|
|
1054
1071
|
if (raw === void 0) return { ok: false, reason: "absent" };
|
|
@@ -1109,14 +1126,18 @@ function accountRoutes(sso) {
|
|
|
1109
1126
|
}
|
|
1110
1127
|
await next();
|
|
1111
1128
|
});
|
|
1112
|
-
|
|
1129
|
+
const answer = async (c, profile) => {
|
|
1130
|
+
if (sso.refreshSessionProfile) await sso.refreshSessionProfile(c, profile);
|
|
1131
|
+
return c.json(profile);
|
|
1132
|
+
};
|
|
1133
|
+
app.get("/profile", async (c) => answer(c, await client.getProfile(await sso.getAccessToken(c))));
|
|
1113
1134
|
app.post("/profile", async (c) => {
|
|
1114
1135
|
const token = await sso.getAccessToken(c);
|
|
1115
1136
|
const body = await c.req.json().catch(() => null);
|
|
1116
1137
|
if (typeof body?.name !== "string") {
|
|
1117
1138
|
return c.json({ error: "invalid_request", error_description: '"name" must be a string' }, 400);
|
|
1118
1139
|
}
|
|
1119
|
-
return c
|
|
1140
|
+
return answer(c, await client.updateProfile(token, body.name));
|
|
1120
1141
|
});
|
|
1121
1142
|
app.post("/profile/avatar", async (c) => {
|
|
1122
1143
|
const token = await sso.getAccessToken(c);
|
|
@@ -1130,9 +1151,9 @@ function accountRoutes(sso) {
|
|
|
1130
1151
|
}
|
|
1131
1152
|
const bytes = await readCapped(c, MAX_AVATAR_BYTES);
|
|
1132
1153
|
if (!bytes) return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
|
|
1133
|
-
return c
|
|
1154
|
+
return answer(c, await client.uploadAvatar(token, bytes, type));
|
|
1134
1155
|
});
|
|
1135
|
-
app.post("/profile/avatar/remove", async (c) => c
|
|
1156
|
+
app.post("/profile/avatar/remove", async (c) => answer(c, await client.removeAvatar(await sso.getAccessToken(c))));
|
|
1136
1157
|
app.onError((err, c) => {
|
|
1137
1158
|
c.header("Cache-Control", "no-store");
|
|
1138
1159
|
const e = err;
|