@ultimat3/auth 23.0.0 → 25.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,106 @@
1
+ // Single responsibility: what `resolveGrants` may read besides the profile (#670). An IdP is free
2
+ // to put authorization facts — groups, entitlements, `allowed_oauth_apps` — on `/userinfo` only,
3
+ // or in an id-token claim `OAuthProfile` does not name; a seam that sees the profile alone refuses
4
+ // a correctly granted user. The token set, the whole verified payload, and a lazy userinfo read.
5
+
6
+ import { renderFixLiteral } from '@ultimat3/core';
7
+ import { decodeJwtSegment } from './json';
8
+ import type { OAuthProviderId } from './oauth';
9
+ import { oauthExchangeFailed } from './oauth-errors';
10
+ import type { OAuthTokens } from './oauth-exchange';
11
+ import { oauthStartPath } from './oauth-paths';
12
+ import {
13
+ type OAuthProfile,
14
+ type OAuthProfileOptions,
15
+ userinfoAccountId,
16
+ userinfoBody,
17
+ } from './oauth-profile';
18
+ import { providerFor } from './oauth-registry';
19
+
20
+ /** The second argument of `resolveGrants`. Read-only, and alive for one callback only. */
21
+ export interface OAuthGrantContext {
22
+ readonly provider: OAuthProviderId;
23
+ /**
24
+ * The exchange's token set, in flight. The framework persists none of it (`accountFor` writes
25
+ * `null`), so an app that keeps a token past this call has chosen to store a credential.
26
+ */
27
+ readonly tokens: OAuthTokens;
28
+ /**
29
+ * Every claim of the id token the exchange VERIFIED — custom ones (`hd`, `groups`) included,
30
+ * which `tokens.claims` drops. `null` when the provider issued no id token.
31
+ */
32
+ readonly idTokenClaims: Readonly<Record<string, unknown>> | null;
33
+ /**
34
+ * The provider's userinfo answer, every claim, read with the access token. At most one request
35
+ * per login, however often it is asked: none at all when the profile was already read off
36
+ * userinfo (GitHub), whose body is handed back; a failed read is not remembered.
37
+ * A body naming another subject is `X_OAUTH_EXCHANGE_FAILED` — the same rule `oauthProfile`
38
+ * applies, so a grant can never be read off somebody else's identity.
39
+ */
40
+ userinfo(): Promise<Readonly<Record<string, unknown>>>;
41
+ }
42
+
43
+ /** The verified token's payload, whole. Its signature, `iss`, `aud` and `exp` were checked already. */
44
+ function fullPayload(tokens: OAuthTokens): Readonly<Record<string, unknown>> | null {
45
+ if (tokens.claims === null || tokens.idToken === null) return null;
46
+ const payload = decodeJwtSegment(tokens.idToken.split('.')[1] ?? '');
47
+ return payload === null ? null : Object.freeze(payload);
48
+ }
49
+
50
+ async function readUserinfo(
51
+ profile: OAuthProfile,
52
+ tokens: OAuthTokens,
53
+ options: OAuthProfileOptions,
54
+ ): Promise<Readonly<Record<string, unknown>>> {
55
+ const { provider } = profile;
56
+ // Asked before `userinfoBody`, whose own refusal for this case speaks of a missing identity:
57
+ // here the identity is proven, and what is missing is the endpoint the app's rule reads.
58
+ if (providerFor(provider).userInfoUrl === null) {
59
+ throw oauthExchangeFailed({
60
+ provider,
61
+ stage: 'userinfo',
62
+ detail: `${provider} publishes no userinfo endpoint, so resolveGrants cannot read one`,
63
+ fix: `resolveGrants: (profile, { idTokenClaims }) => grantsFrom(idTokenClaims) # ${provider} puts its claims in the id token, never behind context.userinfo()`,
64
+ });
65
+ }
66
+ const body = await userinfoBody(provider, tokens, options);
67
+ if (userinfoAccountId(body) !== profile.providerAccountId) {
68
+ throw oauthExchangeFailed({
69
+ provider,
70
+ stage: 'userinfo',
71
+ detail: 'the userinfo subject is not the subject of the identity being signed in',
72
+ fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')}, userInfoUrl: '<the provider's own userinfo endpoint>', … }) # a proxy that rewrites sub is refused; then restart at ${oauthStartPath(provider)}`,
73
+ });
74
+ }
75
+ return Object.freeze(body);
76
+ }
77
+
78
+ /**
79
+ * Built once per callback, after the profile is proven and before `resolveGrants` runs. `fetched`
80
+ * is the userinfo body the profile read already made, if it made one — its subject IS the profile's.
81
+ */
82
+ export function oauthGrantContext(
83
+ profile: OAuthProfile,
84
+ tokens: OAuthTokens,
85
+ options: OAuthProfileOptions,
86
+ fetched: Record<string, unknown> | null = null,
87
+ ): OAuthGrantContext {
88
+ let pending: Promise<Readonly<Record<string, unknown>>> | undefined =
89
+ fetched === null ? undefined : Promise.resolve(Object.freeze(fetched));
90
+ return Object.freeze({
91
+ provider: profile.provider,
92
+ tokens,
93
+ idTokenClaims: fullPayload(tokens),
94
+ userinfo() {
95
+ if (pending === undefined) {
96
+ const read = readUserinfo(profile, tokens, options);
97
+ pending = read;
98
+ // Forget a failure, so a seam that retries asks the provider again rather than the cache.
99
+ read.catch(() => {
100
+ if (pending === read) pending = undefined;
101
+ });
102
+ }
103
+ return pending;
104
+ },
105
+ });
106
+ }
@@ -3,19 +3,14 @@
3
3
  // matter which door the user came through. `completeOAuthLogin` is the blessed entry point;
4
4
  // the three steps under it are exported because a custom flow needs the seams, not a second path.
5
5
 
6
- import { ConfigInvalidError, logger, uuid } from '@ultimat3/core';
6
+ import { ConfigInvalidError, logger, uuidV7 } from '@ultimat3/core';
7
7
  import type { AuthAccount, AuthUser } from './adapter';
8
8
  import type { Auth, LoginResult } from './auth';
9
9
  import { normaliseEmail } from './email';
10
- import { authWriteFailed, mfaRequired } from './errors';
10
+ import { AuthError, authWriteFailed } from './errors';
11
+ import { mfaChallengeRequired } from './mfa-challenge';
11
12
  import type { OAuthCallback, OAuthHandshake } from './oauth';
12
- import {
13
- emailVerifiedNotStored,
14
- oauthAccountNotLinked,
15
- oauthExchangeFailed,
16
- oauthLinkingDisabled,
17
- restartAt,
18
- } from './oauth-errors';
13
+ import { oauthExchangeFailed, restartAt } from './oauth-errors';
19
14
  import {
20
15
  exchangeOAuthCode,
21
16
  type OAuthClientCredentials,
@@ -23,11 +18,58 @@ import {
23
18
  type OAuthTokens,
24
19
  oauthCredentials,
25
20
  } from './oauth-exchange';
26
- import { type OAuthProfile, oauthProfile } from './oauth-profile';
21
+ import { type OAuthGrantContext, oauthGrantContext } from './oauth-grant-context';
22
+ import { type OAuthProfile, readOAuthProfile } from './oauth-profile';
27
23
  import { resolveActor } from './policy-bridge';
28
24
  import { loginFailed } from './rate-limit';
29
25
  import { createSession, sessionCookie } from './session';
30
26
 
27
+ /**
28
+ * The address is proven to the provider, and an account that never proved it already holds it.
29
+ * Naming that is not account enumeration — this caller just demonstrated they own the address —
30
+ * and staying silent would leave them with a login that fails forever and no way out.
31
+ *
32
+ * The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
33
+ * key, and cannot redact an address that was already interpolated into a sentence.
34
+ */
35
+ export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
36
+ new AuthError({
37
+ code: 'X_UNAUTHENTICATED',
38
+ cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
39
+ fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
40
+ meta: { provider, email },
41
+ });
42
+
43
+ /**
44
+ * `link: 'never'` and a local account already holds the address. Same code and same disclosure
45
+ * rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
46
+ * so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
47
+ */
48
+ export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
49
+ new AuthError({
50
+ code: 'X_UNAUTHENTICATED',
51
+ cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
52
+ fix: "sign in with that account's own credentials, or set link: 'verified-email' in defineAuth to let a provider-verified address claim a locally-verified account",
53
+ meta: { provider, email },
54
+ });
55
+
56
+ /**
57
+ * `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
58
+ * write. Falling back to the unstamped row would mint a session for a user every later login
59
+ * reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
60
+ * flow fails closed on an adapter that loses the stamp instead of half-succeeding.
61
+ */
62
+ export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
63
+ new AuthError({
64
+ // `X_AUTH_WRITE_FAILED`, the code for "an adapter write returned no row". It was
65
+ // `X_NOT_IMPLEMENTED` — a 501 telling an operator a feature is missing, about an adapter that
66
+ // implements `updateUser` and lost a write it accepted.
67
+ code: 'X_AUTH_WRITE_FAILED',
68
+ cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
69
+ fix: 'return the updated row from AuthAdapter.updateUser — MemoryAuthAdapter.updateUser is the reference implementation',
70
+ meta: { provider, userId },
71
+ });
72
+
31
73
  /**
32
74
  * What the IdP's answer entitles this identity to, in the app's own vocabulary. Which group maps
33
75
  * to which role is business convention and never ships (axiom 8) — this is the seam it arrives
@@ -48,8 +90,15 @@ export interface OAuthGrants {
48
90
  readonly externalId?: string | null | undefined;
49
91
  }
50
92
 
51
- /** Called once per callback, after the profile is proven and before the session is minted. */
52
- export type ResolveOAuthGrants = (profile: OAuthProfile) => Promise<OAuthGrants> | OAuthGrants;
93
+ /**
94
+ * Called once per callback, after the profile is proven and before the session is minted. The
95
+ * `context` is what the IdP said beyond the profile — the token set, every id-token claim, and a
96
+ * lazy `userinfo()` — for an IdP that keeps its grants off the id token (#670).
97
+ */
98
+ export type ResolveOAuthGrants = (
99
+ profile: OAuthProfile,
100
+ context: OAuthGrantContext,
101
+ ) => Promise<OAuthGrants> | OAuthGrants;
53
102
 
54
103
  export interface OAuthSignInInput {
55
104
  readonly profile: OAuthProfile;
@@ -108,7 +157,7 @@ async function applyGrants(auth: Auth, user: AuthUser, grants: OAuthGrants): Pro
108
157
  * display casing (`Ada@Example.com`) and change it between logins, and `x_users.email` is a plain
109
158
  * case-sensitive `unique` column — so unnormalised, the lookup below missed the account the user
110
159
  * registered and `createUserFor` minted a second one at the same address, which `login()` could
111
- * then never reach. `MemoryAdapter` used to fold case itself, which is why no test saw it.
160
+ * then never reach. `MemoryAuthAdapter` used to fold case itself, which is why no test saw it.
112
161
  */
113
162
  const profileEmail = (profile: OAuthProfile): string | null =>
114
163
  profile.email === null ? null : normaliseEmail(profile.email);
@@ -125,6 +174,12 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
125
174
  fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then ${restartAt(input.profile.provider)}`,
126
175
  });
127
176
  }
177
+ // The provider did not vouch for the address, so this caller has proven nothing about owning
178
+ // it. Minting the account anyway is the pre-hijack: the real owner's `register()` then collides,
179
+ // their verified-provider login is refused as "never verified", and the attacker's identity stays
180
+ // linked to the row they later claim. Answered exactly as `resolveUser` answers the same
181
+ // unverified address when it IS taken, so the two branches are no oracle for "is it taken?".
182
+ if (!input.profile.emailVerified) throw loginFailed();
128
183
  const grants = input.grants ?? {};
129
184
  if ((grants.roles ?? []).length === 0 && (grants.orgId ?? null) === null) {
130
185
  // Not an error — an app may genuinely want a roleless account until somebody approves it —
@@ -136,7 +191,7 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
136
191
  });
137
192
  }
138
193
  const created = await auth.adapter.createUser({
139
- id: uuid(auth.clock),
194
+ id: uuidV7(auth.clock),
140
195
  email,
141
196
  // An OAuth-only account has no password to store, and must never be given a random one:
142
197
  // a hash nobody knows the input to is still a credential a reset flow could hand over.
@@ -147,7 +202,6 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
147
202
  externalId: grants.externalId ?? null,
148
203
  createdAt: auth.clock.now(),
149
204
  });
150
- if (!input.profile.emailVerified) return created;
151
205
  // `CreateUserInput` has no `emailVerifiedAt`, so the stamp is a required second write. Returning
152
206
  // `created` when it does not land would sign in a user whose row says unverified — and the next
153
207
  // login through this same provider would then refuse to link it at all.
@@ -158,7 +212,7 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
158
212
 
159
213
  /**
160
214
  * Resolve the identity to a user: an already-linked account first, then an existing account
161
- * with the same address, then a fresh user.
215
+ * with the same address, then a fresh user — and a fresh user only for a provider-verified address.
162
216
  *
163
217
  * An address alone is not proof of ownership on either side. Attaching a provider identity to a
164
218
  * local account that never verified its own email hands the login to whoever registered that
@@ -195,7 +249,7 @@ async function resolveUser(
195
249
 
196
250
  function accountFor(auth: Auth, user: AuthUser, input: OAuthSignInInput): AuthAccount {
197
251
  return {
198
- id: uuid(auth.clock),
252
+ id: uuidV7(auth.clock),
199
253
  userId: user.id,
200
254
  provider: input.profile.provider,
201
255
  providerAccountId: input.profile.providerAccountId,
@@ -244,7 +298,7 @@ export async function signInWithOAuth(auth: Auth, input: OAuthSignInInput): Prom
244
298
  linked === null ? account : { ...account, id: linked.id, createdAt: linked.createdAt },
245
299
  );
246
300
 
247
- if (user.mfaSecret !== null) throw mfaRequired(user.id);
301
+ if (user.mfaSecret !== null) throw await mfaChallengeRequired(auth, user.id);
248
302
 
249
303
  const issued = await createSession(auth.sessions, {
250
304
  userId: user.id,
@@ -286,11 +340,12 @@ export async function completeOAuthLogin(
286
340
  fetch: input.fetch,
287
341
  timeoutMs: input.timeoutMs,
288
342
  });
289
- const profile = await oauthProfile(provider, tokens, {
290
- fetch: input.fetch,
291
- timeoutMs: input.timeoutMs,
292
- });
293
- const grants = input.resolveGrants === undefined ? undefined : await input.resolveGrants(profile);
343
+ const options = { fetch: input.fetch, timeoutMs: input.timeoutMs };
344
+ const { profile, userinfo } = await readOAuthProfile(provider, tokens, options);
345
+ const grants =
346
+ input.resolveGrants === undefined
347
+ ? undefined
348
+ : await input.resolveGrants(profile, oauthGrantContext(profile, tokens, options, userinfo));
294
349
  return await signInWithOAuth(auth, {
295
350
  profile,
296
351
  tokens,
@@ -133,11 +133,16 @@ async function githubPrimaryEmail(
133
133
  return verified.find((entry) => entry.primary) ?? verified[0] ?? null;
134
134
  }
135
135
 
136
- async function fromUserInfo(
136
+ /**
137
+ * The provider's userinfo answer, whole: every claim, the ones `OAuthProfile` names and the ones it
138
+ * does not. Shared by the profile below and by `resolveGrants`' `userinfo()` (#670), so the two
139
+ * reads refuse the same three failures with the same codes — one endpoint, one reader.
140
+ */
141
+ export async function userinfoBody(
137
142
  provider: OAuthProviderId,
138
143
  tokens: OAuthTokens,
139
144
  options: OAuthProfileOptions,
140
- ): Promise<OAuthProfile> {
145
+ ): Promise<Record<string, unknown>> {
141
146
  const config = providerFor(provider);
142
147
  const url = config.userInfoUrl;
143
148
  if (url === null) {
@@ -167,12 +172,30 @@ async function fromUserInfo(
167
172
  fix: `confirm ${url} is the provider's real userinfo endpoint and not a proxy`,
168
173
  });
169
174
  }
175
+ return result.body;
176
+ }
170
177
 
171
- const body = result.body;
172
- // GitHub's id is a number; OIDC's `sub` is a string. Both are stable, so both are accepted.
178
+ /** GitHub's id is a number; OIDC's `sub` is a string. Both are stable, so both are accepted. */
179
+ export function userinfoAccountId(body: Readonly<Record<string, unknown>>): string {
173
180
  const rawId = body['sub'] ?? body['id'];
174
- const providerAccountId =
175
- typeof rawId === 'number' ? String(rawId) : typeof rawId === 'string' ? rawId : '';
181
+ return typeof rawId === 'number' ? String(rawId) : typeof rawId === 'string' ? rawId : '';
182
+ }
183
+
184
+ /** A profile, and the userinfo body it was read from when it was read from one. */
185
+ export interface OAuthProfileRead {
186
+ readonly profile: OAuthProfile;
187
+ /** `null` when the id token answered alone and no userinfo request was made. */
188
+ readonly userinfo: Record<string, unknown> | null;
189
+ }
190
+
191
+ async function fromUserInfo(
192
+ provider: OAuthProviderId,
193
+ tokens: OAuthTokens,
194
+ options: OAuthProfileOptions,
195
+ ): Promise<OAuthProfileRead> {
196
+ const config = providerFor(provider);
197
+ const body = await userinfoBody(provider, tokens, options);
198
+ const providerAccountId = userinfoAccountId(body);
176
199
  if (providerAccountId === '') {
177
200
  throw oauthExchangeFailed({
178
201
  provider,
@@ -193,13 +216,14 @@ async function fromUserInfo(
193
216
  }
194
217
  }
195
218
 
196
- return {
219
+ const profile: OAuthProfile = {
197
220
  provider,
198
221
  providerAccountId,
199
222
  email,
200
223
  emailVerified,
201
224
  name: stringOrNull(body['name']) ?? stringOrNull(body['login']),
202
225
  };
226
+ return { profile, userinfo: body };
203
227
  }
204
228
 
205
229
  /**
@@ -212,17 +236,29 @@ export async function oauthProfile(
212
236
  tokens: OAuthTokens,
213
237
  options: OAuthProfileOptions = {},
214
238
  ): Promise<OAuthProfile> {
239
+ return (await readOAuthProfile(provider, tokens, options)).profile;
240
+ }
241
+
242
+ /**
243
+ * `oauthProfile`, keeping the userinfo body it fetched — so `resolveGrants`' `userinfo()` hands
244
+ * that body back instead of asking the provider a second time in the same login (#670).
245
+ */
246
+ export async function readOAuthProfile(
247
+ provider: OAuthProviderId,
248
+ tokens: OAuthTokens,
249
+ options: OAuthProfileOptions = {},
250
+ ): Promise<OAuthProfileRead> {
215
251
  const claims = tokens.claims;
216
252
  if (claims === null) return await fromUserInfo(provider, tokens, options);
217
253
 
218
254
  const email = stringOrNull(claims.email);
219
255
  const userInfoUrl = providerFor(provider).userInfoUrl;
220
256
  if (email === null && userInfoUrl !== null) {
221
- const profile = await fromUserInfo(provider, tokens, options);
257
+ const read = await fromUserInfo(provider, tokens, options);
222
258
  // Two surfaces, one identity — or this is not that identity. Overwriting the subject and
223
259
  // keeping the address would link the account on an address belonging to whoever the second
224
260
  // call described, so a disagreement ends the handshake instead of being reconciled.
225
- if (profile.providerAccountId !== claims.sub) {
261
+ if (read.profile.providerAccountId !== claims.sub) {
226
262
  throw oauthExchangeFailed({
227
263
  provider,
228
264
  stage: 'userinfo',
@@ -230,14 +266,15 @@ export async function oauthProfile(
230
266
  fix: `confirm ${userInfoUrl} is ${provider}'s own userinfo endpoint and not a proxy that rewrites sub`,
231
267
  });
232
268
  }
233
- return profile;
269
+ return read;
234
270
  }
235
271
 
236
- return {
272
+ const profile: OAuthProfile = {
237
273
  provider,
238
274
  providerAccountId: claims.sub,
239
275
  email,
240
276
  emailVerified: email !== null && idTokenEmailVerified(claims),
241
277
  name: stringOrNull(claims.name),
242
278
  };
279
+ return { profile, userinfo: null };
243
280
  }
@@ -5,9 +5,24 @@
5
5
  // account linking with it. The three built-ins register through the same call an app uses, so the
6
6
  // opening does not create a second path.
7
7
 
8
+ import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
9
+ import { AuthError } from './errors';
8
10
  import type { OAuthProvider } from './oauth';
9
11
  import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
10
- import { oauthProviderDuplicate, oauthProviderUnknown } from './oauth-errors';
12
+ import { oauthProviderUnknown } from './oauth-errors';
13
+
14
+ /**
15
+ * Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
16
+ * module imported second decide where every login for that id goes — including which `issuers`
17
+ * an id token may claim — so the second registration refuses at boot instead.
18
+ */
19
+ export const oauthProviderDuplicate = (provider: string): AuthError =>
20
+ new AuthError({
21
+ code: 'X_OAUTH_PROVIDER_DUPLICATE',
22
+ cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
23
+ fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
24
+ meta: { provider },
25
+ });
11
26
 
12
27
  const registry = new Map<string, OAuthProvider>();
13
28
 
@@ -10,16 +10,20 @@
10
10
 
11
11
  import {
12
12
  type Clock,
13
+ EnvMissingError,
13
14
  isUltimateError,
14
15
  logger,
16
+ renderCauseValue,
17
+ renderFixShellArg,
15
18
  renderThrowable,
16
19
  type UltimateError,
17
20
  } from '@ultimat3/core';
18
21
  import type { Auth, LoginResult } from './auth';
22
+ import { AuthError } from './errors';
19
23
  import { beginOAuth, type OAuthProviderId } from './oauth';
20
24
  import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
21
25
  import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
22
- import { oauthDenied, oauthExchangeFailed, oauthProviderUnknown } from './oauth-errors';
26
+ import { oauthExchangeFailed, oauthProviderUnknown, restartAt } from './oauth-errors';
23
27
  import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
24
28
  import { oauthCredentials } from './oauth-exchange';
25
29
  import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
@@ -30,6 +34,30 @@ import {
30
34
  } from './oauth-paths';
31
35
  import { hasOAuthProvider } from './oauth-registry';
32
36
 
37
+ /**
38
+ * The provider came back with `error=` and no code — almost always the user pressing Cancel.
39
+ * A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
40
+ * misconfigured, and folding the single commonest non-success outcome of a login into the code
41
+ * that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
42
+ */
43
+ export const oauthDenied = (
44
+ provider: string,
45
+ reason: string,
46
+ description: string | null,
47
+ ): AuthError =>
48
+ new AuthError({
49
+ code: 'X_OAUTH_DENIED',
50
+ // `reason` and `description` are query parameters off the callback URL — whatever the browser
51
+ // was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
52
+ // string literals, so a forged `error_description` cannot forge a second log line or break the
53
+ // sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
54
+ cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
55
+ description === null ? '' : ` (${renderCauseValue(description)})`
56
+ }`,
57
+ fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
58
+ meta: { provider, reason },
59
+ });
60
+
33
61
  /**
34
62
  * What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
35
63
  * `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
@@ -52,9 +80,9 @@ export interface OAuthLoginRoutes {
52
80
 
53
81
  export interface OAuthLoginOptions {
54
82
  /**
55
- * The origin the provider redirects back to. Defaults to `APP_URL`, then to the request's own
56
- * origin — which is the `Host` header, so it is preferred last: a forged one only ever produces
57
- * a `redirect_uri` the provider refuses, but naming the canonical origin costs nothing.
83
+ * The origin the provider redirects back to. Defaults to `APP_URL`; with neither, the start leg
84
+ * refuses (`X_ENV_MISSING`). Never the request's own origin: that is the `Host` header, which
85
+ * the caller writes.
58
86
  */
59
87
  readonly baseUrl?: string | undefined;
60
88
  /** Defaults to the two env vars in the provider table, read per request, never at import. */
@@ -125,34 +153,44 @@ export function oauthRouteStatus(code: string): number {
125
153
  return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
126
154
  }
127
155
 
156
+ /** The one sentence every refusal carries outward. The event name is where the rest went. */
157
+ const PUBLIC_CAUSE =
158
+ 'the sign-in was refused; its cause and its fix are in this server\u2019s log under auth.oauth.refused';
159
+
160
+ /** A code this framework would have minted — the only kind spliced into a command. */
161
+ const FRAMEWORK_CODE = /^X_[A-Z0-9_]+$/;
162
+
128
163
  /**
129
- * What an anonymous caller is allowed to read. `UltimateError.toJSON()` carries `meta` and `stack`
130
- * — a developer's fields — and BOTH legs of this flow are public by definition, so serialising it
131
- * whole published a stack trace and whatever a factory put in `meta` to whoever typed the URL. Four
132
- * fields, the same four on every code: no per-code judgement about which `meta` key is safe today.
164
+ * What an anonymous caller is allowed to read: the code, its registered title and docs link, and
165
+ * one fixed sentence. Both legs are public by definition, and an authored `cause`/`fix` is written
166
+ * for the developer — it names env vars, endpoints and what a provider answered — so neither is
167
+ * served, on any code: no per-code judgement about which sentence is safe today.
133
168
  */
134
169
  const publicBody = (coded: UltimateError): Record<string, string> => ({
135
170
  code: coded.code,
136
171
  title: coded.title,
137
- cause: coded.cause,
138
- fix: coded.fix,
172
+ cause: PUBLIC_CAUSE,
173
+ fix: FRAMEWORK_CODE.test(coded.code)
174
+ ? `x errors explain ${renderFixShellArg(coded.code, '<CODE>')} --json`
175
+ : 'x errors list --json',
139
176
  docs: coded.docs,
140
177
  });
141
178
 
142
179
  /**
143
- * Failure is JSON, never a redirect to a login page carrying `?error=`. A callback is the one
144
- * request in the flow whose failure the developer has to read, and a redirect that drops the code
145
- * and the fix line is exactly how three dead `fix:` strings survived a whole release. An app that
146
- * wants a rendered page wraps these two descriptors; the framework ships the debuggable answer.
180
+ * Failure is JSON, never a redirect to a login page carrying `?error=`: the status and the code
181
+ * are the answer a client branches on. The developer's half — the cause and the fix line — is
182
+ * logged as `auth.oauth.refused`, where only an operator reads it. An app that wants a rendered
183
+ * page wraps these two descriptors.
147
184
  */
148
185
  function problem(error: unknown, extraCookies: readonly string[]): Response {
149
186
  const coded = isUltimateError(error) ? error : uncoded(error);
187
+ const status = oauthRouteStatus(coded.code);
188
+ const fields = { code: coded.code, status, cause: coded.cause, fix: coded.fix };
189
+ if (status >= 500) logger.error('auth.oauth.refused', fields);
190
+ else logger.info('auth.oauth.refused', fields);
150
191
  const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
151
192
  for (const cookie of extraCookies) headers.append('set-cookie', cookie);
152
- return new Response(JSON.stringify(publicBody(coded)), {
153
- status: oauthRouteStatus(coded.code),
154
- headers,
155
- });
193
+ return new Response(JSON.stringify(publicBody(coded)), { status, headers });
156
194
  }
157
195
 
158
196
  /**
@@ -244,10 +282,22 @@ function credentialsFor(
244
282
  }
245
283
  }
246
284
 
247
- function originFor(request: Request, options: OAuthLoginOptions): string {
285
+ /**
286
+ * The declared origin, or a refusal. The request's own origin is the `Host` header, so a
287
+ * `redirect_uri` built from it is one the caller chose.
288
+ */
289
+ function declaredOrigin(options: OAuthLoginOptions): string {
248
290
  const env = options.env ?? Bun.env;
249
- const declared = options.baseUrl ?? env['APP_URL']?.trim() ?? '';
250
- return declared === '' ? new URL(request.url).origin : declared.replace(/\/+$/, '');
291
+ const declared = (options.baseUrl ?? env['APP_URL'] ?? '').trim();
292
+ if (declared === '') {
293
+ throw new EnvMissingError({
294
+ cause:
295
+ 'APP_URL is not set and oauthLogin() was given no baseUrl, so there is no declared origin to build the provider redirect_uri from',
296
+ fix: 'export APP_URL=http://localhost:3000 # the origin this app is served from',
297
+ meta: { key: 'APP_URL' },
298
+ });
299
+ }
300
+ return declared.replace(/\/+$/, '');
251
301
  }
252
302
 
253
303
  /** The handshake's own options, assembled once so both legs seal and open it identically. */
@@ -265,12 +315,15 @@ async function startHandler(
265
315
  request: Request,
266
316
  ): Promise<Response> {
267
317
  try {
318
+ // Before the provider is looked at: asked after, a missing origin answered 500 for a provider
319
+ // this app enabled and 404 for one it did not.
320
+ const origin = declaredOrigin(options);
268
321
  const provider = assertEnabled(auth, providerSegment(request, 'start'));
269
322
  const credentials = credentialsFor(provider, options);
270
323
  const handshake = beginOAuth({
271
324
  provider,
272
325
  clientId: credentials.clientId,
273
- redirectUri: `${originFor(request, options)}${oauthCallbackPath(provider)}`,
326
+ redirectUri: `${origin}${oauthCallbackPath(provider)}`,
274
327
  scopes: options.scopes,
275
328
  });
276
329
  return new Response(null, {
package/src/oauth.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  // configs are pure data and live in `oauth-registry.ts`: importing this file performs no network
5
5
  // I/O and reads no env.
6
6
 
7
+ import { timingSafeEqual } from '@ultimat3/core';
7
8
  import { oauthStateInvalid } from './oauth-errors';
8
9
  import { providerFor } from './oauth-registry';
9
- import { base64Url, randomToken, sha256Bytes, timingSafeEqual } from './tokens';
10
+ import { base64Url, randomToken, sha256Bytes } from './tokens';
10
11
 
11
12
  export interface OAuthProvider {
12
13
  readonly id: string;
@@ -63,7 +64,7 @@ export function pkceChallenge(verifier: string): string {
63
64
  return base64Url(sha256Bytes(verifier));
64
65
  }
65
66
 
66
- export function createPkce(): PkcePair {
67
+ export function pkcePair(): PkcePair {
67
68
  // 32 random bytes -> 43 base64url chars, the RFC's minimum verifier length.
68
69
  const verifier = randomToken(32);
69
70
  return { verifier, challenge: pkceChallenge(verifier), method: 'S256' };
@@ -88,7 +89,7 @@ export interface BeginOAuthInput {
88
89
 
89
90
  export function beginOAuth(input: BeginOAuthInput): OAuthHandshake {
90
91
  const provider = providerFor(input.provider);
91
- const pkce = createPkce();
92
+ const pkce = pkcePair();
92
93
  const state = randomToken(16);
93
94
  const nonce = randomToken(16);
94
95
  const url = new URL(provider.authorizeUrl);