@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1160
|
+
return answer(c, await client.uploadAvatar(token, bytes, type));
|
|
1132
1161
|
});
|
|
1133
|
-
app.post("/profile/avatar/remove", async (c) => 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;
|