@broberg/sso 0.8.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 +113 -0
- package/dist/hono.cjs +341 -4
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +108 -1
- package/dist/hono.d.ts +108 -1
- package/dist/hono.js +341 -5
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +196 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +129 -1
- package/dist/index.d.ts +129 -1
- package/dist/index.js +190 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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() {
|
|
@@ -448,7 +549,36 @@ function createSsoClient(config, options = {}) {
|
|
|
448
549
|
unverifiedClaims: merged.unverifiedClaims,
|
|
449
550
|
idToken: body.id_token,
|
|
450
551
|
...body.access_token ? { accessToken: body.access_token } : {},
|
|
451
|
-
...body.refresh_token ? { refreshToken: body.refresh_token } : {}
|
|
552
|
+
...body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
553
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
554
|
+
};
|
|
555
|
+
},
|
|
556
|
+
async refreshTokens(refreshToken) {
|
|
557
|
+
const { token_endpoint } = await discovery();
|
|
558
|
+
const res = await fetchImpl(token_endpoint, {
|
|
559
|
+
method: "POST",
|
|
560
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
561
|
+
body: new URLSearchParams({
|
|
562
|
+
grant_type: "refresh_token",
|
|
563
|
+
refresh_token: refreshToken,
|
|
564
|
+
client_id: config.clientId,
|
|
565
|
+
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
566
|
+
})
|
|
567
|
+
});
|
|
568
|
+
const raw = await res.text();
|
|
569
|
+
let body;
|
|
570
|
+
try {
|
|
571
|
+
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
572
|
+
} catch {
|
|
573
|
+
throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
|
|
574
|
+
}
|
|
575
|
+
if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
|
|
576
|
+
throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
accessToken: body.access_token,
|
|
580
|
+
...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
581
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
452
582
|
};
|
|
453
583
|
},
|
|
454
584
|
async logoutUrl(opts = {}) {
|
|
@@ -479,6 +609,35 @@ function createSsoClient(config, options = {}) {
|
|
|
479
609
|
return status;
|
|
480
610
|
},
|
|
481
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
|
+
},
|
|
482
641
|
async inviteUsers(appKey, input) {
|
|
483
642
|
if (input.users.length > MAX_INVITATIONS) {
|
|
484
643
|
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
@@ -509,6 +668,26 @@ function createSsoClient(config, options = {}) {
|
|
|
509
668
|
};
|
|
510
669
|
}
|
|
511
670
|
|
|
671
|
+
// src/tokens.ts
|
|
672
|
+
function expiresAtFrom(expiresIn, accessToken, now) {
|
|
673
|
+
if (typeof expiresIn === "number" && expiresIn > 0) return now + Math.floor(expiresIn);
|
|
674
|
+
const parts = accessToken.split(".");
|
|
675
|
+
if (parts.length === 3) {
|
|
676
|
+
try {
|
|
677
|
+
const json = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
678
|
+
if (typeof json.exp === "number" && json.exp > now) return json.exp;
|
|
679
|
+
} catch {
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return now + 300;
|
|
683
|
+
}
|
|
684
|
+
function newSid() {
|
|
685
|
+
const b = crypto.getRandomValues(new Uint8Array(16));
|
|
686
|
+
let s = "";
|
|
687
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
688
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
689
|
+
}
|
|
690
|
+
|
|
512
691
|
// src/session.ts
|
|
513
692
|
var enc = new TextEncoder();
|
|
514
693
|
var b64url2 = (bytes) => {
|
|
@@ -639,6 +818,7 @@ function ssoRoutes(options = {}) {
|
|
|
639
818
|
throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
|
|
640
819
|
}
|
|
641
820
|
const onStoreError = backchannel?.onStoreError ?? "reject";
|
|
821
|
+
const tokenStore = options.tokenStore;
|
|
642
822
|
async function liveSession(c) {
|
|
643
823
|
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
644
824
|
if (!session || !backchannel) return session;
|
|
@@ -671,6 +851,7 @@ function ssoRoutes(options = {}) {
|
|
|
671
851
|
}
|
|
672
852
|
try {
|
|
673
853
|
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
854
|
+
if (tokenStore) await tokenStore.deleteSub(verified.sub);
|
|
674
855
|
await backchannel.store.useJti(verified.jti, verified.exp);
|
|
675
856
|
} catch {
|
|
676
857
|
return c.json({ error: "store_unavailable" }, 503);
|
|
@@ -734,11 +915,23 @@ function ssoRoutes(options = {}) {
|
|
|
734
915
|
codeVerifier: parsed.tx.codeVerifier,
|
|
735
916
|
nonce: parsed.tx.nonce
|
|
736
917
|
});
|
|
737
|
-
const
|
|
918
|
+
const nowS = Math.floor(Date.now() / 1e3);
|
|
919
|
+
const exp = nowS + config.sessionMaxAge;
|
|
920
|
+
let sid;
|
|
921
|
+
if (tokenStore && result.accessToken) {
|
|
922
|
+
sid = newSid();
|
|
923
|
+
await tokenStore.set(sid, {
|
|
924
|
+
sub: result.claims.sub,
|
|
925
|
+
accessToken: result.accessToken,
|
|
926
|
+
...result.refreshToken ? { refreshToken: result.refreshToken } : {},
|
|
927
|
+
expiresAt: expiresAtFrom(result.expiresIn, result.accessToken, nowS)
|
|
928
|
+
});
|
|
929
|
+
}
|
|
738
930
|
const session = {
|
|
739
931
|
sub: result.claims.sub,
|
|
740
932
|
exp,
|
|
741
|
-
iat:
|
|
933
|
+
iat: nowS,
|
|
934
|
+
...sid ? { sid } : {},
|
|
742
935
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
743
936
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
744
937
|
...result.claims.picture ? { picture: result.claims.picture } : {},
|
|
@@ -772,6 +965,10 @@ function ssoRoutes(options = {}) {
|
|
|
772
965
|
readCookie(c.req.header("cookie"), idTokenCookie),
|
|
773
966
|
config.cookieSecret
|
|
774
967
|
);
|
|
968
|
+
if (tokenStore) {
|
|
969
|
+
const leaving = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
970
|
+
if (leaving?.sid) await tokenStore.delete(leaving.sid);
|
|
971
|
+
}
|
|
775
972
|
c.header(
|
|
776
973
|
"Set-Cookie",
|
|
777
974
|
cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
|
|
@@ -802,7 +999,54 @@ function ssoRoutes(options = {}) {
|
|
|
802
999
|
c.set(SESSION_KEY, session);
|
|
803
1000
|
await next();
|
|
804
1001
|
};
|
|
805
|
-
|
|
1002
|
+
const refreshing = /* @__PURE__ */ new Map();
|
|
1003
|
+
async function getAccessToken(c) {
|
|
1004
|
+
if (!tokenStore) {
|
|
1005
|
+
throw new SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
|
|
1006
|
+
}
|
|
1007
|
+
const raw = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
1008
|
+
const live = await liveSession(c);
|
|
1009
|
+
if (!live) {
|
|
1010
|
+
if (raw?.sid) await tokenStore.delete(raw.sid);
|
|
1011
|
+
throw new SsoReauthError("no live session \u2014 log in again.", "no_session");
|
|
1012
|
+
}
|
|
1013
|
+
const sid = live.sid;
|
|
1014
|
+
if (!sid) throw new SsoReauthError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.", "no_tokens");
|
|
1015
|
+
const tokens = await tokenStore.get(sid);
|
|
1016
|
+
if (!tokens) throw new SsoReauthError("no tokens stored for this session \u2014 log in again.", "no_tokens");
|
|
1017
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1018
|
+
if (tokens.expiresAt - 30 > now) return tokens.accessToken;
|
|
1019
|
+
if (!tokens.refreshToken) {
|
|
1020
|
+
await tokenStore.delete(sid);
|
|
1021
|
+
throw new SsoReauthError("the access token has expired and there is no refresh token \u2014 log in again.", "expired");
|
|
1022
|
+
}
|
|
1023
|
+
const inFlight = refreshing.get(sid);
|
|
1024
|
+
if (inFlight) return inFlight;
|
|
1025
|
+
const p = (async () => {
|
|
1026
|
+
let next;
|
|
1027
|
+
try {
|
|
1028
|
+
next = await client.refreshTokens(tokens.refreshToken);
|
|
1029
|
+
} catch (e) {
|
|
1030
|
+
await tokenStore.delete(sid);
|
|
1031
|
+
throw new SsoReauthError(e instanceof SsoError ? e.message : "token refresh failed", "refresh_failed");
|
|
1032
|
+
}
|
|
1033
|
+
await tokenStore.set(sid, {
|
|
1034
|
+
sub: tokens.sub,
|
|
1035
|
+
accessToken: next.accessToken,
|
|
1036
|
+
// Rotation: use the new one if BID issued one, else keep the old.
|
|
1037
|
+
...next.refreshToken ?? tokens.refreshToken ? { refreshToken: next.refreshToken ?? tokens.refreshToken } : {},
|
|
1038
|
+
expiresAt: expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
|
|
1039
|
+
});
|
|
1040
|
+
return next.accessToken;
|
|
1041
|
+
})();
|
|
1042
|
+
refreshing.set(sid, p);
|
|
1043
|
+
try {
|
|
1044
|
+
return await p;
|
|
1045
|
+
} finally {
|
|
1046
|
+
refreshing.delete(sid);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return { app, attach, require: require2, client, config, getAccessToken };
|
|
806
1050
|
}
|
|
807
1051
|
async function parseTransaction(raw, secret) {
|
|
808
1052
|
if (raw === void 0) return { ok: false, reason: "absent" };
|
|
@@ -821,7 +1065,99 @@ async function parseTransaction(raw, secret) {
|
|
|
821
1065
|
function getSession(c) {
|
|
822
1066
|
return c.get(SESSION_KEY) ?? null;
|
|
823
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
|
+
}
|
|
824
1160
|
|
|
825
|
-
export { getSession, ssoRoutes };
|
|
1161
|
+
export { accountRoutes, getSession, ssoRoutes };
|
|
826
1162
|
//# sourceMappingURL=hono.js.map
|
|
827
1163
|
//# sourceMappingURL=hono.js.map
|