@ultimat3/auth 9.0.0 → 10.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.
@@ -7,8 +7,8 @@
7
7
 
8
8
  import type { Clock } from '@ultimat3/core';
9
9
  import { EnvMissingError, systemClock } from '@ultimat3/core';
10
- import { oauthStateInvalid } from './errors';
11
10
  import type { OAuthHandshake, OAuthProviderId } from './oauth';
11
+ import { oauthStateInvalid } from './oauth-errors';
12
12
  import { hasOAuthProvider } from './oauth-registry';
13
13
  import { type RequestLike, readCookie } from './session';
14
14
  import { base64Url, timingSafeEqual } from './tokens';
@@ -4,9 +4,9 @@
4
4
  // four endpoints that nobody re-checks when the vendor moves one.
5
5
 
6
6
  import { renderThrowable } from '@ultimat3/core';
7
- import { oauthExchangeFailed } from './errors';
8
7
  import { isRecord } from './json';
9
8
  import type { OAuthProvider } from './oauth';
9
+ import { oauthExchangeFailed } from './oauth-errors';
10
10
  import type { OAuthFetch } from './oauth-exchange';
11
11
 
12
12
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -0,0 +1,178 @@
1
+ // Single responsibility: the OAuth half of this package's error factories — every refusal a
2
+ // provider handshake can produce, and the one phrase their fixes are built from.
3
+ // Split from `errors.ts` at the 500-line ceiling; the codes, the titles and the single
4
+ // `registerErrorCodes()` call stay there, so this file adds no code and registers nothing.
5
+
6
+ import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
7
+ import { AuthError } from './errors';
8
+ import { oauthStartPath } from './oauth-paths';
9
+
10
+ /**
11
+ * The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
12
+ * line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
13
+ * so every caller who followed it hit a 404. One declaration, read by the mount and by the fix,
14
+ * is what stops that recurring — `oauthLogin()` cannot move without moving this sentence.
15
+ */
16
+ export const oauthStateInvalid = (provider: string, part: string): AuthError =>
17
+ new AuthError({
18
+ code: 'X_OAUTH_STATE_INVALID',
19
+ cause: `${provider} callback rejected: ${part}`,
20
+ fix: `${restartAt(provider)} — a callback URL is single-use`,
21
+ meta: { provider },
22
+ });
23
+
24
+ /** The one phrase every "start over" fix is built from, so none of them can name a dead route. */
25
+ export const restartAt = (provider: string): string =>
26
+ `restart the flow at GET ${oauthStartPath(provider)}`;
27
+
28
+ /**
29
+ * The provider came back with `error=` and no code — almost always the user pressing Cancel.
30
+ * A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
31
+ * misconfigured, and folding the single commonest non-success outcome of a login into the code
32
+ * that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
33
+ */
34
+ export const oauthDenied = (
35
+ provider: string,
36
+ reason: string,
37
+ description: string | null,
38
+ ): AuthError =>
39
+ new AuthError({
40
+ code: 'X_OAUTH_DENIED',
41
+ // `reason` and `description` are query parameters off the callback URL — whatever the browser
42
+ // was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
43
+ // string literals, so a forged `error_description` cannot forge a second log line or break the
44
+ // sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
45
+ cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
46
+ description === null ? '' : ` (${renderCauseValue(description)})`
47
+ }`,
48
+ fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
49
+ meta: { provider, reason },
50
+ });
51
+
52
+ /**
53
+ * A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
54
+ * was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
55
+ * describes the app's configuration to an unauthenticated caller, and the fix is the same sentence
56
+ * either way.
57
+ *
58
+ * **`supported` is the CALLER's to scope, because the two callers have two audiences.**
59
+ * `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
60
+ * typed a URL, and the three built-ins are a framework constant already in the public docs, while
61
+ * the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
62
+ * `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
63
+ * is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
64
+ * what this deployment turned on is the disclosure the shared refusal exists to prevent.
65
+ *
66
+ * The fix names `registerOAuthProvider` first so it stays executable for the branch the narrowed
67
+ * list cannot cover — a segment nothing registered cannot be added to `providers` at all, so
68
+ * "add it" alone was an instruction that could not be followed.
69
+ *
70
+ * The segment itself is a URL path the caller typed, so it goes through `renderCauseValue` in the
71
+ * sentence and `renderFixLiteral` in the command — a fix has to parse after a hostile value lands
72
+ * in it.
73
+ */
74
+ export const oauthProviderUnknown = (provider: string, supported: readonly string[]): AuthError =>
75
+ new AuthError({
76
+ code: 'X_OAUTH_PROVIDER_UNKNOWN',
77
+ cause: `no oauth provider is mounted at ${renderCauseValue(oauthStartPath(provider))}`,
78
+ fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) if it is not built in, then add that id to defineAuth({ providers: [...] }) — known here: ${supported.map((id) => `'${id}'`).join(', ')}`,
79
+ meta: { provider },
80
+ });
81
+
82
+ /**
83
+ * Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
84
+ * module imported second decide where every login for that id goes — including which `issuers`
85
+ * an id token may claim — so the second registration refuses at boot instead.
86
+ */
87
+ export const oauthProviderDuplicate = (provider: string): AuthError =>
88
+ new AuthError({
89
+ code: 'X_OAUTH_PROVIDER_DUPLICATE',
90
+ cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
91
+ fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
92
+ meta: { provider },
93
+ });
94
+
95
+ export interface OAuthExchangeFailure {
96
+ readonly provider: string;
97
+ /**
98
+ * Which leg of the server-to-server conversation failed. `discovery` and `jwks` are the two
99
+ * boot/verification legs an enterprise OP adds: reading `/.well-known/openid-configuration`,
100
+ * and reading the key set an id token's signature is checked against.
101
+ */
102
+ readonly stage: 'token' | 'userinfo' | 'discovery' | 'jwks';
103
+ readonly detail: string;
104
+ readonly status?: number | undefined;
105
+ readonly fix: string;
106
+ }
107
+
108
+ /**
109
+ * Deliberately specific, unlike every credential error above it. This one describes a
110
+ * conversation between two servers — naming the stage, the provider and its own status
111
+ * discloses nothing about any user, and is the difference between a fixable misconfiguration
112
+ * and a shrug.
113
+ */
114
+ export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
115
+ new AuthError({
116
+ code: 'X_OAUTH_EXCHANGE_FAILED',
117
+ cause:
118
+ `${failure.provider} ${failure.stage} request failed` +
119
+ `${failure.status === undefined ? '' : ` with HTTP ${failure.status}`}: ${failure.detail}`,
120
+ fix: failure.fix,
121
+ meta: {
122
+ provider: failure.provider,
123
+ stage: failure.stage,
124
+ ...(failure.status === undefined ? {} : { status: failure.status }),
125
+ },
126
+ });
127
+
128
+ /**
129
+ * The address is proven to the provider, and an account that never proved it already holds it.
130
+ * Naming that is not account enumeration — this caller just demonstrated they own the address —
131
+ * and staying silent would leave them with a login that fails forever and no way out.
132
+ *
133
+ * The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
134
+ * key, and cannot redact an address that was already interpolated into a sentence.
135
+ */
136
+ export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
137
+ new AuthError({
138
+ code: 'X_UNAUTHENTICATED',
139
+ cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
140
+ fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
141
+ meta: { provider, email },
142
+ });
143
+
144
+ /**
145
+ * `link: 'never'` and a local account already holds the address. Same code and same disclosure
146
+ * rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
147
+ * so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
148
+ */
149
+ export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
150
+ new AuthError({
151
+ code: 'X_UNAUTHENTICATED',
152
+ cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
153
+ 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",
154
+ meta: { provider, email },
155
+ });
156
+
157
+ /**
158
+ * `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
159
+ * write. Falling back to the unstamped row would mint a session for a user every later login
160
+ * reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
161
+ * flow fails closed on an adapter that loses the stamp instead of half-succeeding.
162
+ */
163
+ export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
164
+ new AuthError({
165
+ code: 'X_NOT_IMPLEMENTED',
166
+ cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
167
+ fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
168
+ meta: { provider, userId },
169
+ });
170
+
171
+ /** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
172
+ export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
173
+ new AuthError({
174
+ code: 'X_OAUTH_TOKEN_INVALID',
175
+ cause: `${provider} id token rejected: ${reason}`,
176
+ fix,
177
+ meta: { provider },
178
+ });
@@ -4,8 +4,13 @@
4
4
  // token is verified here, so no caller downstream can forget to.
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
7
- import { EnvMissingError, renderCauseValue, renderThrowable, systemClock } from '@ultimat3/core';
8
- import { oauthExchangeFailed, restartAt } from './errors';
7
+ import {
8
+ EnvMissingError,
9
+ logger,
10
+ renderCauseValue,
11
+ renderThrowable,
12
+ systemClock,
13
+ } from '@ultimat3/core';
9
14
  import { type IdTokenClaims, verifyIdToken } from './id-token';
10
15
  import { isRecord } from './json';
11
16
  import type { IdTokenKeys } from './jwks';
@@ -15,6 +20,7 @@ import {
15
20
  type OAuthHandshake,
16
21
  type OAuthProviderId,
17
22
  } from './oauth';
23
+ import { oauthExchangeFailed, restartAt } from './oauth-errors';
18
24
  import { providerFor } from './oauth-registry';
19
25
 
20
26
  /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
@@ -91,7 +97,31 @@ export function oauthCredentials(
91
97
  * mostly prose this package authored, and quoting that would make every readable message unread-
92
98
  * able. The rule is "remote text is rendered", and this function is where remote text is made.
93
99
  */
94
- export async function providerDetail(response: Response): Promise<string> {
100
+ /**
101
+ * How much of a body this leg may quote back.
102
+ *
103
+ * `'raw'` — the default and what userinfo, discovery and jwks use: those requests carry a bearer
104
+ * token or nothing, and the raw text is what makes a misconfigured enterprise OP debuggable.
105
+ *
106
+ * `'coded-only'` — the `POST /token` leg, and ONLY it. That request carries `client_secret`, and
107
+ * an endpoint that echoes what it was sent (compromised, impersonated, or a misbehaving gateway)
108
+ * put the secret into an `X_OAUTH_EXCHANGE_FAILED` `cause:` that `oauth-route.ts` publishes to an
109
+ * unauthenticated caller. Measured: 38 of 42 characters of the secret reached the response body,
110
+ * and a shorter `redirect_uri` fits all of it inside `MAX_DETAIL_LENGTH`. A coded OAuth error
111
+ * still comes through — `invalid_grant`, `redirect_uri_mismatch` — because that is a field the
112
+ * SPEC defines and the whole reason this string exists.
113
+ */
114
+ export type DetailEcho = 'raw' | 'coded-only';
115
+
116
+ /** What a `coded-only` leg says instead of the body. Names why, so nobody re-adds the echo. */
117
+ const OPAQUE_BODY =
118
+ 'the endpoint answered with a body that is not a coded OAuth error, and this leg does not quote ' +
119
+ 'one back because its request carries the client secret';
120
+
121
+ export async function providerDetail(
122
+ response: Response,
123
+ echo: DetailEcho = 'raw',
124
+ ): Promise<string> {
95
125
  const text = await response.text().catch(() => '');
96
126
  if (text === '') return 'the response body was empty';
97
127
  try {
@@ -105,6 +135,7 @@ export async function providerDetail(response: Response): Promise<string> {
105
135
  } catch {
106
136
  // Not JSON — fall through to the truncated raw text below.
107
137
  }
138
+ if (echo === 'coded-only') return OPAQUE_BODY;
108
139
  return renderCauseValue(
109
140
  text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text,
110
141
  );
@@ -143,16 +174,31 @@ async function postForm(
143
174
  signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
144
175
  });
145
176
  } catch (error) {
146
- // `renderThrowable`, never `error.message` behind an `instanceof`: the rejection comes from
147
- // an injected `fetch`, and `instanceof` itself throws on a value whose `getPrototypeOf` trap
148
- // does losing the one refusal that tells a caller the code is already spent.
149
- const reason = renderThrowable(error);
177
+ // The rendered value goes to the LOG, never into the `detail:`.
178
+ //
179
+ // `oauth-route.ts` publishes this cause to an unauthenticated caller, and the rejection comes
180
+ // from an injected `OAuthFetch` — an app's own wrapper, a proxy client, an adapter behind it —
181
+ // so its text is a server's internals. Reproduced: a bare
182
+ // `Error('connect ECONNREFUSED 10.4.2.17:5432 (postgres://app:hunter2@db.internal/prod)')`
183
+ // was served as the 502 body of a public callback. `problem()`'s uncoded branch does NOT catch
184
+ // this one: it is coded by the time it gets there.
185
+ //
186
+ // `renderThrowable`, never `error.message` behind an `instanceof`: `instanceof` itself throws
187
+ // on a value whose `getPrototypeOf` trap does, losing the one refusal that tells a caller the
188
+ // code is already spent. What stays in the sentence is `url` and the remedy, both framework
189
+ // constants, so the failure is still fixable without quoting anything this package does not own.
190
+ logger.error('auth.oauth.token_fetch_failed', {
191
+ provider,
192
+ url,
193
+ detail: renderThrowable(error),
194
+ });
150
195
  throw oauthExchangeFailed({
151
196
  provider,
152
197
  stage: 'token',
153
198
  detail:
154
- `${reason} — nothing left this host for ${url} (egress, DNS or TLS); restart the ` +
155
- 'flow once it does, since the code is already spent',
199
+ `nothing left this host for ${url} (egress, DNS or TLS); the reason is in this process ` +
200
+ 'log under auth.oauth.token_fetch_failed. Restart the flow once it does, since the code ' +
201
+ 'is already spent',
156
202
  fix: `curl -sS -m 5 -o /dev/null ${url}`,
157
203
  });
158
204
  }
@@ -161,7 +207,9 @@ async function postForm(
161
207
  throw oauthExchangeFailed({
162
208
  provider,
163
209
  stage: 'token',
164
- detail: await providerDetail(response),
210
+ // `coded-only`: this request carried `client_secret`, and `oauth-route.ts` publishes this
211
+ // cause to whoever typed the URL.
212
+ detail: await providerDetail(response, 'coded-only'),
165
213
  status: response.status,
166
214
  fix: fixForStatus(provider, response.status),
167
215
  });
@@ -7,16 +7,15 @@ import { ConfigInvalidError, logger, uuid } 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';
11
+ import type { OAuthCallback, OAuthHandshake } from './oauth';
10
12
  import {
11
- authWriteFailed,
12
13
  emailVerifiedNotStored,
13
- mfaRequired,
14
14
  oauthAccountNotLinked,
15
15
  oauthExchangeFailed,
16
16
  oauthLinkingDisabled,
17
17
  restartAt,
18
- } from './errors';
19
- import type { OAuthCallback, OAuthHandshake } from './oauth';
18
+ } from './oauth-errors';
20
19
  import {
21
20
  exchangeOAuthCode,
22
21
  type OAuthClientCredentials,
@@ -200,8 +199,20 @@ function accountFor(auth: Auth, user: AuthUser, input: OAuthSignInInput): AuthAc
200
199
  userId: user.id,
201
200
  provider: input.profile.provider,
202
201
  providerAccountId: input.profile.providerAccountId,
203
- accessToken: input.tokens.accessToken,
204
- refreshToken: input.tokens.refreshToken,
202
+ // `null`, deliberately — this package does not persist a provider credential.
203
+ //
204
+ // `x_accounts.access_token` and `refresh_token` held live provider tokens in the CLEAR, under
205
+ // a `tables.ts` header promising "no column holds a plaintext secret", and NOTHING read either
206
+ // one back: `oauth-profile.ts` uses the token from the exchange, in flight, and
207
+ // `packages/auth/CLAUDE.md` states that refresh is not implemented. Declared and never wired
208
+ // — the same deletion this repo already ran for `jobs.driver`, except this one made a database
209
+ // dump into a set of usable third-party credentials.
210
+ //
211
+ // The TYPE keeps both fields: `AuthAccount` is the documented adapter seam, and an app that
212
+ // deliberately stores tokens implements `linkAccount` itself. What changed is what the
213
+ // framework writes.
214
+ accessToken: null,
215
+ refreshToken: null,
205
216
  expiresAt: input.tokens.expiresAt,
206
217
  createdAt: auth.clock.now(),
207
218
  };
@@ -4,10 +4,10 @@
4
4
  // decides whether this login may attach itself to an existing account by address.
5
5
 
6
6
  import { logger, renderThrowable } from '@ultimat3/core';
7
- import { oauthExchangeFailed, restartAt } from './errors';
8
7
  import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
9
8
  import { isRecord } from './json';
10
9
  import type { OAuthProviderId } from './oauth';
10
+ import { oauthExchangeFailed, restartAt } from './oauth-errors';
11
11
  import {
12
12
  OAUTH_USER_AGENT,
13
13
  type OAuthFetch,
@@ -5,9 +5,9 @@
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 { oauthProviderDuplicate, oauthProviderUnknown } from './errors';
9
8
  import type { OAuthProvider } from './oauth';
10
9
  import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
10
+ import { oauthProviderDuplicate, oauthProviderUnknown } from './oauth-errors';
11
11
 
12
12
  const registry = new Map<string, OAuthProvider>();
13
13
 
@@ -8,12 +8,18 @@
8
8
  // is tier 4 and describes a rendered page. A bare `Request` in, a `Response` out, drivable from
9
9
  // a test and mountable by any router that can match a `:param`.
10
10
 
11
- import { type Clock, isUltimateError, renderThrowable, type UltimateError } from '@ultimat3/core';
11
+ import {
12
+ type Clock,
13
+ isUltimateError,
14
+ logger,
15
+ renderThrowable,
16
+ type UltimateError,
17
+ } from '@ultimat3/core';
12
18
  import type { Auth, LoginResult } from './auth';
13
- import { oauthDenied, oauthExchangeFailed, oauthProviderUnknown } from './errors';
14
19
  import { beginOAuth, type OAuthProviderId } from './oauth';
15
20
  import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
16
21
  import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
22
+ import { oauthDenied, oauthExchangeFailed, oauthProviderUnknown } from './oauth-errors';
17
23
  import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
18
24
  import { oauthCredentials } from './oauth-exchange';
19
25
  import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
@@ -101,6 +107,24 @@ export const OAUTH_ROUTE_STATUS: Readonly<Record<string, number>> = {
101
107
  X_CONFIG_INVALID: 500,
102
108
  };
103
109
 
110
+ /**
111
+ * The status for a code, read as an OWN key of the table above.
112
+ *
113
+ * `OAUTH_ROUTE_STATUS[code] ?? 502` is an index read on a plain object, so `Object.prototype`
114
+ * answered for `toString`, `constructor`, `hasOwnProperty` and `__proto__` — and `toString`
115
+ * yields a FUNCTION, which was handed straight to `ResponseInit.status`. `code` is a plain
116
+ * `string` on every `UltimateError`, so no cast makes that unreachable. Fourth instance of the
117
+ * class in the framework, after i18n's catalog lookup, schema's `coerce` and mcp's `validate-args`.
118
+ *
119
+ * Exported so a test can drive it without building a `Response`; `OAUTH_ROUTE_STATUS` stays
120
+ * exported unchanged, because `scripts/oauth-route-status.test.ts` pins it against
121
+ * `@ultimat3/http`'s `statusFor`.
122
+ */
123
+ export function oauthRouteStatus(code: string): number {
124
+ // 502 is the default: an uncoded throw on this path came out of the provider conversation.
125
+ return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
126
+ }
127
+
104
128
  /**
105
129
  * What an anonymous caller is allowed to read. `UltimateError.toJSON()` carries `meta` and `stack`
106
130
  * — a developer's fields — and BOTH legs of this flow are public by definition, so serialising it
@@ -122,26 +146,42 @@ const publicBody = (coded: UltimateError): Record<string, string> => ({
122
146
  * wants a rendered page wraps these two descriptors; the framework ships the debuggable answer.
123
147
  */
124
148
  function problem(error: unknown, extraCookies: readonly string[]): Response {
125
- const coded = isUltimateError(error)
126
- ? error
127
- : oauthExchangeFailed({
128
- provider: 'oauth',
129
- stage: 'token',
130
- // `renderThrowable`, never `error.message`: the throw came from an adapter or a `fetch`
131
- // this package does not own, and a getter on `message` — or a `Proxy` trapping
132
- // `getPrototypeOf` — would make the callback's last answer throw instead of send.
133
- detail: renderThrowable(error),
134
- fix: 'throw an UltimateError from the AuthAdapter or OAuthFetch that failed — the factories are in packages/auth/src/errors.ts',
135
- });
149
+ const coded = isUltimateError(error) ? error : uncoded(error);
136
150
  const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
137
151
  for (const cookie of extraCookies) headers.append('set-cookie', cookie);
138
152
  return new Response(JSON.stringify(publicBody(coded)), {
139
- // 502 is the default: an uncoded throw on this path came out of the provider conversation.
140
- status: OAUTH_ROUTE_STATUS[coded.code] ?? 502,
153
+ status: oauthRouteStatus(coded.code),
141
154
  headers,
142
155
  });
143
156
  }
144
157
 
158
+ /**
159
+ * A throw with no code: a bug in an `AuthAdapter` or an `OAuthFetch` this package does not own.
160
+ *
161
+ * Its text goes to the LOG and never into the response. `publicBody` publishes `cause`, and the
162
+ * rendered value went straight into it — so a bare
163
+ * `Error('connect ECONNREFUSED 10.4.2.17:5432 (postgres://app:hunter2@db.internal/prod)')` from a
164
+ * pool inside a callback was served to whoever typed the URL, as a 502 with the connection string
165
+ * in the body. The comment above `publicBody` says a stack and `meta` must never reach that
166
+ * reader; the field carrying the message was the one exception nobody meant to make.
167
+ *
168
+ * `renderThrowable`, never `error.message`: the value came from code this package does not own,
169
+ * and a getter on `message` — or a `Proxy` trapping `getPrototypeOf` — would make the callback's
170
+ * last answer throw instead of send. Same shape `rate-limit.ts`'s `loginFailed()` uses: one fixed
171
+ * sentence outward, the discriminating detail kept where only an operator can read it.
172
+ */
173
+ function uncoded(error: unknown): UltimateError {
174
+ logger.error('auth.oauth.uncoded_failure', { detail: renderThrowable(error) });
175
+ return oauthExchangeFailed({
176
+ provider: 'oauth',
177
+ stage: 'token',
178
+ detail:
179
+ 'an adapter or fetch this package does not own threw a value with no error code; the ' +
180
+ 'detail is in this process log under auth.oauth.uncoded_failure',
181
+ fix: 'throw an UltimateError from the AuthAdapter or OAuthFetch that failed — the factories are in packages/auth/src/errors.ts, and the OAuth ones in packages/auth/src/oauth-errors.ts',
182
+ });
183
+ }
184
+
145
185
  /** `/auth/oauth/github` → `github`; `/auth/oauth/github/callback` → `github`. */
146
186
  function providerSegment(request: Request, leg: 'start' | 'callback'): string {
147
187
  const segments = new URL(request.url).pathname.split('/').filter((s) => s.length > 0);
@@ -173,6 +213,37 @@ function assertEnabled(auth: Auth, segment: string): OAuthProviderId {
173
213
  return provider;
174
214
  }
175
215
 
216
+ /**
217
+ * The credentials for a provider, or the SAME 404 an unknown provider gets.
218
+ *
219
+ * `assertEnabled` collapses "never heard of it" and "not enabled here" into one answer so an
220
+ * anonymous caller cannot enumerate this deployment's configuration. Past it, `oauthCredentials`
221
+ * threw `X_ENV_MISSING` and `problem()` answered 500 — publishing the app's own environment
222
+ * variable names in the `cause` and, worse, restoring the oracle: 500 meant "registered here",
223
+ * 404 meant "not". A provider with no credentials is not usable, which is the only thing this
224
+ * caller is entitled to learn.
225
+ *
226
+ * The real cause is not lost: `X_ENV_MISSING`'s `cause` and `fix` are the operator's, and they go
227
+ * to the process log where only an operator can read them.
228
+ */
229
+ function credentialsFor(
230
+ provider: OAuthProviderId,
231
+ options: OAuthLoginOptions,
232
+ ): OAuthClientCredentials {
233
+ if (options.credentials !== undefined) return options.credentials;
234
+ try {
235
+ return oauthCredentials(provider, options.env ?? Bun.env);
236
+ } catch (error) {
237
+ if (!isUltimateError(error) || error.code !== 'X_ENV_MISSING') throw error;
238
+ logger.error('auth.oauth.credentials_missing', {
239
+ provider,
240
+ cause: error.cause,
241
+ fix: error.fix,
242
+ });
243
+ throw oauthProviderUnknown(provider, BUILTIN_OAUTH_PROVIDER_IDS);
244
+ }
245
+ }
246
+
176
247
  function originFor(request: Request, options: OAuthLoginOptions): string {
177
248
  const env = options.env ?? Bun.env;
178
249
  const declared = options.baseUrl ?? env['APP_URL']?.trim() ?? '';
@@ -195,7 +266,7 @@ async function startHandler(
195
266
  ): Promise<Response> {
196
267
  try {
197
268
  const provider = assertEnabled(auth, providerSegment(request, 'start'));
198
- const credentials = options.credentials ?? oauthCredentials(provider, options.env ?? Bun.env);
269
+ const credentials = credentialsFor(provider, options);
199
270
  const handshake = beginOAuth({
200
271
  provider,
201
272
  clientId: credentials.clientId,
@@ -233,6 +304,10 @@ async function callbackHandler(
233
304
  const clear = hasOAuthProvider(segment) ? [clearHandshakeCookie(segment)] : [];
234
305
  try {
235
306
  const provider = assertEnabled(auth, segment);
307
+ // Asked here purely so a provider with no credentials answers 404 on THIS leg too: the
308
+ // exchange below resolves them again from the same two sources. One oracle, closed on both
309
+ // halves — a 404 on start and a 500 on callback is the same enumeration one request later.
310
+ credentialsFor(provider, options);
236
311
  const url = new URL(request.url);
237
312
  assertNoProviderError(url, provider);
238
313
  const result: LoginResult = await completeOAuthLogin(auth, {
package/src/oauth.ts CHANGED
@@ -4,7 +4,7 @@
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 { oauthStateInvalid } from './errors';
7
+ import { oauthStateInvalid } from './oauth-errors';
8
8
  import { providerFor } from './oauth-registry';
9
9
  import { base64Url, randomToken, sha256Bytes, timingSafeEqual } from './tokens';
10
10
 
package/src/tables.ts CHANGED
@@ -1,6 +1,29 @@
1
1
  // Single responsibility: the DDL `BuiltinAdapter` expects. Exported as plain strings so an app
2
- // can paste them into a migration and read exactly what auth stores — no column holds a
3
- // plaintext secret, and that is meant to be verifiable by reading, not by trusting.
2
+ // can paste them into a migration and read EXACTLY what auth stores — verifiable by reading,
3
+ // never by trusting.
4
+ //
5
+ // What is at rest, stated column by column rather than as a claim:
6
+ //
7
+ // | Column | Holds |
8
+ // |---|---|
9
+ // | `x_users.password_hash` | an argon2id digest, never the password |
10
+ // | `x_users.recovery_code_hashes` | SHA-256 digests, never the codes (`mfa.ts`) |
11
+ // | `x_users.mfa_secret` | **the base32 TOTP seed, in the clear** — see below |
12
+ // | `x_accounts.access_token` / `refresh_token` | **nothing.** The framework writes `null` |
13
+ // | `x_sessions.*`, `x_verifications.token_hash` | digests and metadata, never a token |
14
+ //
15
+ // `mfa_secret` is the one plaintext secret this schema still holds, and it is stated here rather
16
+ // than glossed: a TOTP seed is symmetric, so verifying a code requires the seed itself and a
17
+ // digest cannot replace it. Encrypting it needs a key-management seam this package does not have,
18
+ // which is a design change and not a patch — DEFERRED, deliberately, and written down so nobody
19
+ // reads the header above as covering it. `mfa.ts`'s "a database dump is not a permanent MFA
20
+ // bypass" is true of the recovery CODES and not of the seed.
21
+ //
22
+ // The two `x_accounts` token columns are kept in the DDL and written `null`: they held live
23
+ // provider credentials in the clear and nothing in this package ever read them back
24
+ // (`oauth-login.ts`'s `accountFor`). The columns stay so an app that deliberately stores tokens
25
+ // through its own `linkAccount` has somewhere to put them, and so an existing deployment needs
26
+ // no migration to stop.
4
27
 
5
28
  export const X_USERS_TABLE = `create table if not exists x_users (
6
29
  id uuid primary key,
@@ -12,6 +35,8 @@ export const X_USERS_TABLE = `create table if not exists x_users (
12
35
  permissions text[] not null default '{}',
13
36
  scopes text[] not null default '{}',
14
37
  external_id text unique,
38
+ -- The base32 TOTP seed, in the clear. A seed is symmetric: a digest cannot verify a code.
39
+ -- Encryption at rest is deferred and needs a key-management seam - see the header.
15
40
  mfa_secret text,
16
41
  recovery_code_hashes text[] not null default '{}',
17
42
  disabled_at timestamptz,
@@ -52,6 +77,8 @@ export const X_ACCOUNTS_TABLE = `create table if not exists x_accounts (
52
77
  user_id uuid not null references x_users (id) on delete cascade,
53
78
  provider text not null,
54
79
  provider_account_id text not null,
80
+ -- Written NULL by the framework. Nothing reads them back, and a live provider credential at
81
+ -- rest turns a database dump into third-party access. Kept for an app's own linkAccount().
55
82
  access_token text,
56
83
  refresh_token text,
57
84
  expires_at timestamptz,
package/src/verify.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
7
7
  import type { AuthVerification, VerificationStore } from './adapter';
8
+ import { normaliseEmail } from './email';
8
9
  import { AuthError } from './errors';
9
10
  import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
10
11
 
@@ -68,10 +69,17 @@ export async function issueVerification(
68
69
  const token = randomToken(32);
69
70
  const ttl = input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[input.purpose];
70
71
  const expiresAt = new Date(now.getTime() + ttl);
72
+ // The FOURTH identity door, and the one that did not normalise: `register`, `login`,
73
+ // `profileEmail` and `accountKey` all do. `putVerification` upserts on `(purpose, identifier)`
74
+ // and `adapter.ts` promises that "issuing a new token invalidates the previous one" — a promise
75
+ // that was per SPELLING, so N live reset tokens for one address could be held at once by varying
76
+ // the case, and each one of them is a password. It is also the address the mail is sent to, so
77
+ // the link goes to the inbox the account is keyed by rather than to whatever a form posted.
78
+ const identifier = normaliseEmail(input.identifier);
71
79
  await runtime.store.putVerification({
72
80
  id: randomToken(12),
73
81
  purpose: input.purpose,
74
- identifier: input.identifier,
82
+ identifier,
75
83
  tokenHash: sha256Hex(token),
76
84
  expiresAt,
77
85
  consumedAt: null,
@@ -79,7 +87,7 @@ export async function issueVerification(
79
87
  });
80
88
  await runtime.mail.send(
81
89
  VERIFICATION_TEMPLATES[input.purpose],
82
- input.identifier,
90
+ identifier,
83
91
  {
84
92
  ...(input.data ?? {}),
85
93
  link: input.link?.(token) ?? token,
@@ -119,7 +127,13 @@ export async function consumeVerification(
119
127
  input: ConsumeVerificationInput,
120
128
  ): Promise<AuthVerification> {
121
129
  const tokenHash = sha256Hex(input.token);
122
- const record = await runtime.store.takeVerification(input.purpose, input.identifier, tokenHash);
130
+ // Normalised on the way OUT as well as in, or a link issued for `ada@x.test` is unredeemable by
131
+ // a form that posted `Ada@X.test` — one door, one key.
132
+ const record = await runtime.store.takeVerification(
133
+ input.purpose,
134
+ normaliseEmail(input.identifier),
135
+ tokenHash,
136
+ );
123
137
  if (record === null) throw verificationInvalid(input.purpose);
124
138
  // Kept for the seam, not for the blessed adapters: `VerificationStore` is implementable by an
125
139
  // app, and one that ignores the third argument would otherwise redeem any token. Constant-time