@ultimat3/auth 1.0.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.
@@ -0,0 +1,244 @@
1
+ // Single responsibility: the authorization-code leg — the client credentials it needs and the
2
+ // one POST to the provider's token endpoint. `fetch` is the whole client; no SDK, no provider
3
+ // branch beyond the data in `oauth.ts`. The tokens this returns are already trustworthy: an id
4
+ // token is verified here, so no caller downstream can forget to.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { EnvMissingError, systemClock } from '@ultimat3/core';
8
+ import { oauthExchangeFailed } from './errors';
9
+ import { type IdTokenClaims, verifyIdToken } from './id-token';
10
+ import {
11
+ assertOAuthCallback,
12
+ OAUTH_PROVIDERS,
13
+ type OAuthCallback,
14
+ type OAuthHandshake,
15
+ type OAuthProviderId,
16
+ } from './oauth';
17
+
18
+ /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
19
+ export type OAuthFetch = (input: string, init: RequestInit) => Promise<Response>;
20
+
21
+ /** GitHub answers 403 to a request with no user agent, so every call this package makes has one. */
22
+ export const OAUTH_USER_AGENT = 'ultimate-auth';
23
+
24
+ const DEFAULT_TIMEOUT_MS = 10_000;
25
+ const MAX_DETAIL_LENGTH = 200;
26
+
27
+ export interface OAuthClientCredentials {
28
+ readonly clientId: string;
29
+ readonly clientSecret: string;
30
+ }
31
+
32
+ export interface OAuthTokens {
33
+ readonly accessToken: string;
34
+ readonly refreshToken: string | null;
35
+ readonly expiresAt: Date | null;
36
+ readonly idToken: string | null;
37
+ /** Verified claims of `idToken`. `null` when the provider issues no id token. */
38
+ readonly claims: IdTokenClaims | null;
39
+ }
40
+
41
+ export interface OAuthExchangeOptions {
42
+ readonly credentials: OAuthClientCredentials;
43
+ /** No `Date.now()` in this package: the token expiry is measured against this. */
44
+ readonly clock?: Clock | undefined;
45
+ /** Injected in tests; production uses the global. */
46
+ readonly fetch?: OAuthFetch | undefined;
47
+ readonly timeoutMs?: number | undefined;
48
+ }
49
+
50
+ /**
51
+ * Reads the two env vars the provider table names. Kept out of module scope on purpose —
52
+ * importing `oauth.ts` still reads no env and touches no network.
53
+ */
54
+ export function oauthCredentials(
55
+ provider: OAuthProviderId,
56
+ env: Readonly<Record<string, string | undefined>> = Bun.env,
57
+ ): OAuthClientCredentials {
58
+ const { clientIdEnv, clientSecretEnv } = OAUTH_PROVIDERS[provider];
59
+ const clientId = env[clientIdEnv]?.trim() ?? '';
60
+ const clientSecret = env[clientSecretEnv]?.trim() ?? '';
61
+ const missing = [
62
+ ...(clientId === '' ? [clientIdEnv] : []),
63
+ ...(clientSecret === '' ? [clientSecretEnv] : []),
64
+ ];
65
+ if (missing.length > 0) {
66
+ throw new EnvMissingError({
67
+ cause: `${provider} oauth is enabled but ${missing.join(' and ')} ${missing.length === 1 ? 'is' : 'are'} not set`,
68
+ fix: `add ${missing.join(' and ')} to .env from the ${provider} app's credentials, then run: x doctor --json`,
69
+ meta: { provider, missing },
70
+ });
71
+ }
72
+ return { clientId, clientSecret };
73
+ }
74
+
75
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
76
+ typeof value === 'object' && value !== null && !Array.isArray(value);
77
+
78
+ /** Prefers the provider's own words; falls back to raw text, capped so a login page can't flood logs. */
79
+ export async function providerDetail(response: Response): Promise<string> {
80
+ const text = await response.text().catch(() => '');
81
+ if (text === '') return 'the response body was empty';
82
+ try {
83
+ const parsed: unknown = JSON.parse(text);
84
+ if (isRecord(parsed)) {
85
+ const description = parsed['error_description'] ?? parsed['error'] ?? parsed['message'];
86
+ if (typeof description === 'string' && description !== '') return description;
87
+ }
88
+ } catch {
89
+ // Not JSON — fall through to the truncated raw text below.
90
+ }
91
+ return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
92
+ }
93
+
94
+ function fixForStatus(provider: OAuthProviderId, status: number): string {
95
+ const { clientIdEnv, clientSecretEnv } = OAUTH_PROVIDERS[provider];
96
+ if (status === 401 || status === 403) {
97
+ return `set ${clientIdEnv} and ${clientSecretEnv} to the current values in the ${provider} app settings`;
98
+ }
99
+ if (status === 400) {
100
+ return `register this exact redirect_uri in the ${provider} app settings, then restart the flow`;
101
+ }
102
+ return `retry; if it persists, check the ${provider} status page before changing anything`;
103
+ }
104
+
105
+ async function postForm(
106
+ provider: OAuthProviderId,
107
+ body: URLSearchParams,
108
+ options: OAuthExchangeOptions,
109
+ ): Promise<Record<string, unknown>> {
110
+ const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
111
+ const url = OAUTH_PROVIDERS[provider].tokenUrl;
112
+
113
+ let response: Response;
114
+ try {
115
+ response = await doFetch(url, {
116
+ method: 'POST',
117
+ headers: {
118
+ // GitHub answers `application/x-www-form-urlencoded` unless asked for JSON.
119
+ Accept: 'application/json',
120
+ 'Content-Type': 'application/x-www-form-urlencoded',
121
+ 'User-Agent': OAUTH_USER_AGENT,
122
+ },
123
+ body: body.toString(),
124
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
125
+ });
126
+ } catch (error) {
127
+ const reason = error instanceof Error ? error.message : 'the request failed before a response';
128
+ throw oauthExchangeFailed({
129
+ provider,
130
+ stage: 'token',
131
+ detail:
132
+ `${reason} — nothing left this host for ${url} (egress, DNS or TLS); restart the ` +
133
+ 'flow once it does, since the code is already spent',
134
+ fix: `curl -sS -m 5 -o /dev/null ${url}`,
135
+ });
136
+ }
137
+
138
+ if (!response.ok) {
139
+ throw oauthExchangeFailed({
140
+ provider,
141
+ stage: 'token',
142
+ detail: await providerDetail(response),
143
+ status: response.status,
144
+ fix: fixForStatus(provider, response.status),
145
+ });
146
+ }
147
+
148
+ const parsed: unknown = await response.json().catch(() => undefined);
149
+ if (!isRecord(parsed)) {
150
+ throw oauthExchangeFailed({
151
+ provider,
152
+ stage: 'token',
153
+ detail: 'the token endpoint answered 200 with a body that is not a JSON object',
154
+ fix: `confirm ${url} is the provider's real token endpoint and not a proxy`,
155
+ });
156
+ }
157
+
158
+ // GitHub reports a bad code, a reused code and a wrong secret as HTTP 200 with an `error`
159
+ // field. Trusting the status alone here mints a session from a failed exchange.
160
+ const error = parsed['error'];
161
+ if (typeof error === 'string' && error !== '') {
162
+ const description = parsed['error_description'];
163
+ throw oauthExchangeFailed({
164
+ provider,
165
+ stage: 'token',
166
+ detail: typeof description === 'string' && description !== '' ? description : error,
167
+ fix:
168
+ error === 'bad_verification_code' || error === 'invalid_grant'
169
+ ? 'restart the flow — an authorization code is single-use and short-lived'
170
+ : fixForStatus(provider, 400),
171
+ });
172
+ }
173
+ return parsed;
174
+ }
175
+
176
+ /**
177
+ * Validates the callback, then trades the code for tokens. PKCE's verifier travels here and
178
+ * nowhere else — it is what proves this exchange belongs to the browser that started the flow.
179
+ */
180
+ export async function exchangeOAuthCode(
181
+ handshake: OAuthHandshake,
182
+ callback: OAuthCallback,
183
+ options: OAuthExchangeOptions,
184
+ ): Promise<OAuthTokens> {
185
+ assertOAuthCallback(handshake, callback);
186
+ const provider = OAUTH_PROVIDERS[handshake.provider];
187
+ const clock = options.clock ?? systemClock;
188
+
189
+ const body = new URLSearchParams({
190
+ grant_type: 'authorization_code',
191
+ code: callback.code,
192
+ redirect_uri: handshake.redirectUri,
193
+ client_id: options.credentials.clientId,
194
+ client_secret: options.credentials.clientSecret,
195
+ });
196
+ if (provider.usesPkce) body.set('code_verifier', handshake.verifier);
197
+
198
+ const payload = await postForm(handshake.provider, body, options);
199
+ const accessToken = payload['access_token'];
200
+ if (typeof accessToken !== 'string' || accessToken === '') {
201
+ throw oauthExchangeFailed({
202
+ provider: provider.id,
203
+ stage: 'token',
204
+ detail: 'the response carried no access_token',
205
+ fix: `confirm the ${provider.id} app grants the scopes in OAUTH_PROVIDERS.${provider.id}.scopes`,
206
+ });
207
+ }
208
+
209
+ const refreshToken = payload['refresh_token'];
210
+ const expiresIn = payload['expires_in'];
211
+ const rawIdToken = payload['id_token'];
212
+ const idToken = typeof rawIdToken === 'string' && rawIdToken !== '' ? rawIdToken : null;
213
+
214
+ // An OIDC provider that answers without an id token has not identified anyone, and the
215
+ // access token alone cannot be bound to this handshake's nonce.
216
+ if (provider.usesNonce && idToken === null) {
217
+ throw oauthExchangeFailed({
218
+ provider: provider.id,
219
+ stage: 'token',
220
+ detail: 'the response carried no id_token',
221
+ fix: `include "openid" in the scopes passed to beginOAuth() for ${provider.id}`,
222
+ });
223
+ }
224
+
225
+ return {
226
+ accessToken,
227
+ refreshToken: typeof refreshToken === 'string' && refreshToken !== '' ? refreshToken : null,
228
+ expiresAt:
229
+ typeof expiresIn === 'number' && Number.isFinite(expiresIn)
230
+ ? new Date(clock.now().getTime() + expiresIn * 1000)
231
+ : null,
232
+ idToken,
233
+ claims:
234
+ idToken === null
235
+ ? null
236
+ : verifyIdToken({
237
+ provider: handshake.provider,
238
+ idToken,
239
+ clientId: options.credentials.clientId,
240
+ nonce: handshake.nonce,
241
+ clock,
242
+ }),
243
+ };
244
+ }
@@ -0,0 +1,193 @@
1
+ // Single responsibility: the last leg — a verified provider identity becomes the same
2
+ // `LoginResult` a password login produces. One session shape, one `Actor`, one MFA rule, no
3
+ // matter which door the user came through. `completeOAuthLogin` is the blessed entry point;
4
+ // the three steps under it are exported because a custom flow needs the seams, not a second path.
5
+
6
+ import { ConfigInvalidError, uuid } from '@ultimat3/core';
7
+ import type { AuthAccount, AuthUser } from './adapter';
8
+ import type { Auth, LoginResult } from './auth';
9
+ import {
10
+ emailVerifiedNotStored,
11
+ mfaRequired,
12
+ oauthAccountNotLinked,
13
+ oauthExchangeFailed,
14
+ } from './errors';
15
+ import type { OAuthCallback, OAuthHandshake } from './oauth';
16
+ import {
17
+ exchangeOAuthCode,
18
+ type OAuthClientCredentials,
19
+ type OAuthFetch,
20
+ type OAuthTokens,
21
+ oauthCredentials,
22
+ } from './oauth-exchange';
23
+ import { type OAuthProfile, oauthProfile } from './oauth-profile';
24
+ import { resolveActor } from './policy-bridge';
25
+ import { loginFailed } from './rate-limit';
26
+ import { createSession, sessionCookie } from './session';
27
+
28
+ export interface OAuthSignInInput {
29
+ readonly profile: OAuthProfile;
30
+ readonly tokens: OAuthTokens;
31
+ readonly ip?: string | null | undefined;
32
+ readonly userAgent?: string | null | undefined;
33
+ /** Granted to a user this flow creates. An existing user's roles are never rewritten here. */
34
+ readonly roles?: readonly string[] | undefined;
35
+ readonly orgId?: string | null | undefined;
36
+ }
37
+
38
+ async function userForAccount(auth: Auth, account: AuthAccount): Promise<AuthUser> {
39
+ const user = await auth.adapter.findUserById(account.userId);
40
+ if (user === null || user.disabledAt !== null) throw loginFailed();
41
+ return user;
42
+ }
43
+
44
+ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthUser> {
45
+ const email = input.profile.email;
46
+ // The provider authenticated somebody and told us no address. There is nothing to create an
47
+ // account from, and saying "wrong password" here would send the developer hunting the wrong bug.
48
+ if (email === null) {
49
+ throw oauthExchangeFailed({
50
+ provider: input.profile.provider,
51
+ stage: 'userinfo',
52
+ detail: 'the provider returned an identity with no email address',
53
+ fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then restart the flow`,
54
+ });
55
+ }
56
+ const created = await auth.adapter.createUser({
57
+ id: uuid(auth.clock),
58
+ email,
59
+ // An OAuth-only account has no password to store, and must never be given a random one:
60
+ // a hash nobody knows the input to is still a credential a reset flow could hand over.
61
+ passwordHash: null,
62
+ orgId: input.orgId ?? null,
63
+ roles: input.roles ?? [],
64
+ createdAt: auth.clock.now(),
65
+ });
66
+ if (!input.profile.emailVerified) return created;
67
+ // `CreateUserInput` has no `emailVerifiedAt`, so the stamp is a required second write. Returning
68
+ // `created` when it does not land would sign in a user whose row says unverified — and the next
69
+ // login through this same provider would then refuse to link it at all.
70
+ const stamped = await auth.adapter.updateUser(created.id, { emailVerifiedAt: auth.clock.now() });
71
+ if (stamped === null) throw emailVerifiedNotStored(input.profile.provider, created.id);
72
+ return stamped;
73
+ }
74
+
75
+ /**
76
+ * Resolve the identity to a user: an already-linked account first, then an existing account
77
+ * with the same address, then a fresh user.
78
+ *
79
+ * An address alone is not proof of ownership on either side. Attaching a provider identity to a
80
+ * local account that never verified its own email hands the login to whoever registered that
81
+ * address first, so both halves must have proven it before they are treated as one person.
82
+ */
83
+ async function resolveUser(
84
+ auth: Auth,
85
+ input: OAuthSignInInput,
86
+ linked: AuthAccount | null,
87
+ ): Promise<AuthUser> {
88
+ const { provider, email, emailVerified } = input.profile;
89
+ if (linked !== null) return await userForAccount(auth, linked);
90
+
91
+ if (email === null) return await createUserFor(auth, input);
92
+ const existing = await auth.adapter.findUserByEmail(email);
93
+ if (existing === null) return await createUserFor(auth, input);
94
+ if (existing.disabledAt !== null) throw loginFailed();
95
+ // The provider did not vouch for the address, so nothing here proves the two are one person.
96
+ if (!emailVerified) throw loginFailed();
97
+ // It did vouch, and the local account never did: say so, because this caller owns the address.
98
+ if (existing.emailVerifiedAt === null) throw oauthAccountNotLinked(provider, email);
99
+ return existing;
100
+ }
101
+
102
+ function accountFor(auth: Auth, user: AuthUser, input: OAuthSignInInput): AuthAccount {
103
+ return {
104
+ id: uuid(auth.clock),
105
+ userId: user.id,
106
+ provider: input.profile.provider,
107
+ providerAccountId: input.profile.providerAccountId,
108
+ accessToken: input.tokens.accessToken,
109
+ refreshToken: input.tokens.refreshToken,
110
+ expiresAt: input.tokens.expiresAt,
111
+ createdAt: auth.clock.now(),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Mints the session for an identity the provider has already proven. MFA still applies: an
117
+ * enrolled second factor is the user's rule, not the password path's rule.
118
+ */
119
+ export async function signInWithOAuth(auth: Auth, input: OAuthSignInInput): Promise<LoginResult> {
120
+ const provider = input.profile.provider;
121
+ if (!auth.providers.includes(provider)) {
122
+ throw new ConfigInvalidError({
123
+ cause: `${provider} is not in defineAuth({ providers }), so a ${provider} identity cannot sign in`,
124
+ fix: `add '${provider}' to defineAuth({ providers: [...] }), or stop offering the ${provider} button`,
125
+ meta: { provider, enabled: [...auth.providers] },
126
+ });
127
+ }
128
+
129
+ const linked = await auth.adapter.findAccount(provider, input.profile.providerAccountId);
130
+ const user = await resolveUser(auth, input, linked);
131
+ // Linked before the MFA gate on purpose: the second factor is finished on another request,
132
+ // and that request must find the identity already attached. A re-link refreshes the tokens
133
+ // and keeps the row's own identity — the provider account never changes hands.
134
+ const account = accountFor(auth, user, input);
135
+ await auth.adapter.linkAccount(
136
+ linked === null ? account : { ...account, id: linked.id, createdAt: linked.createdAt },
137
+ );
138
+
139
+ if (user.mfaSecret !== null) throw mfaRequired(user.id);
140
+
141
+ const issued = await createSession(auth.sessions, {
142
+ userId: user.id,
143
+ ip: input.ip ?? null,
144
+ userAgent: input.userAgent,
145
+ mfaSatisfied: true,
146
+ });
147
+ return {
148
+ actor: resolveActor({ kind: 'user', user, session: issued.session }),
149
+ session: issued.session,
150
+ token: issued.token,
151
+ cookie: sessionCookie(issued.token, auth.sessions.policy),
152
+ };
153
+ }
154
+
155
+ export interface CompleteOAuthLoginInput {
156
+ readonly handshake: OAuthHandshake;
157
+ readonly callback: OAuthCallback;
158
+ /** Defaults to the two env vars named in the provider table. */
159
+ readonly credentials?: OAuthClientCredentials | undefined;
160
+ /** Injected in tests; production uses the global. */
161
+ readonly fetch?: OAuthFetch | undefined;
162
+ readonly timeoutMs?: number | undefined;
163
+ readonly ip?: string | null | undefined;
164
+ readonly userAgent?: string | null | undefined;
165
+ readonly roles?: readonly string[] | undefined;
166
+ readonly orgId?: string | null | undefined;
167
+ }
168
+
169
+ /** Callback → session, in one call: exchange, identify, sign in. */
170
+ export async function completeOAuthLogin(
171
+ auth: Auth,
172
+ input: CompleteOAuthLoginInput,
173
+ ): Promise<LoginResult> {
174
+ const provider = input.handshake.provider;
175
+ const tokens = await exchangeOAuthCode(input.handshake, input.callback, {
176
+ credentials: input.credentials ?? oauthCredentials(provider),
177
+ clock: auth.clock,
178
+ fetch: input.fetch,
179
+ timeoutMs: input.timeoutMs,
180
+ });
181
+ const profile = await oauthProfile(provider, tokens, {
182
+ fetch: input.fetch,
183
+ timeoutMs: input.timeoutMs,
184
+ });
185
+ return await signInWithOAuth(auth, {
186
+ profile,
187
+ tokens,
188
+ ip: input.ip,
189
+ userAgent: input.userAgent,
190
+ roles: input.roles,
191
+ orgId: input.orgId,
192
+ });
193
+ }
@@ -0,0 +1,213 @@
1
+ // Single responsibility: one normalised identity out of whichever surface the provider offers.
2
+ // The rule is single: claims from a verified id token when there is one, the userinfo endpoint
3
+ // when there is not. `emailVerified` is carried honestly rather than assumed — it is what
4
+ // decides whether this login may attach itself to an existing account by address.
5
+
6
+ import { logger } from '@ultimat3/core';
7
+ import { oauthExchangeFailed } from './errors';
8
+ import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
9
+ import { OAUTH_PROVIDERS, type OAuthProviderId } from './oauth';
10
+ import {
11
+ OAUTH_USER_AGENT,
12
+ type OAuthFetch,
13
+ type OAuthTokens,
14
+ providerDetail,
15
+ } from './oauth-exchange';
16
+
17
+ const DEFAULT_TIMEOUT_MS = 10_000;
18
+
19
+ export interface OAuthProfile {
20
+ readonly provider: OAuthProviderId;
21
+ /** The provider's own stable id (`sub`, or GitHub's numeric id). Never the email. */
22
+ readonly providerAccountId: string;
23
+ readonly email: string | null;
24
+ /** Only a provider-asserted verification counts. Defaults to false, never to true. */
25
+ readonly emailVerified: boolean;
26
+ readonly name: string | null;
27
+ }
28
+
29
+ export interface OAuthProfileOptions {
30
+ readonly fetch?: OAuthFetch | undefined;
31
+ readonly timeoutMs?: number | undefined;
32
+ }
33
+
34
+ interface GithubEmail {
35
+ readonly email: string;
36
+ readonly primary: boolean;
37
+ readonly verified: boolean;
38
+ }
39
+
40
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
41
+ typeof value === 'object' && value !== null && !Array.isArray(value);
42
+
43
+ const stringOrNull = (value: unknown): string | null =>
44
+ typeof value === 'string' && value !== '' ? value : null;
45
+
46
+ async function getJson(
47
+ provider: OAuthProviderId,
48
+ url: string,
49
+ accessToken: string,
50
+ options: OAuthProfileOptions,
51
+ ): Promise<{ ok: true; body: unknown } | { ok: false; status: number; detail: string }> {
52
+ const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
53
+ let response: Response;
54
+ try {
55
+ response = await doFetch(url, {
56
+ method: 'GET',
57
+ headers: {
58
+ Accept: 'application/json',
59
+ Authorization: `Bearer ${accessToken}`,
60
+ 'User-Agent': OAUTH_USER_AGENT,
61
+ },
62
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
63
+ });
64
+ } catch (error) {
65
+ const reason = error instanceof Error ? error.message : 'the request failed before a response';
66
+ throw oauthExchangeFailed({
67
+ provider,
68
+ stage: 'userinfo',
69
+ detail:
70
+ `${reason} — nothing left this host for ${url} (egress, DNS or TLS); restart the ` +
71
+ 'flow once it does',
72
+ fix: `curl -sS -m 5 -o /dev/null ${url}`,
73
+ });
74
+ }
75
+ if (!response.ok) {
76
+ return { ok: false, status: response.status, detail: await providerDetail(response) };
77
+ }
78
+ return { ok: true, body: await response.json().catch(() => undefined) };
79
+ }
80
+
81
+ /** GitHub keeps a private primary address off the profile; this is where it actually lives. */
82
+ async function githubPrimaryEmail(
83
+ url: string,
84
+ accessToken: string,
85
+ options: OAuthProfileOptions,
86
+ ): Promise<GithubEmail | null> {
87
+ const result = await getJson('github', url, accessToken, options);
88
+ if (!result.ok) {
89
+ logger.warn('auth.oauth.github_emails_unavailable', {
90
+ status: result.status,
91
+ detail: result.detail,
92
+ });
93
+ return null;
94
+ }
95
+ const entries = Array.isArray(result.body) ? result.body : [];
96
+ const verified = entries.filter(
97
+ (entry): entry is GithubEmail =>
98
+ isRecord(entry) &&
99
+ typeof entry['email'] === 'string' &&
100
+ entry['verified'] === true &&
101
+ typeof entry['primary'] === 'boolean',
102
+ );
103
+ return verified.find((entry) => entry.primary) ?? verified[0] ?? null;
104
+ }
105
+
106
+ async function fromUserInfo(
107
+ provider: OAuthProviderId,
108
+ tokens: OAuthTokens,
109
+ options: OAuthProfileOptions,
110
+ ): Promise<OAuthProfile> {
111
+ const config = OAUTH_PROVIDERS[provider];
112
+ const url = config.userInfoUrl;
113
+ if (url === null) {
114
+ throw oauthExchangeFailed({
115
+ provider,
116
+ stage: 'userinfo',
117
+ detail: `${provider} publishes no userinfo endpoint and its id token carried no identity`,
118
+ fix: `request the "email" scope for ${provider} in beginOAuth(), then restart the flow`,
119
+ });
120
+ }
121
+
122
+ const result = await getJson(provider, url, tokens.accessToken, options);
123
+ if (!result.ok) {
124
+ throw oauthExchangeFailed({
125
+ provider,
126
+ stage: 'userinfo',
127
+ detail: result.detail,
128
+ status: result.status,
129
+ fix: `confirm the ${provider} app still grants ${config.scopes.join(' ')}, then restart the flow`,
130
+ });
131
+ }
132
+ if (!isRecord(result.body)) {
133
+ throw oauthExchangeFailed({
134
+ provider,
135
+ stage: 'userinfo',
136
+ detail: 'the userinfo endpoint answered 200 with a body that is not a JSON object',
137
+ fix: `confirm ${url} is the provider's real userinfo endpoint and not a proxy`,
138
+ });
139
+ }
140
+
141
+ const body = result.body;
142
+ // GitHub's id is a number; OIDC's `sub` is a string. Both are stable, so both are accepted.
143
+ const rawId = body['sub'] ?? body['id'];
144
+ const providerAccountId =
145
+ typeof rawId === 'number' ? String(rawId) : typeof rawId === 'string' ? rawId : '';
146
+ if (providerAccountId === '') {
147
+ throw oauthExchangeFailed({
148
+ provider,
149
+ stage: 'userinfo',
150
+ detail: 'the profile carried no stable account id',
151
+ fix: `confirm the ${provider} app requests ${config.scopes.join(' ')} and restart the flow`,
152
+ });
153
+ }
154
+
155
+ let email = stringOrNull(body['email']);
156
+ let emailVerified = isVerifiedFlag(body['email_verified']);
157
+
158
+ if (config.userEmailsUrl !== null) {
159
+ const primary = await githubPrimaryEmail(config.userEmailsUrl, tokens.accessToken, options);
160
+ if (primary !== null) {
161
+ email = primary.email;
162
+ emailVerified = true;
163
+ }
164
+ }
165
+
166
+ return {
167
+ provider,
168
+ providerAccountId,
169
+ email,
170
+ emailVerified,
171
+ name: stringOrNull(body['name']) ?? stringOrNull(body['login']),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * The id token is preferred because it was verified during the exchange and costs no round
177
+ * trip. Userinfo is the fallback for a provider that issues no id token (GitHub) and for the
178
+ * narrowed-scope case where the token identifies a subject but carries no address.
179
+ */
180
+ export async function oauthProfile(
181
+ provider: OAuthProviderId,
182
+ tokens: OAuthTokens,
183
+ options: OAuthProfileOptions = {},
184
+ ): Promise<OAuthProfile> {
185
+ const claims = tokens.claims;
186
+ if (claims === null) return await fromUserInfo(provider, tokens, options);
187
+
188
+ const email = stringOrNull(claims.email);
189
+ const userInfoUrl = OAUTH_PROVIDERS[provider].userInfoUrl;
190
+ if (email === null && userInfoUrl !== null) {
191
+ const profile = await fromUserInfo(provider, tokens, options);
192
+ // Two surfaces, one identity — or this is not that identity. Overwriting the subject and
193
+ // keeping the address would link the account on an address belonging to whoever the second
194
+ // call described, so a disagreement ends the handshake instead of being reconciled.
195
+ if (profile.providerAccountId !== claims.sub) {
196
+ throw oauthExchangeFailed({
197
+ provider,
198
+ stage: 'userinfo',
199
+ detail: 'the userinfo subject is not the subject of the verified id token',
200
+ fix: `confirm ${userInfoUrl} is ${provider}'s own userinfo endpoint and not a proxy that rewrites sub`,
201
+ });
202
+ }
203
+ return profile;
204
+ }
205
+
206
+ return {
207
+ provider,
208
+ providerAccountId: claims.sub,
209
+ email,
210
+ emailVerified: email !== null && idTokenEmailVerified(claims),
211
+ name: stringOrNull(claims.name),
212
+ };
213
+ }