@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,151 @@
1
+ // Single responsibility: the second leg of a sign-in. The first leg ends in `X_MFA_REQUIRED`
2
+ // carrying a sealed, short-lived challenge; `completeMfa` opens it, meters the code against the
3
+ // same buckets a password guess spends, and only then mints the session.
4
+
5
+ import { isUltimateError, openText, randomHex, type SealKeySource, seal } from '@ultimat3/core';
6
+ import type { Auth, LoginResult } from './auth';
7
+ import { type AuthError, mfaChallengeInvalid, mfaCodeRejected, mfaRequired } from './errors';
8
+ import { isRecord } from './json';
9
+ import { openLoginAttempt } from './login-attempt';
10
+ import { recoveryCodeHash, TOTP_DIGITS, verifyTotp } from './mfa';
11
+ import { openTotpSecret } from './mfa-secret';
12
+ import { resolveActor } from './policy-bridge';
13
+ import { accountKey } from './rate-limit';
14
+ import { createSession, sessionCookie } from './session';
15
+
16
+ /** Bound into the seal's tag: a value sealed for anything else does not open as a challenge. */
17
+ export const MFA_CHALLENGE_PURPOSE = 'auth:mfa-challenge';
18
+
19
+ /** Long enough to unlock a phone and read six digits; short enough that a lifted one is stale. */
20
+ export const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000;
21
+
22
+ interface MfaChallenge {
23
+ readonly userId: string;
24
+ /** Makes two challenges for one user at one instant different values. */
25
+ readonly nonce: string;
26
+ readonly expiresAtMs: number;
27
+ }
28
+
29
+ /**
30
+ * The refusal `login()` and the OAuth path end their first leg with, carrying the challenge in
31
+ * `meta.challenge`. Sealed under the app's master key (`x secrets init`), so it needs no table and
32
+ * no second secret — and a deployment that enrols a second factor without one fails here, loudly,
33
+ * with `X_SEAL_KEY_MISSING`.
34
+ */
35
+ export async function mfaChallengeRequired(
36
+ auth: Auth,
37
+ userId: string,
38
+ keys: SealKeySource = {},
39
+ ): Promise<AuthError> {
40
+ const challenge: MfaChallenge = {
41
+ userId,
42
+ nonce: randomHex(16),
43
+ expiresAtMs: auth.clock.now().getTime() + MFA_CHALLENGE_TTL_MS,
44
+ };
45
+ return mfaRequired(
46
+ await seal(JSON.stringify(challenge), { ...keys, purpose: MFA_CHALLENGE_PURPOSE }),
47
+ );
48
+ }
49
+
50
+ /** A seal that would not open is a bad challenge; a missing master key is a broken deployment. */
51
+ const UNOPENABLE: ReadonlySet<string> = new Set(['X_SEAL_INVALID', 'X_SEAL_KEY_UNKNOWN']);
52
+
53
+ async function openChallenge(
54
+ auth: Auth,
55
+ token: unknown,
56
+ keys: SealKeySource,
57
+ ): Promise<MfaChallenge> {
58
+ if (typeof token !== 'string' || token.length === 0) throw mfaChallengeInvalid();
59
+ let parsed: unknown;
60
+ try {
61
+ parsed = JSON.parse(await openText(token, { ...keys, purpose: MFA_CHALLENGE_PURPOSE }));
62
+ } catch (thrown) {
63
+ if (isUltimateError(thrown) && !UNOPENABLE.has(thrown.code)) throw thrown;
64
+ throw mfaChallengeInvalid();
65
+ }
66
+ if (!isRecord(parsed)) throw mfaChallengeInvalid();
67
+ const { userId, nonce, expiresAtMs } = parsed;
68
+ if (typeof userId !== 'string' || typeof nonce !== 'string' || typeof expiresAtMs !== 'number') {
69
+ throw mfaChallengeInvalid();
70
+ }
71
+ // The server's clock decides. `>=` on a number that is not one is false, so a NaN is refused
72
+ // by asking the question the way round that fails closed.
73
+ if (!(auth.clock.now().getTime() < expiresAtMs)) throw mfaChallengeInvalid();
74
+ return { userId, nonce, expiresAtMs };
75
+ }
76
+
77
+ export interface CompleteMfaOptions extends SealKeySource {
78
+ /** The caller's address: a wrong code counts against it, exactly as a wrong password does. */
79
+ readonly ip?: string | null | undefined;
80
+ readonly userAgent?: string | null | undefined;
81
+ }
82
+
83
+ /** What an authenticator app shows, once the spaces a person types are gone. */
84
+ const TOTP_SHAPE = new RegExp(`^[0-9]{${TOTP_DIGITS}}$`);
85
+
86
+ /**
87
+ * Finish a sign-in that stopped at `X_MFA_REQUIRED`: the challenge from `error.meta.challenge`
88
+ * and either the six digits or one recovery code. Answers what `login()` answers.
89
+ *
90
+ * The guess is RESERVED before the code is looked at, against the account, the address and the
91
+ * tenant — the buckets a password guess spends — so six digits are worth `maxAttempts` tries per
92
+ * window and no more, and a wrong code is followed by `X_ACCOUNT_LOCKED`. A code is good once:
93
+ * a TOTP step is remembered in `auth.totpReplay`, and a recovery code is consumed by the adapter
94
+ * in one atomic step, so two requests carrying one code cannot both finish.
95
+ */
96
+ export async function completeMfa(
97
+ auth: Auth,
98
+ challengeToken: string,
99
+ code: string,
100
+ options: CompleteMfaOptions = {},
101
+ ): Promise<LoginResult> {
102
+ const challenge = await openChallenge(auth, challengeToken, options);
103
+ const user = await auth.adapter.findUserById(challenge.userId);
104
+ if (user === null || user.disabledAt !== null || user.mfaSecret === null) {
105
+ throw mfaChallengeInvalid();
106
+ }
107
+
108
+ // Before anything is reserved: a stored value that is not sealed is the deployment's fault,
109
+ // not a guess, and it is refused (`X_MFA_SECRET_UNSEALED`) rather than read.
110
+ const secret = await openTotpSecret(user.mfaSecret, options);
111
+
112
+ const ip = options.ip ?? null;
113
+ const attempt = await openLoginAttempt(auth, accountKey(user.email), ip);
114
+ await attempt.reserveOrg(user.orgId);
115
+
116
+ const presented = typeof code === 'string' ? code.replaceAll(' ', '') : '';
117
+ if (TOTP_SHAPE.test(presented)) {
118
+ const now = auth.clock.now();
119
+ const verdict = verifyTotp({ secret, code: presented, at: now });
120
+ // Checked and remembered with no `await` between them: two concurrent completions carrying
121
+ // one code cannot both find the step unspent.
122
+ if (!verdict.ok || verdict.step === null || auth.totpReplay.isUsed(user.id, verdict.step)) {
123
+ throw mfaCodeRejected();
124
+ }
125
+ auth.totpReplay.remember(user.id, verdict.step, now);
126
+ } else {
127
+ let consumed: boolean;
128
+ try {
129
+ consumed = await auth.adapter.consumeRecoveryCode(user.id, recoveryCodeHash(presented));
130
+ } catch (thrown) {
131
+ // A store that is down reached no verdict, so the guess is not counted as a wrong one.
132
+ await attempt.release();
133
+ throw thrown;
134
+ }
135
+ if (!consumed) throw mfaCodeRejected();
136
+ }
137
+ await attempt.succeed();
138
+
139
+ const issued = await createSession(auth.sessions, {
140
+ userId: user.id,
141
+ ip,
142
+ userAgent: options.userAgent,
143
+ mfaSatisfied: true,
144
+ });
145
+ return {
146
+ actor: resolveActor({ kind: 'user', user, session: issued.session }),
147
+ session: issued.session,
148
+ token: issued.token,
149
+ cookie: sessionCookie(issued.token, auth.sessions.policy),
150
+ };
151
+ }
@@ -0,0 +1,91 @@
1
+ // Single responsibility: the second-factor secret at rest. A TOTP seed is symmetric — verifying a
2
+ // code needs the seed itself — so it is sealed under the app's master key, never hashed and never
3
+ // stored in the clear. One writer, one reader, and the one-shot that seals what predates them.
4
+
5
+ import { isSealed, openText, type SealKeySource, seal } from '@ultimat3/core';
6
+ import type { UserStore } from './adapter';
7
+ import { authWriteFailed, mfaSecretInvalid, mfaSecretUnsealed } from './errors';
8
+ import { base32Decode } from './mfa';
9
+
10
+ /** The slice of the seam this file reads and writes — what a CLI holding only a database can supply. */
11
+ export interface MfaSecretStore {
12
+ readonly adapter: Pick<UserStore, 'listUsersWithMfaSecret' | 'replaceMfaSecret' | 'updateUser'>;
13
+ }
14
+
15
+ /** Bound into the tag: a value sealed for any other column does not open as a TOTP secret. */
16
+ export const MFA_SECRET_PURPOSE = 'auth:x_users.mfa_secret';
17
+
18
+ /**
19
+ * Enrolment's write: the base32 secret `enrolTotp` answered, sealed, onto the user's row. The
20
+ * ONLY way a secret should reach `x_users.mfa_secret` — a value written past this is refused by
21
+ * every reader until `sealMfaSecrets` has sealed it.
22
+ */
23
+ export async function saveTotpSecret(
24
+ auth: MfaSecretStore,
25
+ userId: string,
26
+ secret: string,
27
+ keys: SealKeySource = {},
28
+ ): Promise<void> {
29
+ // A secret nothing can derive a code from is refused before it is sealed: sealed, it would be
30
+ // an opaque value nobody could tell was a lockout.
31
+ if (typeof secret !== 'string' || base32Decode(secret).length === 0) {
32
+ throw mfaSecretInvalid('saveTotpSecret');
33
+ }
34
+ const sealed = await seal(secret, { ...keys, purpose: MFA_SECRET_PURPOSE });
35
+ const user = await auth.adapter.updateUser(userId, { mfaSecret: sealed });
36
+ if (user === null) throw authWriteFailed('updateUser', 'x_users');
37
+ }
38
+
39
+ /**
40
+ * The base32 secret behind a stored value. A value that is not sealed is REFUSED, never read as
41
+ * the secret it probably is: a plaintext read kept "for now" is a plaintext column forever.
42
+ */
43
+ export async function openTotpSecret(stored: string, keys: SealKeySource = {}): Promise<string> {
44
+ if (!isSealed(stored)) throw mfaSecretUnsealed();
45
+ return await openText(stored, { ...keys, purpose: MFA_SECRET_PURPOSE });
46
+ }
47
+
48
+ export interface SealMfaSecretsReport {
49
+ /** Values that were plaintext and are now sealed. */
50
+ readonly sealed: number;
51
+ /** Values that were already sealed, and were left exactly as they were. */
52
+ readonly alreadySealed: number;
53
+ /**
54
+ * Rows whose secret changed between the read and the write — the user re-enrolled, or
55
+ * un-enrolled, mid-run — and were left alone. A new enrolment is already sealed.
56
+ */
57
+ readonly skipped: number;
58
+ }
59
+
60
+ /** How many stored secrets are still plaintext — what `x doctor` reports before a user finds out. */
61
+ export async function countUnsealedMfaSecrets(auth: MfaSecretStore): Promise<number> {
62
+ const stored = await auth.adapter.listUsersWithMfaSecret();
63
+ return stored.filter((row) => !isSealed(row.mfaSecret)).length;
64
+ }
65
+
66
+ /**
67
+ * The one-shot behind `x auth seal-mfa`: every plaintext `mfa_secret` sealed in place. Idempotent
68
+ * — a sealed value is counted and never touched — so a run that stopped half way is finished by
69
+ * running it again. The master key is resolved by the first `seal`, before any row is written.
70
+ *
71
+ * Each write is a compare-and-set on the value that was read: a row that changed underneath is
72
+ * `skipped`, never overwritten with the secret it used to hold.
73
+ */
74
+ export async function sealMfaSecrets(
75
+ auth: MfaSecretStore,
76
+ keys: SealKeySource = {},
77
+ ): Promise<SealMfaSecretsReport> {
78
+ let sealed = 0;
79
+ let alreadySealed = 0;
80
+ let skipped = 0;
81
+ for (const row of await auth.adapter.listUsersWithMfaSecret()) {
82
+ if (isSealed(row.mfaSecret)) {
83
+ alreadySealed += 1;
84
+ continue;
85
+ }
86
+ const value = await seal(row.mfaSecret, { ...keys, purpose: MFA_SECRET_PURPOSE });
87
+ if (await auth.adapter.replaceMfaSecret(row.userId, row.mfaSecret, value)) sealed += 1;
88
+ else skipped += 1;
89
+ }
90
+ return { sealed, alreadySealed, skipped };
91
+ }
package/src/mfa.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  // Single responsibility: TOTP (RFC 6238) and recovery codes. The drift window is explicit and
2
2
  // small, and every accepted step is remembered: without replay rejection a code shouted over a
3
3
  // phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
4
- // and single-use, so a database dump is not a permanent MFA bypass.
4
+ // and single-use, so a database dump is not a permanent MFA bypass; consuming one is the
5
+ // adapter's (`consumeRecoveryCode`), because single-use has to be one atomic step.
5
6
 
6
- import { finiteCount } from '@ultimat3/core';
7
+ import { finiteCount, timingSafeEqual } from '@ultimat3/core';
7
8
  import type { Auth } from './auth';
8
9
  import { mfaSecretInvalid } from './errors';
9
10
  import { assertFiniteAuthCount } from './policy-numbers';
10
- import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
11
+ import { randomBytes, sha256Hex } from './tokens';
11
12
 
12
13
  const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
13
14
 
@@ -195,7 +196,7 @@ export interface TotpReplayGuard {
195
196
  remember(subject: string, step: number, at: Date): void;
196
197
  }
197
198
 
198
- /** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
199
+ /** What `memoryTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
199
200
  export interface MemoryTotpReplayGuard extends TotpReplayGuard {
200
201
  readonly size: number;
201
202
  }
@@ -244,9 +245,9 @@ const newestStep = (steps: ReadonlySet<number>): number => {
244
245
  * is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
245
246
  * entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
246
247
  * is not enough does the cap evict live state, furthest from the live window first — the shape
247
- * `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
248
+ * `memoryAuthLimiter` evicts by, where a live lockout is the last bucket to go.
248
249
  */
249
- export function createTotpReplayGuard(
250
+ export function memoryTotpReplayGuard(
250
251
  drift: number = TOTP_DRIFT_STEPS,
251
252
  maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
252
253
  ): MemoryTotpReplayGuard {
@@ -308,6 +309,12 @@ export interface RecoveryCodeSet {
308
309
  const normaliseRecoveryCode = (code: string): string =>
309
310
  code.replaceAll('-', '').replaceAll(' ', '').toUpperCase();
310
311
 
312
+ /**
313
+ * What a recovery code is stored and looked up as: SHA-256 of the code with the dashes and spaces
314
+ * a person types taken out. `AuthAdapter.consumeRecoveryCode` takes this, never the code.
315
+ */
316
+ export const recoveryCodeHash = (code: string): string => sha256Hex(normaliseRecoveryCode(code));
317
+
311
318
  /**
312
319
  * `count` is a LOOP BOUND, so it fails in both directions and neither is a smaller set of codes.
313
320
  * `NaN` — what `Number(process.env.RECOVERY_CODES)` answers for an unset variable, and not nullish,
@@ -326,26 +333,5 @@ export function generateRecoveryCodes(count = 10): RecoveryCodeSet {
326
333
  const raw = base32Encode(randomBytes(10)).slice(0, 16);
327
334
  codes.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}`);
328
335
  }
329
- return { codes, hashes: codes.map((code) => sha256Hex(normaliseRecoveryCode(code))) };
330
- }
331
-
332
- /**
333
- * Returns the remaining hashes with the redeemed one removed, or `null` if nothing matched.
334
- * The caller persists the result — that write is what makes a code single-use.
335
- */
336
- export function redeemRecoveryCode(
337
- code: string,
338
- hashes: readonly string[],
339
- ): readonly string[] | null {
340
- const candidate = sha256Hex(normaliseRecoveryCode(code));
341
- let matched = false;
342
- const remaining: string[] = [];
343
- for (const hash of hashes) {
344
- if (!matched && timingSafeEqual(hash, candidate)) {
345
- matched = true;
346
- continue;
347
- }
348
- remaining.push(hash);
349
- }
350
- return matched ? remaining : null;
336
+ return { codes, hashes: codes.map(recoveryCodeHash) };
351
337
  }
@@ -39,6 +39,12 @@ export const GOOGLE_PROVIDER: OAuthProvider = {
39
39
  clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
40
40
  };
41
41
 
42
+ /**
43
+ * UNPROVEN: this flow has never completed a sign-in against Apple, in a test or anywhere else.
44
+ * Apple answers a request that asks for a scope with `response_mode=form_post` — a POST to the
45
+ * callback — and both route descriptors in `oauth-route.ts` are GET and read the query string.
46
+ * Whether this entry ships or is removed is an open owner decision; do not rely on it.
47
+ */
42
48
  export const APPLE_PROVIDER: OAuthProvider = {
43
49
  id: 'apple',
44
50
  authorizeUrl: 'https://appleid.apple.com/auth/authorize',
@@ -6,13 +6,19 @@
6
6
  // format at one trust level.
7
7
 
8
8
  import type { Clock } from '@ultimat3/core';
9
- import { EnvMissingError, systemClock } from '@ultimat3/core';
9
+ import {
10
+ EnvMissingError,
11
+ readCookie,
12
+ serializeSetCookie,
13
+ systemClock,
14
+ timingSafeEqual,
15
+ } from '@ultimat3/core';
10
16
  import type { OAuthHandshake, OAuthProviderId } from './oauth';
11
17
  import { oauthStateInvalid } from './oauth-errors';
12
18
  import { hasOAuthProvider } from './oauth-registry';
13
19
  import { assertFiniteAuthCount } from './policy-numbers';
14
- import { type RequestLike, readCookie } from './session';
15
- import { base64Url, timingSafeEqual } from './tokens';
20
+ import type { RequestLike } from './session';
21
+ import { base64Url } from './tokens';
16
22
 
17
23
  /** `__Host-` for the same reason the session cookie carries it: no subdomain can plant one. */
18
24
  export const OAUTH_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth';
@@ -67,7 +73,8 @@ export function handshakeSecret(
67
73
  cause:
68
74
  secret === ''
69
75
  ? 'SESSION_SECRET is not set, so an oauth handshake cannot be signed'
70
- : `SESSION_SECRET is ${secret.length} characters and at least ${MIN_SECRET_LENGTH} are required`,
76
+ : // Never its length: how long the configured secret is narrows a guess at it.
77
+ `SESSION_SECRET is shorter than the ${MIN_SECRET_LENGTH} characters required`,
71
78
  fix: 'export SESSION_SECRET="$(openssl rand -hex 32)"',
72
79
  meta: { key: 'SESSION_SECRET', minLength: MIN_SECRET_LENGTH },
73
80
  });
@@ -188,7 +195,7 @@ export function handshakeCookie(
188
195
  const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
189
196
  // The handshake already names its provider, so the two legs cannot disagree about the name.
190
197
  const name = options?.name ?? handshakeCookieName(handshake.provider);
191
- return `${name}=${sealHandshake(handshake, options)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
198
+ return serializeSetCookie(name, sealHandshake(handshake, options), { maxAge });
192
199
  }
193
200
 
194
201
  /**
@@ -199,7 +206,7 @@ export function handshakeCookie(
199
206
  * clear nothing, and clearing every provider's would cancel a login running in another tab.
200
207
  */
201
208
  export function clearHandshakeCookie(provider: OAuthProviderId, name?: string): string {
202
- return `${name ?? handshakeCookieName(provider)}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
209
+ return serializeSetCookie(name ?? handshakeCookieName(provider), '', { maxAge: 0 });
203
210
  }
204
211
 
205
212
  /** Reads the cookie set on the redirect and returns the handshake `completeOAuthLogin` takes. */
@@ -208,7 +215,10 @@ export function readHandshakeCookie(
208
215
  provider: OAuthProviderId,
209
216
  options?: HandshakeCookieOptions,
210
217
  ): OAuthHandshake {
211
- const sealed = readCookie(request, options?.name ?? handshakeCookieName(provider));
218
+ const sealed = readCookie(
219
+ request.headers.get('cookie'),
220
+ options?.name ?? handshakeCookieName(provider),
221
+ );
212
222
  if (sealed === null) {
213
223
  throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback');
214
224
  }
@@ -3,7 +3,12 @@
3
3
  // boot, no dependency — an enterprise IdP is then three lines instead of a hand-copied table of
4
4
  // four endpoints that nobody re-checks when the vendor moves one.
5
5
 
6
- import { renderFixLiteral, renderFixShellArg, renderThrowable } from '@ultimat3/core';
6
+ import {
7
+ renderCauseValue,
8
+ renderFixLiteral,
9
+ renderFixShellArg,
10
+ renderThrowable,
11
+ } from '@ultimat3/core';
7
12
  import { isRecord } from './json';
8
13
  import type { OAuthProvider } from './oauth';
9
14
  import { oauthExchangeFailed } from './oauth-errors';
@@ -16,9 +21,12 @@ const TIMEOUT_CONSEQUENCE =
16
21
 
17
22
  const DEFAULT_TIMEOUT_MS = 10_000;
18
23
 
24
+ /** An issuer as it is compared and built on: its trailing slashes are not part of its identity. */
25
+ const bareIssuer = (issuer: string): string => issuer.replace(/\/+$/, '');
26
+
19
27
  /** The document lives at a fixed suffix off the issuer; the issuer keeps its own path. */
20
28
  export const discoveryUrl = (issuer: string): string =>
21
- `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
29
+ `${bareIssuer(issuer)}/.well-known/openid-configuration`;
22
30
 
23
31
  export interface DiscoverOAuthProviderInput {
24
32
  /** The id the URL segment carries: `/auth/oauth/<id>`. Also names the two env vars. */
@@ -46,9 +54,9 @@ const envPrefix = (id: string): string => id.toUpperCase().replace(/[^A-Z0-9]+/g
46
54
  * `registerOAuthProvider(await discoverOAuthProvider({ id, issuer }))` keeps one install point,
47
55
  * and an app that wants to override one endpoint spreads the result before registering it.
48
56
  *
49
- * `issuers` is pinned to the `issuer` the document itself declares, not the URL that was asked
50
- * for — a document that names a different issuer is answering for somebody else, and pinning the
51
- * requested URL would let it.
57
+ * A document that declares a different issuer than the one asked for is answering for somebody
58
+ * else and is refused (OIDC Discovery 1.0 §4.3). `issuers` then carries the document's own
59
+ * spelling, which is what its id tokens put in `iss`; only a trailing slash may differ.
52
60
  */
53
61
  export async function discoverOAuthProvider(
54
62
  input: DiscoverOAuthProviderInput,
@@ -124,6 +132,17 @@ export async function discoverOAuthProvider(
124
132
  });
125
133
  }
126
134
 
135
+ if (bareIssuer(issuer) !== bareIssuer(input.issuer)) {
136
+ throw oauthExchangeFailed({
137
+ provider: input.id,
138
+ stage: 'discovery',
139
+ detail:
140
+ `the discovery document declares issuer ${renderCauseValue(issuer)}, and it was fetched ` +
141
+ `for ${renderCauseValue(input.issuer)}, so it is answering for another issuer`,
142
+ fix: `curl -sS -m 5 ${renderFixShellArg(url, '<issuer>/.well-known/openid-configuration')} | jq .issuer`,
143
+ });
144
+ }
145
+
127
146
  const jwksUri = stringOrNull(body, 'jwks_uri');
128
147
  if (jwksUri === null) {
129
148
  // Without a key set there is nothing to verify an id token against, and the only remaining
@@ -1,7 +1,7 @@
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.
1
+ // Single responsibility: the OAuth refusals more than one OAuth module raises, and the one phrase
2
+ // every "start over" fix is built from. A refusal with one thrower lives beside it (`oauthDenied`
3
+ // in `oauth-route.ts`, the linking ones in `oauth-login.ts`); the codes, titles and the single
4
+ // `registerErrorCodes()` call stay in `errors.ts`, so this file adds no code and registers nothing.
5
5
 
6
6
  import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
7
7
  import { AuthError } from './errors';
@@ -25,30 +25,6 @@ export const oauthStateInvalid = (provider: string, part: string): AuthError =>
25
25
  export const restartAt = (provider: string): string =>
26
26
  `restart the flow at GET ${oauthStartPath(provider)}`;
27
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
28
  /**
53
29
  * A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
54
30
  * was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
@@ -79,19 +55,6 @@ export const oauthProviderUnknown = (provider: string, supported: readonly strin
79
55
  meta: { provider },
80
56
  });
81
57
 
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
58
  export interface OAuthExchangeFailure {
96
59
  readonly provider: string;
97
60
  /**
@@ -125,49 +88,6 @@ export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
125
88
  },
126
89
  });
127
90
 
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
91
  /** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
172
92
  export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
173
93
  new AuthError({
@@ -124,6 +124,13 @@ const OPAQUE_BODY =
124
124
  'the endpoint answered with a body that is not a coded OAuth error, and this leg does not quote ' +
125
125
  'one back because its request carries the client secret';
126
126
 
127
+ /**
128
+ * The one way a remote endpoint's own words reach a cause: cut, then rendered. Every branch that
129
+ * quotes the other end goes through it — a branch that skipped it published 5 KB verbatim.
130
+ */
131
+ const remoteText = (text: string): string =>
132
+ renderCauseValue(text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text);
133
+
127
134
  export async function providerDetail(
128
135
  response: Response,
129
136
  echo: DetailEcho = 'raw',
@@ -140,17 +147,13 @@ export async function providerDetail(
140
147
  parsed['error_description'] ??
141
148
  parsed['error'] ??
142
149
  (echo === 'coded-only' ? undefined : parsed['message']);
143
- if (typeof description === 'string' && description !== '') {
144
- return renderCauseValue(description);
145
- }
150
+ if (typeof description === 'string' && description !== '') return remoteText(description);
146
151
  }
147
152
  } catch {
148
153
  // Not JSON — fall through to the truncated raw text below.
149
154
  }
150
155
  if (echo === 'coded-only') return OPAQUE_BODY;
151
- return renderCauseValue(
152
- text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text,
153
- );
156
+ return remoteText(text);
154
157
  }
155
158
 
156
159
  function fixForStatus(provider: OAuthProviderId, status: number): string {
@@ -257,7 +260,10 @@ async function postForm(
257
260
  throw oauthExchangeFailed({
258
261
  provider,
259
262
  stage: 'token',
260
- detail: typeof description === 'string' && description !== '' ? description : error,
263
+ // Remote text on a 200, so it is cut and rendered exactly as a non-2xx body is.
264
+ detail: remoteText(
265
+ typeof description === 'string' && description !== '' ? description : error,
266
+ ),
261
267
  fix:
262
268
  error === 'bad_verification_code' || error === 'invalid_grant'
263
269
  ? `${restartAt(provider)} — an authorization code is single-use and short-lived`