@broberg/sso 0.9.0 → 0.10.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 +69 -0
- package/dist/hono.cjs +228 -5
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +48 -1
- package/dist/hono.d.ts +48 -1
- package/dist/hono.js +228 -6
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +136 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -1
- package/dist/index.d.ts +65 -1
- package/dist/index.js +131 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/hono.d.cts
CHANGED
|
@@ -139,6 +139,15 @@ interface Discovery {
|
|
|
139
139
|
userinfo_endpoint?: string;
|
|
140
140
|
end_session_endpoint?: string;
|
|
141
141
|
}
|
|
142
|
+
/** The signed-in user's own profile, as BID's GET /api/app/profile returns it. */
|
|
143
|
+
interface BidProfile {
|
|
144
|
+
sub: string;
|
|
145
|
+
name: string | null;
|
|
146
|
+
picture: string | null;
|
|
147
|
+
email: string | null;
|
|
148
|
+
/** BID's own account page — mail, password, passkeys, 2FA and sessions live there. */
|
|
149
|
+
account_url: string;
|
|
150
|
+
}
|
|
142
151
|
/**
|
|
143
152
|
* What `beginLogin` produces. Every field except `url` must survive the round
|
|
144
153
|
* trip to BID and back — the adapter stores them in a short-lived cookie.
|
|
@@ -244,6 +253,21 @@ interface SsoClient {
|
|
|
244
253
|
* Throws SsoError on anything that is not a valid, sub-only logout token.
|
|
245
254
|
*/
|
|
246
255
|
verifyLogoutToken(token: string): Promise<LogoutToken>;
|
|
256
|
+
/**
|
|
257
|
+
* F095.2 — the signed-in user's own profile (scope `profile`), with the user's
|
|
258
|
+
* access token. Throws SsoAppApiError on any refusal.
|
|
259
|
+
*/
|
|
260
|
+
getProfile(accessToken: string): Promise<BidProfile>;
|
|
261
|
+
/** F095.2 — change the user's own name (scope `profile:write`). Returns the new profile. */
|
|
262
|
+
updateProfile(accessToken: string, name: string): Promise<BidProfile>;
|
|
263
|
+
/**
|
|
264
|
+
* F095.2 — replace the user's picture (scope `profile:write`). Refuses more than
|
|
265
|
+
* MAX_AVATAR_BYTES or a type outside AVATAR_TYPES with SsoAvatarRejectedError
|
|
266
|
+
* BEFORE anything is sent.
|
|
267
|
+
*/
|
|
268
|
+
uploadAvatar(accessToken: string, bytes: Uint8Array | ArrayBuffer, contentType: string): Promise<BidProfile>;
|
|
269
|
+
/** F095.2 — remove the user's picture (scope `profile:write`). */
|
|
270
|
+
removeAvatar(accessToken: string): Promise<BidProfile>;
|
|
247
271
|
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
248
272
|
readonly jwks: JwksCache;
|
|
249
273
|
}
|
|
@@ -428,5 +452,28 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
|
428
452
|
type TransactionFailure = "absent" | "expired" | "unreadable";
|
|
429
453
|
/** Read the session a middleware attached. */
|
|
430
454
|
declare function getSession(c: Context): SessionPayload | null;
|
|
455
|
+
/** What accountRoutes needs from ssoRoutes — pass its return value as is. */
|
|
456
|
+
interface AccountRoutesInput {
|
|
457
|
+
client: Pick<SsoClient, "getProfile" | "updateProfile" | "uploadAvatar" | "removeAvatar">;
|
|
458
|
+
getAccessToken(c: Context): Promise<string>;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The signed-in user's own name and picture, for the app's account page.
|
|
462
|
+
* Mount it on /api/account:
|
|
463
|
+
*
|
|
464
|
+
* const sso = ssoRoutes({ tokenStore });
|
|
465
|
+
* app.route("/api/account", accountRoutes(sso));
|
|
466
|
+
*
|
|
467
|
+
* GET /profile · POST /profile {name} · POST /profile/avatar (raw image body) ·
|
|
468
|
+
* POST /profile/avatar/remove. Every answer is JSON, refusals included — it is
|
|
469
|
+
* an API, so never a login redirect:
|
|
470
|
+
* 401 {error:"unauthenticated"} no live session
|
|
471
|
+
* 401 {error:"reauth"} the session's tokens are spent — log in again
|
|
472
|
+
* 403 {error:"insufficient_scope", scope} log in again with that scope requested
|
|
473
|
+
* 413 / 415 picture too large / not PNG, JPEG or WebP
|
|
474
|
+
* 4xx {error:<BID's code>} BID refused the change (e.g. an invalid name)
|
|
475
|
+
* 502 {error:"bid_unavailable"} BID did not answer usably
|
|
476
|
+
*/
|
|
477
|
+
declare function accountRoutes(sso: AccountRoutesInput): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
431
478
|
|
|
432
|
-
export { type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
|
|
479
|
+
export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
|
package/dist/hono.d.ts
CHANGED
|
@@ -139,6 +139,15 @@ interface Discovery {
|
|
|
139
139
|
userinfo_endpoint?: string;
|
|
140
140
|
end_session_endpoint?: string;
|
|
141
141
|
}
|
|
142
|
+
/** The signed-in user's own profile, as BID's GET /api/app/profile returns it. */
|
|
143
|
+
interface BidProfile {
|
|
144
|
+
sub: string;
|
|
145
|
+
name: string | null;
|
|
146
|
+
picture: string | null;
|
|
147
|
+
email: string | null;
|
|
148
|
+
/** BID's own account page — mail, password, passkeys, 2FA and sessions live there. */
|
|
149
|
+
account_url: string;
|
|
150
|
+
}
|
|
142
151
|
/**
|
|
143
152
|
* What `beginLogin` produces. Every field except `url` must survive the round
|
|
144
153
|
* trip to BID and back — the adapter stores them in a short-lived cookie.
|
|
@@ -244,6 +253,21 @@ interface SsoClient {
|
|
|
244
253
|
* Throws SsoError on anything that is not a valid, sub-only logout token.
|
|
245
254
|
*/
|
|
246
255
|
verifyLogoutToken(token: string): Promise<LogoutToken>;
|
|
256
|
+
/**
|
|
257
|
+
* F095.2 — the signed-in user's own profile (scope `profile`), with the user's
|
|
258
|
+
* access token. Throws SsoAppApiError on any refusal.
|
|
259
|
+
*/
|
|
260
|
+
getProfile(accessToken: string): Promise<BidProfile>;
|
|
261
|
+
/** F095.2 — change the user's own name (scope `profile:write`). Returns the new profile. */
|
|
262
|
+
updateProfile(accessToken: string, name: string): Promise<BidProfile>;
|
|
263
|
+
/**
|
|
264
|
+
* F095.2 — replace the user's picture (scope `profile:write`). Refuses more than
|
|
265
|
+
* MAX_AVATAR_BYTES or a type outside AVATAR_TYPES with SsoAvatarRejectedError
|
|
266
|
+
* BEFORE anything is sent.
|
|
267
|
+
*/
|
|
268
|
+
uploadAvatar(accessToken: string, bytes: Uint8Array | ArrayBuffer, contentType: string): Promise<BidProfile>;
|
|
269
|
+
/** F095.2 — remove the user's picture (scope `profile:write`). */
|
|
270
|
+
removeAvatar(accessToken: string): Promise<BidProfile>;
|
|
247
271
|
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
248
272
|
readonly jwks: JwksCache;
|
|
249
273
|
}
|
|
@@ -428,5 +452,28 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
|
428
452
|
type TransactionFailure = "absent" | "expired" | "unreadable";
|
|
429
453
|
/** Read the session a middleware attached. */
|
|
430
454
|
declare function getSession(c: Context): SessionPayload | null;
|
|
455
|
+
/** What accountRoutes needs from ssoRoutes — pass its return value as is. */
|
|
456
|
+
interface AccountRoutesInput {
|
|
457
|
+
client: Pick<SsoClient, "getProfile" | "updateProfile" | "uploadAvatar" | "removeAvatar">;
|
|
458
|
+
getAccessToken(c: Context): Promise<string>;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The signed-in user's own name and picture, for the app's account page.
|
|
462
|
+
* Mount it on /api/account:
|
|
463
|
+
*
|
|
464
|
+
* const sso = ssoRoutes({ tokenStore });
|
|
465
|
+
* app.route("/api/account", accountRoutes(sso));
|
|
466
|
+
*
|
|
467
|
+
* GET /profile · POST /profile {name} · POST /profile/avatar (raw image body) ·
|
|
468
|
+
* POST /profile/avatar/remove. Every answer is JSON, refusals included — it is
|
|
469
|
+
* an API, so never a login redirect:
|
|
470
|
+
* 401 {error:"unauthenticated"} no live session
|
|
471
|
+
* 401 {error:"reauth"} the session's tokens are spent — log in again
|
|
472
|
+
* 403 {error:"insufficient_scope", scope} log in again with that scope requested
|
|
473
|
+
* 413 / 415 picture too large / not PNG, JPEG or WebP
|
|
474
|
+
* 4xx {error:<BID's code>} BID refused the change (e.g. an invalid name)
|
|
475
|
+
* 502 {error:"bid_unavailable"} BID did not answer usably
|
|
476
|
+
*/
|
|
477
|
+
declare function accountRoutes(sso: AccountRoutesInput): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
431
478
|
|
|
432
|
-
export { type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
|
|
479
|
+
export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
|
package/dist/hono.js
CHANGED
|
@@ -174,6 +174,45 @@ var SsoError = class extends Error {
|
|
|
174
174
|
this.name = "SsoError";
|
|
175
175
|
}
|
|
176
176
|
};
|
|
177
|
+
var SsoReauthError = class extends SsoError {
|
|
178
|
+
constructor(message, reason) {
|
|
179
|
+
super(message);
|
|
180
|
+
this.reason = reason;
|
|
181
|
+
this.name = "SsoReauthError";
|
|
182
|
+
}
|
|
183
|
+
reason;
|
|
184
|
+
};
|
|
185
|
+
var SsoAppApiError = class extends SsoError {
|
|
186
|
+
constructor(message, status, code) {
|
|
187
|
+
super(message);
|
|
188
|
+
this.status = status;
|
|
189
|
+
this.code = code;
|
|
190
|
+
this.name = "SsoAppApiError";
|
|
191
|
+
}
|
|
192
|
+
status;
|
|
193
|
+
code;
|
|
194
|
+
};
|
|
195
|
+
var SsoInsufficientScopeError = class extends SsoAppApiError {
|
|
196
|
+
constructor(message, scope) {
|
|
197
|
+
super(message, 403, "insufficient_scope");
|
|
198
|
+
this.scope = scope;
|
|
199
|
+
this.name = "SsoInsufficientScopeError";
|
|
200
|
+
}
|
|
201
|
+
scope;
|
|
202
|
+
};
|
|
203
|
+
var SsoAvatarRejectedError = class extends SsoError {
|
|
204
|
+
constructor(message, reason) {
|
|
205
|
+
super(message);
|
|
206
|
+
this.reason = reason;
|
|
207
|
+
this.name = "SsoAvatarRejectedError";
|
|
208
|
+
}
|
|
209
|
+
reason;
|
|
210
|
+
};
|
|
211
|
+
var MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
|
212
|
+
var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
213
|
+
function mediaType(contentType) {
|
|
214
|
+
return (contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
215
|
+
}
|
|
177
216
|
var b64url = (bytes) => {
|
|
178
217
|
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
179
218
|
let s = "";
|
|
@@ -265,6 +304,68 @@ function createSsoClient(config, options = {}) {
|
|
|
265
304
|
clearTimeout(timer);
|
|
266
305
|
}
|
|
267
306
|
}
|
|
307
|
+
async function appRequest(path, bearer, init, what) {
|
|
308
|
+
const url = new URL(path, config.issuer).toString();
|
|
309
|
+
const ctrl = new AbortController();
|
|
310
|
+
let timer;
|
|
311
|
+
const timedOut = new Promise((_, reject) => {
|
|
312
|
+
timer = setTimeout(() => {
|
|
313
|
+
reject(new SsoAppApiError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`, null, void 0));
|
|
314
|
+
ctrl.abort();
|
|
315
|
+
}, timeoutMs);
|
|
316
|
+
});
|
|
317
|
+
const headers = { authorization: `Bearer ${bearer}` };
|
|
318
|
+
let body;
|
|
319
|
+
if (init.json !== void 0) {
|
|
320
|
+
headers["content-type"] = "application/json";
|
|
321
|
+
body = JSON.stringify(init.json);
|
|
322
|
+
} else if (init.bytes !== void 0) {
|
|
323
|
+
headers["content-type"] = init.contentType ?? "application/octet-stream";
|
|
324
|
+
body = init.bytes;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const res = await Promise.race([
|
|
328
|
+
fetchImpl(url, { method: init.method, headers, ...body !== void 0 ? { body } : {}, signal: ctrl.signal }),
|
|
329
|
+
timedOut
|
|
330
|
+
]);
|
|
331
|
+
const raw = await Promise.race([res.text(), timedOut]);
|
|
332
|
+
let parsed;
|
|
333
|
+
try {
|
|
334
|
+
parsed = raw.trim() === "" ? void 0 : JSON.parse(raw);
|
|
335
|
+
} catch {
|
|
336
|
+
parsed = void 0;
|
|
337
|
+
}
|
|
338
|
+
const fields = parsed && typeof parsed === "object" ? parsed : {};
|
|
339
|
+
const code = typeof fields.error === "string" ? fields.error : void 0;
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
if (res.status === 403 && code === "insufficient_scope") {
|
|
342
|
+
const scope = typeof fields.scope === "string" && fields.scope ? fields.scope : init.method === "GET" ? "profile" : "profile:write";
|
|
343
|
+
throw new SsoInsufficientScopeError(
|
|
344
|
+
`${url} answered 403 insufficient_scope \u2014 the user's token lacks scope "${scope}"; log in again with it requested.`,
|
|
345
|
+
scope
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
throw new SsoAppApiError(`${url} answered ${res.status}${code ? ` (${code})` : ""} \u2014 ${what} is unknown.`, res.status, code);
|
|
349
|
+
}
|
|
350
|
+
if (parsed === void 0) {
|
|
351
|
+
throw new SsoAppApiError(`${url} answered ${res.status} with a body that is not JSON \u2014 ${what} is unknown.`, res.status, void 0);
|
|
352
|
+
}
|
|
353
|
+
return parsed;
|
|
354
|
+
} catch (e) {
|
|
355
|
+
if (e instanceof SsoError) throw e;
|
|
356
|
+
throw new SsoAppApiError(`${url} could not be reached (${e instanceof Error ? e.name : "error"}) \u2014 ${what} is unknown.`, null, void 0);
|
|
357
|
+
} finally {
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function asProfile(body, path) {
|
|
362
|
+
const b = body ?? {};
|
|
363
|
+
if (typeof b.sub !== "string" || b.sub === "" || typeof b.account_url !== "string") {
|
|
364
|
+
throw new SsoAppApiError(`${path} returned no "sub" or "account_url" \u2014 the profile is unknown.`, 200, void 0);
|
|
365
|
+
}
|
|
366
|
+
const opt = (v) => typeof v === "string" ? v : null;
|
|
367
|
+
return { sub: b.sub, name: opt(b.name), picture: opt(b.picture), email: opt(b.email), account_url: b.account_url };
|
|
368
|
+
}
|
|
268
369
|
let discoveryPromise = null;
|
|
269
370
|
let jwksCache = null;
|
|
270
371
|
async function discovery() {
|
|
@@ -508,6 +609,35 @@ function createSsoClient(config, options = {}) {
|
|
|
508
609
|
return status;
|
|
509
610
|
},
|
|
510
611
|
verifyLogoutToken,
|
|
612
|
+
async getProfile(accessToken) {
|
|
613
|
+
const path = "/api/app/profile";
|
|
614
|
+
return asProfile(await appRequest(path, accessToken, { method: "GET" }, "the profile"), path);
|
|
615
|
+
},
|
|
616
|
+
async updateProfile(accessToken, name) {
|
|
617
|
+
const path = "/api/app/profile";
|
|
618
|
+
return asProfile(await appRequest(path, accessToken, { method: "POST", json: { name } }, "the new name"), path);
|
|
619
|
+
},
|
|
620
|
+
async uploadAvatar(accessToken, bytes, contentType) {
|
|
621
|
+
const type = mediaType(contentType);
|
|
622
|
+
if (!AVATAR_TYPES.includes(type)) {
|
|
623
|
+
throw new SsoAvatarRejectedError(
|
|
624
|
+
`uploadAvatar: "${type}" is not accepted \u2014 BID takes ${AVATAR_TYPES.join(", ")}.`,
|
|
625
|
+
"unsupported_type"
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (bytes.byteLength > MAX_AVATAR_BYTES) {
|
|
629
|
+
throw new SsoAvatarRejectedError(
|
|
630
|
+
`uploadAvatar: ${bytes.byteLength} bytes \u2014 BID takes at most ${MAX_AVATAR_BYTES}.`,
|
|
631
|
+
"too_large"
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const path = "/api/app/profile/avatar";
|
|
635
|
+
return asProfile(await appRequest(path, accessToken, { method: "POST", bytes, contentType: type }, "the new picture"), path);
|
|
636
|
+
},
|
|
637
|
+
async removeAvatar(accessToken) {
|
|
638
|
+
const path = "/api/app/profile/avatar/remove";
|
|
639
|
+
return asProfile(await appRequest(path, accessToken, { method: "POST" }, "the removed picture"), path);
|
|
640
|
+
},
|
|
511
641
|
async inviteUsers(appKey, input) {
|
|
512
642
|
if (input.users.length > MAX_INVITATIONS) {
|
|
513
643
|
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
@@ -878,17 +1008,17 @@ function ssoRoutes(options = {}) {
|
|
|
878
1008
|
const live = await liveSession(c);
|
|
879
1009
|
if (!live) {
|
|
880
1010
|
if (raw?.sid) await tokenStore.delete(raw.sid);
|
|
881
|
-
throw new
|
|
1011
|
+
throw new SsoReauthError("no live session \u2014 log in again.", "no_session");
|
|
882
1012
|
}
|
|
883
1013
|
const sid = live.sid;
|
|
884
|
-
if (!sid) throw new
|
|
1014
|
+
if (!sid) throw new SsoReauthError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.", "no_tokens");
|
|
885
1015
|
const tokens = await tokenStore.get(sid);
|
|
886
|
-
if (!tokens) throw new
|
|
1016
|
+
if (!tokens) throw new SsoReauthError("no tokens stored for this session \u2014 log in again.", "no_tokens");
|
|
887
1017
|
const now = Math.floor(Date.now() / 1e3);
|
|
888
1018
|
if (tokens.expiresAt - 30 > now) return tokens.accessToken;
|
|
889
1019
|
if (!tokens.refreshToken) {
|
|
890
1020
|
await tokenStore.delete(sid);
|
|
891
|
-
throw new
|
|
1021
|
+
throw new SsoReauthError("the access token has expired and there is no refresh token \u2014 log in again.", "expired");
|
|
892
1022
|
}
|
|
893
1023
|
const inFlight = refreshing.get(sid);
|
|
894
1024
|
if (inFlight) return inFlight;
|
|
@@ -898,7 +1028,7 @@ function ssoRoutes(options = {}) {
|
|
|
898
1028
|
next = await client.refreshTokens(tokens.refreshToken);
|
|
899
1029
|
} catch (e) {
|
|
900
1030
|
await tokenStore.delete(sid);
|
|
901
|
-
throw e instanceof SsoError ? e :
|
|
1031
|
+
throw new SsoReauthError(e instanceof SsoError ? e.message : "token refresh failed", "refresh_failed");
|
|
902
1032
|
}
|
|
903
1033
|
await tokenStore.set(sid, {
|
|
904
1034
|
sub: tokens.sub,
|
|
@@ -935,7 +1065,99 @@ async function parseTransaction(raw, secret) {
|
|
|
935
1065
|
function getSession(c) {
|
|
936
1066
|
return c.get(SESSION_KEY) ?? null;
|
|
937
1067
|
}
|
|
1068
|
+
function errName(e) {
|
|
1069
|
+
return e instanceof Error ? e.name : void 0;
|
|
1070
|
+
}
|
|
1071
|
+
async function readCapped(c, max) {
|
|
1072
|
+
const stream = c.req.raw.body;
|
|
1073
|
+
if (!stream) return new Uint8Array(0);
|
|
1074
|
+
const reader = stream.getReader();
|
|
1075
|
+
const chunks = [];
|
|
1076
|
+
let size = 0;
|
|
1077
|
+
for (; ; ) {
|
|
1078
|
+
const { done, value } = await reader.read();
|
|
1079
|
+
if (done) break;
|
|
1080
|
+
size += value.byteLength;
|
|
1081
|
+
if (size > max) {
|
|
1082
|
+
await reader.cancel().catch(() => {
|
|
1083
|
+
});
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
chunks.push(value);
|
|
1087
|
+
}
|
|
1088
|
+
const out = new Uint8Array(size);
|
|
1089
|
+
let at = 0;
|
|
1090
|
+
for (const ch of chunks) {
|
|
1091
|
+
out.set(ch, at);
|
|
1092
|
+
at += ch.byteLength;
|
|
1093
|
+
}
|
|
1094
|
+
return out;
|
|
1095
|
+
}
|
|
1096
|
+
function accountRoutes(sso) {
|
|
1097
|
+
const app = new Hono();
|
|
1098
|
+
const { client } = sso;
|
|
1099
|
+
app.use("*", async (c, next) => {
|
|
1100
|
+
c.header("Cache-Control", "no-store");
|
|
1101
|
+
await next();
|
|
1102
|
+
});
|
|
1103
|
+
app.use("*", async (c, next) => {
|
|
1104
|
+
const site = c.req.header("sec-fetch-site");
|
|
1105
|
+
if (c.req.method !== "GET" && site !== void 0 && site !== "same-origin" && site !== "none") {
|
|
1106
|
+
return c.json({ error: "cross_site" }, 403);
|
|
1107
|
+
}
|
|
1108
|
+
await next();
|
|
1109
|
+
});
|
|
1110
|
+
app.get("/profile", async (c) => c.json(await client.getProfile(await sso.getAccessToken(c))));
|
|
1111
|
+
app.post("/profile", async (c) => {
|
|
1112
|
+
const token = await sso.getAccessToken(c);
|
|
1113
|
+
const body = await c.req.json().catch(() => null);
|
|
1114
|
+
if (typeof body?.name !== "string") {
|
|
1115
|
+
return c.json({ error: "invalid_request", error_description: '"name" must be a string' }, 400);
|
|
1116
|
+
}
|
|
1117
|
+
return c.json(await client.updateProfile(token, body.name));
|
|
1118
|
+
});
|
|
1119
|
+
app.post("/profile/avatar", async (c) => {
|
|
1120
|
+
const token = await sso.getAccessToken(c);
|
|
1121
|
+
const type = mediaType(c.req.header("content-type"));
|
|
1122
|
+
if (!AVATAR_TYPES.includes(type)) {
|
|
1123
|
+
return c.json({ error: "unsupported_type", accepted: AVATAR_TYPES }, 415);
|
|
1124
|
+
}
|
|
1125
|
+
const declared = Number(c.req.header("content-length"));
|
|
1126
|
+
if (Number.isFinite(declared) && declared > MAX_AVATAR_BYTES) {
|
|
1127
|
+
return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
|
|
1128
|
+
}
|
|
1129
|
+
const bytes = await readCapped(c, MAX_AVATAR_BYTES);
|
|
1130
|
+
if (!bytes) return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
|
|
1131
|
+
return c.json(await client.uploadAvatar(token, bytes, type));
|
|
1132
|
+
});
|
|
1133
|
+
app.post("/profile/avatar/remove", async (c) => c.json(await client.removeAvatar(await sso.getAccessToken(c))));
|
|
1134
|
+
app.onError((err, c) => {
|
|
1135
|
+
c.header("Cache-Control", "no-store");
|
|
1136
|
+
const e = err;
|
|
1137
|
+
switch (errName(err)) {
|
|
1138
|
+
case "SsoReauthError":
|
|
1139
|
+
return c.json({ error: e.reason === "no_session" ? "unauthenticated" : "reauth" }, 401);
|
|
1140
|
+
case "SsoInsufficientScopeError":
|
|
1141
|
+
return c.json({ error: "insufficient_scope", scope: e.scope }, 403);
|
|
1142
|
+
case "SsoAvatarRejectedError":
|
|
1143
|
+
return e.reason === "too_large" ? c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413) : c.json({ error: "unsupported_type", accepted: AVATAR_TYPES }, 415);
|
|
1144
|
+
case "SsoAppApiError": {
|
|
1145
|
+
if (e.status === 401) return c.json({ error: "reauth" }, 401);
|
|
1146
|
+
if (typeof e.status === "number" && e.status >= 400 && e.status < 500) {
|
|
1147
|
+
return c.json({ error: e.code ?? "bid_refused" }, e.status);
|
|
1148
|
+
}
|
|
1149
|
+
return c.json({ error: "bid_unavailable" }, 502);
|
|
1150
|
+
}
|
|
1151
|
+
// A plain SsoError here is a setup mistake (e.g. ssoRoutes without tokenStore).
|
|
1152
|
+
case "SsoError":
|
|
1153
|
+
return c.json({ error: "sso_misconfigured" }, 500);
|
|
1154
|
+
default:
|
|
1155
|
+
return c.json({ error: "server_error" }, 500);
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
return app;
|
|
1159
|
+
}
|
|
938
1160
|
|
|
939
|
-
export { getSession, ssoRoutes };
|
|
1161
|
+
export { accountRoutes, getSession, ssoRoutes };
|
|
940
1162
|
//# sourceMappingURL=hono.js.map
|
|
941
1163
|
//# sourceMappingURL=hono.js.map
|