Skip to content

Commit 223ff07

Browse files
committed
fix(selfhost): handle unavailable SSO UserInfo
1 parent a42fb6e commit 223ff07

2 files changed

Lines changed: 53 additions & 16 deletions

File tree

‎apps/host-selfhost/src/auth/sso-userinfo.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,36 @@ test("returns null when UserInfo fails or omits sub or email", async () => {
236236
}
237237
});
238238

239+
// Network and decoding failures at either external boundary must decline the
240+
// profile like a non-OK response, rather than reject the OAuth callback.
241+
test("returns null when UserInfo fetch or JSON parsing rejects", async () => {
242+
const getUserInfo = ssoProviderConfig(sso).getUserInfo!;
243+
const tokens = {
244+
idToken: jwt({ sub: "alice", email: "alice@example.com" }),
245+
accessToken: "access-token",
246+
};
247+
const discovery = new Response(
248+
JSON.stringify({ userinfo_endpoint: "https://idp.example/userinfo" }),
249+
);
250+
// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- test-only mock of a third-party response JSON boundary
251+
const invalidJson = { ok: true, json: () => Promise.reject(new Error("invalid JSON")) };
252+
// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- test-only mock of an unavailable third-party request boundary
253+
const offline = () => Promise.reject(new Error("offline"));
254+
255+
for (const responses of [
256+
[offline()],
257+
[invalidJson],
258+
[discovery, offline()],
259+
[discovery, invalidJson],
260+
]) {
261+
const fetch = vi.fn();
262+
for (const response of responses) fetch.mockImplementationOnce(() => response);
263+
vi.stubGlobal("fetch", fetch);
264+
await expect(getUserInfo(tokens)).resolves.toBeNull();
265+
vi.unstubAllGlobals();
266+
}
267+
});
268+
239269
// The claim is only worth resolving because the admission gate reads it: a thin
240270
// token that used to arrive without `email_verified` was refused at the door.
241271
test("resolves a thin ID token into an admitted user at the gate", async () => {

‎apps/host-selfhost/src/auth/sso.ts‎

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,24 +43,31 @@ export const ssoUserInfo = async (discoveryUrl: string, tokens: OAuthTokens) =>
4343
}
4444

4545
if (!tokens.accessToken) return null;
46-
const discovery = await fetch(discoveryUrl).then(async (response) =>
47-
response.ok ? (response.json() as Promise<{ userinfo_endpoint?: string }>) : null,
48-
);
49-
if (!discovery?.userinfo_endpoint) return null;
46+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: an unavailable IdP must decline the profile rather than reject the OAuth callback
47+
try {
48+
const discoveryResponse = await fetch(discoveryUrl);
49+
if (!discoveryResponse.ok) return null;
50+
const discovery = (await discoveryResponse.json()) as { userinfo_endpoint?: string };
51+
if (!discovery.userinfo_endpoint) return null;
5052

51-
const profile = await fetch(discovery.userinfo_endpoint, {
52-
headers: { authorization: `Bearer ${tokens.accessToken}` },
53-
}).then(async (response) => (response.ok ? (response.json() as Promise<OidcClaims>) : null));
54-
if (!profile?.sub || !profile.email) return null;
53+
const profileResponse = await fetch(discovery.userinfo_endpoint, {
54+
headers: { authorization: `Bearer ${tokens.accessToken}` },
55+
});
56+
if (!profileResponse.ok) return null;
57+
const profile = (await profileResponse.json()) as OidcClaims;
58+
if (!profile.sub || !profile.email) return null;
5559

56-
return {
57-
...profile,
58-
id: profile.sub,
59-
email: profile.email,
60-
emailVerified: profile.email_verified ?? false,
61-
name: profile.name,
62-
image: profile.picture,
63-
};
60+
return {
61+
...profile,
62+
id: profile.sub,
63+
email: profile.email,
64+
emailVerified: profile.email_verified ?? false,
65+
name: profile.name,
66+
image: profile.picture,
67+
};
68+
} catch {
69+
return null;
70+
}
6471
};
6572

6673
// Better Auth serves OAuth sign-in callbacks at `/oauth2/callback/:providerId`

0 commit comments

Comments
 (0)