@ultimat3/auth 1.2.0 → 2.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.
package/src/errors.ts CHANGED
@@ -3,7 +3,13 @@
3
3
  // factories here are deliberately coarse — `rate-limit.ts` owns the single login failure
4
4
  // every credential path must throw, and nothing else describes *why* a credential failed.
5
5
 
6
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+ import {
7
+ registerErrorCodes,
8
+ renderCauseValue,
9
+ renderFixLiteral,
10
+ UltimateError,
11
+ } from '@ultimat3/core';
12
+ import { oauthStartPath } from './oauth-paths';
7
13
 
8
14
  /** Codes this package declares and owns. `X_UNAUTHENTICATED` is auth's; http only borrows it. */
9
15
  export const AUTH_OWNED_ERROR_CODES = [
@@ -13,10 +19,15 @@ export const AUTH_OWNED_ERROR_CODES = [
13
19
  'X_OAUTH_STATE_INVALID',
14
20
  'X_OAUTH_EXCHANGE_FAILED',
15
21
  'X_OAUTH_TOKEN_INVALID',
22
+ 'X_OAUTH_PROVIDER_UNKNOWN',
23
+ 'X_OAUTH_PROVIDER_DUPLICATE',
24
+ 'X_OAUTH_DENIED',
16
25
  'X_PASSWORD_WEAK',
17
26
  'X_ACCOUNT_LOCKED',
18
27
  'X_API_KEY_INVALID',
19
28
  'X_AUTH_WRITE_FAILED',
29
+ 'X_AUTH_LIMITER_NOT_SHARED',
30
+ 'X_AUTH_LIMITER_POLICY_MISMATCH',
20
31
  ] as const;
21
32
 
22
33
  /**
@@ -24,7 +35,20 @@ export const AUTH_OWNED_ERROR_CODES = [
24
35
  * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. No titles here on purpose — a second copy of a title
25
36
  * is a title that drifts, and registering one of these would be `X_ERROR_CODE_DUPLICATE`.
26
37
  */
27
- export const AUTH_BORROWED_ERROR_CODES = ['X_FORBIDDEN', 'X_NOT_IMPLEMENTED'] as const;
38
+ // `X_ENV_MISSING` and `X_CONFIG_INVALID` were thrown here long before they were declared here —
39
+ // oauth-cookie.ts and oauth-exchange.ts refuse on a missing secret, oauth-login.ts on a bad
40
+ // config. An undeclared borrow is a code the manifest cannot attribute to the package that throws
41
+ // it, so `x errors` could not point a reader at this file.
42
+ // `X_OVERLOADED` is `@ultimat3/http`'s, borrowed rather than re-declared: this package is the
43
+ // same tier and can never import http, but a refusal to start 19 MiB of argon2 work IS load
44
+ // shedding and must read as the same thing to a client (503 + Retry-After) whichever layer shed it.
45
+ export const AUTH_BORROWED_ERROR_CODES = [
46
+ 'X_FORBIDDEN',
47
+ 'X_NOT_IMPLEMENTED',
48
+ 'X_ENV_MISSING',
49
+ 'X_CONFIG_INVALID',
50
+ 'X_OVERLOADED',
51
+ ] as const;
28
52
 
29
53
  /** Every code auth can throw: the ones it owns plus the ones it borrows. */
30
54
  export const AUTH_ERROR_CODES = [...AUTH_OWNED_ERROR_CODES, ...AUTH_BORROWED_ERROR_CODES] as const;
@@ -38,11 +62,16 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
38
62
  X_MFA_REQUIRED: 'a second factor is required before this session is usable',
39
63
  X_OAUTH_STATE_INVALID: 'oauth state, nonce or pkce verifier did not match',
40
64
  X_OAUTH_EXCHANGE_FAILED: 'the oauth provider refused the exchange or returned no usable identity',
41
- X_OAUTH_TOKEN_INVALID: 'id token failed its issuer, audience or expiry check',
65
+ X_OAUTH_TOKEN_INVALID: 'id token failed its signature, issuer, audience or expiry check',
66
+ X_OAUTH_PROVIDER_UNKNOWN: 'the URL named a provider this app has not enabled',
67
+ X_OAUTH_PROVIDER_DUPLICATE: 'two oauth providers were registered under one id',
68
+ X_OAUTH_DENIED: 'the user or the provider declined the authorization',
42
69
  X_PASSWORD_WEAK: 'password does not meet the configured policy',
43
70
  X_ACCOUNT_LOCKED: 'too many failed attempts; this key is locked out',
44
71
  X_API_KEY_INVALID: 'api key is unknown, revoked, expired or wrong',
45
72
  X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
73
+ X_AUTH_LIMITER_NOT_SHARED: 'the lockout is declared fleet-wide and the limiter is per-process',
74
+ X_AUTH_LIMITER_POLICY_MISMATCH: 'the limiter in use enforces other numbers than the app declared',
46
75
  };
47
76
 
48
77
  // Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
@@ -74,11 +103,17 @@ export class AuthError extends UltimateError {
74
103
  }
75
104
  }
76
105
 
106
+ /**
107
+ * The two things that produce an anonymous actor on a surface that needs one, so the fix names
108
+ * both: the request carried no `__Host-x_session` cookie, or it did and nothing resolved it.
109
+ * `authenticate()` returns the anonymous actor for a missing cookie rather than throwing, which
110
+ * is correct — and is exactly why the failure surfaces here, one layer later, instead of there.
111
+ */
77
112
  export const unauthenticated = (surface: string): AuthError =>
78
113
  new AuthError({
79
114
  code: 'X_UNAUTHENTICATED',
80
115
  cause: `${surface} needs an actor but ctx.actor is anonymous`,
81
- fix: 'x auth whoami --json # confirm the request carries the __Host-x_session cookie',
116
+ fix: 'send the __Host-x_session cookie with this request, and resolve it once at the boundary: authenticate(auth, readSessionCookie(request, auth.sessions.policy))',
82
117
  });
83
118
 
84
119
  export const forbidden = (surface: string, reason: string): AuthError =>
@@ -100,27 +135,138 @@ export const sessionUnknown = (): AuthError =>
100
135
  new AuthError({
101
136
  code: 'X_UNAUTHENTICATED',
102
137
  cause: 'the session cookie does not match any live session',
103
- fix: 'x auth sessions list --json # then sign in again to mint a fresh session',
138
+ // No lookup by cookie is offered, and that is deliberate: a forged id and a deleted session
139
+ // are indistinguishable here by design. The reader signs in again; a developer holding a
140
+ // user id can see what is still live without the cookie telling them anything.
141
+ fix: 'sign in again to mint a fresh session — listDevices(auth.sessions, userId) lists the sessions still live for a user',
142
+ });
143
+
144
+ /**
145
+ * Shed BEFORE the KDF runs, the same decision `@ultimat3/http`'s `admit` stage makes for a whole
146
+ * request. `retryAfterSeconds` rides in `meta` because this package cannot reach an HTTP header;
147
+ * the host reads it onto `Retry-After`.
148
+ */
149
+ export const kdfOverloaded = (active: number, queued: number): AuthError =>
150
+ new AuthError({
151
+ code: 'X_OVERLOADED',
152
+ cause: `${active} password hashes are already running and ${queued} more are queued`,
153
+ fix: 'retry after the Retry-After header; widen the ceiling with configureKdfGate({ maxConcurrent, maxQueued }) only if the box has the memory — every argon2id hash holds ~19 MiB while it runs',
154
+ meta: { active, queued, retryAfterSeconds: 1 },
104
155
  });
105
156
 
157
+ /**
158
+ * The second leg is the APP's, and this line says so — it named `POST /auth/mfa/verify` for a
159
+ * release while no such route, no `completeMfa()` and no pending-MFA credential existed anywhere,
160
+ * the same dead-`fix:` defect `oauth-paths.ts` exists to stop. Shipping that route from here would
161
+ * be worse than saying nothing: the only correlation value this error carries is a user id, so the
162
+ * route would be unauthenticated by construction and MFA would become the ONLY factor. The design
163
+ * constraint for the real second leg is in `packages/auth/CLAUDE.md`.
164
+ *
165
+ * `userId` is `meta`, never `cause`: both surfaces that render this code to an anonymous caller
166
+ * (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause` and drop
167
+ * `meta`, and a user id handed to whoever typed the URL is what feeds the attack above.
168
+ */
106
169
  export const mfaRequired = (userId: string): AuthError =>
107
170
  new AuthError({
108
171
  code: 'X_MFA_REQUIRED',
109
- cause: `user ${userId} has TOTP enrolled and this session has not satisfied it`,
110
- fix: 'POST /auth/mfa/verify { code } with the 6-digit code, then retry',
172
+ cause: 'this account has TOTP enrolled and the second factor has not been satisfied',
173
+ fix: 'no second-factor route ships yet — catch X_MFA_REQUIRED in your sign-in handler, check the code with verifyTotp({ secret: user.mfaSecret, code, at }), then mint the session with createSession(auth.sessions, { userId, mfaSatisfied: true })',
174
+ meta: { userId },
111
175
  });
112
176
 
177
+ /**
178
+ * The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
179
+ * line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
180
+ * so every caller who followed it hit a 404. One declaration, read by the mount and by the fix,
181
+ * is what stops that recurring — `oauthLogin()` cannot move without moving this sentence.
182
+ */
113
183
  export const oauthStateInvalid = (provider: string, part: string): AuthError =>
114
184
  new AuthError({
115
185
  code: 'X_OAUTH_STATE_INVALID',
116
186
  cause: `${provider} callback rejected: ${part}`,
117
- fix: `restart the flow at GET /auth/oauth/${provider} — a callback URL is single-use`,
187
+ fix: `${restartAt(provider)} — a callback URL is single-use`,
188
+ meta: { provider },
189
+ });
190
+
191
+ /** The one phrase every "start over" fix is built from, so none of them can name a dead route. */
192
+ export const restartAt = (provider: string): string =>
193
+ `restart the flow at GET ${oauthStartPath(provider)}`;
194
+
195
+ /**
196
+ * The provider came back with `error=` and no code — almost always the user pressing Cancel.
197
+ * A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
198
+ * misconfigured, and folding the single commonest non-success outcome of a login into the code
199
+ * that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
200
+ */
201
+ export const oauthDenied = (
202
+ provider: string,
203
+ reason: string,
204
+ description: string | null,
205
+ ): AuthError =>
206
+ new AuthError({
207
+ code: 'X_OAUTH_DENIED',
208
+ // `reason` and `description` are query parameters off the callback URL — whatever the browser
209
+ // was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
210
+ // string literals, so a forged `error_description` cannot forge a second log line or break the
211
+ // sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
212
+ cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
213
+ description === null ? '' : ` (${renderCauseValue(description)})`
214
+ }`,
215
+ fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
216
+ meta: { provider, reason },
217
+ });
218
+
219
+ /**
220
+ * A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
221
+ * was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
222
+ * describes the app's configuration to an unauthenticated caller, and the fix is the same sentence
223
+ * either way.
224
+ *
225
+ * **`supported` is the CALLER's to scope, because the two callers have two audiences.**
226
+ * `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
227
+ * typed a URL, and the three built-ins are a framework constant already in the public docs, while
228
+ * the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
229
+ * `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
230
+ * is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
231
+ * what this deployment turned on is the disclosure the shared refusal exists to prevent.
232
+ *
233
+ * The fix names `registerOAuthProvider` first so it stays executable for the branch the narrowed
234
+ * list cannot cover — a segment nothing registered cannot be added to `providers` at all, so
235
+ * "add it" alone was an instruction that could not be followed.
236
+ *
237
+ * The segment itself is a URL path the caller typed, so it goes through `renderCauseValue` in the
238
+ * sentence and `renderFixLiteral` in the command — a fix has to parse after a hostile value lands
239
+ * in it.
240
+ */
241
+ export const oauthProviderUnknown = (provider: string, supported: readonly string[]): AuthError =>
242
+ new AuthError({
243
+ code: 'X_OAUTH_PROVIDER_UNKNOWN',
244
+ cause: `no oauth provider is mounted at ${renderCauseValue(oauthStartPath(provider))}`,
245
+ 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(', ')}`,
246
+ meta: { provider },
247
+ });
248
+
249
+ /**
250
+ * Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
251
+ * module imported second decide where every login for that id goes — including which `issuers`
252
+ * an id token may claim — so the second registration refuses at boot instead.
253
+ */
254
+ export const oauthProviderDuplicate = (provider: string): AuthError =>
255
+ new AuthError({
256
+ code: 'X_OAUTH_PROVIDER_DUPLICATE',
257
+ cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
258
+ fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
259
+ meta: { provider },
118
260
  });
119
261
 
120
262
  export interface OAuthExchangeFailure {
121
263
  readonly provider: string;
122
- /** Which leg of the server-to-server conversation failed. */
123
- readonly stage: 'token' | 'userinfo';
264
+ /**
265
+ * Which leg of the server-to-server conversation failed. `discovery` and `jwks` are the two
266
+ * boot/verification legs an enterprise OP adds: reading `/.well-known/openid-configuration`,
267
+ * and reading the key set an id token's signature is checked against.
268
+ */
269
+ readonly stage: 'token' | 'userinfo' | 'discovery' | 'jwks';
124
270
  readonly detail: string;
125
271
  readonly status?: number | undefined;
126
272
  readonly fix: string;
@@ -162,6 +308,19 @@ export const oauthAccountNotLinked = (provider: string, email: string): AuthErro
162
308
  meta: { provider, email },
163
309
  });
164
310
 
311
+ /**
312
+ * `link: 'never'` and a local account already holds the address. Same code and same disclosure
313
+ * rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
314
+ * so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
315
+ */
316
+ export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
317
+ new AuthError({
318
+ code: 'X_UNAUTHENTICATED',
319
+ cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
320
+ 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",
321
+ meta: { provider, email },
322
+ });
323
+
165
324
  /**
166
325
  * `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
167
326
  * write. Falling back to the unstamped row would mint a session for a user every later login
@@ -192,11 +351,24 @@ export const passwordWeak = (reasons: readonly string[]): AuthError =>
192
351
  fix: 'choose a longer, uncommon password — or relax defineAuth({ password: { minLength } })',
193
352
  });
194
353
 
354
+ /**
355
+ * The escape is `recordSuccess(key)`, which is what a successful login already calls: it deletes
356
+ * the bucket, so it clears exactly this one key and nothing else. `reset()` exists too and is the
357
+ * wrong reach — it drops every bucket in the table, including the spray this lockout is holding.
358
+ *
359
+ * `key` carries an address or an email the caller chose, so it goes through `renderFixLiteral`:
360
+ * a fix line has to still parse after a hostile value lands in it.
361
+ */
195
362
  export const accountLocked = (key: string, retryAfterSeconds: number): AuthError =>
196
363
  new AuthError({
197
364
  code: 'X_ACCOUNT_LOCKED',
198
- cause: `${key} is locked out for another ${retryAfterSeconds}s after repeated failures`,
199
- fix: `wait ${retryAfterSeconds}s, or run: x auth unlock ${key}`,
365
+ // `renderCauseValue`, matching `oauthDenied`: `ipKey(ip)` builds this key from whatever address
366
+ // string its caller passed, so a newline in it writes a second log line an operator reads as
367
+ // genuine. The value is `string` by type — the static scan only sees `unknown`/`any`, so this
368
+ // one was never going to be caught for us — and rendering it as a JSON string literal is
369
+ // escaping, not throw-safety.
370
+ cause: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
371
+ fix: `wait ${retryAfterSeconds}s — or clear this one bucket: auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}), auth.orgLimiter for an org: key — or raise defineAuth({ rateLimit })`,
200
372
  });
201
373
 
202
374
  /** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
@@ -204,7 +376,9 @@ export const apiKeyInvalid = (): AuthError =>
204
376
  new AuthError({
205
377
  code: 'X_API_KEY_INVALID',
206
378
  cause: 'the presented api key is unknown, revoked, expired or does not match its hash',
207
- fix: 'x auth keys list --json # then: x auth keys issue --scopes "<scope>"',
379
+ // Which of the four it was is not named here and never will be — see the factory's contract
380
+ // above — so the fix is the one action that resolves all four: issue a replacement.
381
+ fix: 'issue a replacement: const { plaintext, record } = issueApiKey({ env, scopes }); await auth.adapter.putApiKey(record) — auth.adapter.listApiKeys(ownerId) through describeApiKey() shows which keys are still live',
208
382
  });
209
383
 
210
384
  /**
@@ -216,10 +390,41 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
216
390
  new AuthError({
217
391
  code: 'X_AUTH_WRITE_FAILED',
218
392
  cause: `${operation} returned no row from ${table}, so the write cannot be confirmed`,
219
- fix: `x db migrate # then: x db query "select 1 from ${table} limit 1" --json`,
393
+ fix: `x db migrate # then, if ${table} is already there: x dev, and read it in the db panel at /_x`,
220
394
  meta: { operation, table },
221
395
  });
222
396
 
397
+ /**
398
+ * At `defineAuth`, never at a login. `replicas: 3` behind one policy means each process counts
399
+ * failures on its own, so the account survives `maxAttempts × 3` guesses and a lockout established
400
+ * on one replica is invisible to the other two — a throttle that reads as configured and is not.
401
+ */
402
+ export const authLimiterNotShared = (found: string): AuthError =>
403
+ new AuthError({
404
+ code: 'X_AUTH_LIMITER_NOT_SHARED',
405
+ cause: `rateLimit.scope is 'shared' but the limiter in use is ${found}, so every replica would grant the full maxAttempts on its own`,
406
+ fix: "pass a limiter whose scope is 'shared' — defineAuth({ adapter, limiter }) — or set rateLimit.scope: 'process' in defineAuth to accept per-replica lockouts",
407
+ meta: { scope: found },
408
+ });
409
+
410
+ /**
411
+ * At `defineAuth`, never at a login. `Auth.rateLimit` is what an operator reads as "what this
412
+ * deployment enforces", and an injected limiter counting to its own numbers makes that field a
413
+ * claim nothing backs — five attempts declared, fifty granted, and every surface reporting five.
414
+ * The policy is the app's single statement of the limits; this is what keeps it true.
415
+ */
416
+ export const authLimiterPolicyMismatch = (
417
+ field: string,
418
+ declared: number,
419
+ enforced: number,
420
+ ): AuthError =>
421
+ new AuthError({
422
+ code: 'X_AUTH_LIMITER_POLICY_MISMATCH',
423
+ cause: `defineAuth declares rateLimit.${field} = ${declared} but the limiter passed to it enforces ${enforced}; if ${enforced} is the number this deployment means to enforce, then the declaration is the half that is wrong`,
424
+ fix: `construct the limiter with ${field}: ${declared} — defineAuth({ rateLimit, limiter }) compares the two, and the declaration is what Auth.rateLimit reports`,
425
+ meta: { field, declared, enforced },
426
+ });
427
+
223
428
  /** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
224
429
  export const authNotImplemented = (feature: string, fix: string): AuthError =>
225
430
  new AuthError({
package/src/guards.ts CHANGED
@@ -1,11 +1,11 @@
1
- // Single responsibility: the assertions the http pipeline's auth stage runs. They read the
2
- // ambient actor from core's context and assert on it — they never evaluate a policy, never
3
- // load a row and never look at a session. `@ultimat3/policy` is the only authz evaluator;
4
- // duplicating even a little of it here would be the second authz system the framework forbids.
1
+ // Single responsibility: whether somebody is signed in. Both read the ambient actor from core's
2
+ // context and assert on it — they never evaluate a policy, never load a row and never look at a
3
+ // session. `@ultimat3/policy` is the only authz evaluator, so a role or scope decision does not
4
+ // live here: it is a `Policy`, which `x routes`, the manifest and `x policy list` can all see.
5
5
 
6
6
  import type { Actor } from '@ultimat3/core';
7
- import { hasRole, hasScope, isAnonymous, useContext } from '@ultimat3/core';
8
- import { forbidden, unauthenticated } from './errors';
7
+ import { isAnonymous, useContext } from '@ultimat3/core';
8
+ import { unauthenticated } from './errors';
9
9
 
10
10
  const DEFAULT_SURFACE = 'this request';
11
11
 
@@ -16,26 +16,6 @@ export function requireActor(surface: string = DEFAULT_SURFACE): Actor {
16
16
  return actor;
17
17
  }
18
18
 
19
- /**
20
- * A coarse role gate for routes that are role-shaped rather than permission-shaped (an admin
21
- * area). Anything finer belongs in a policy — `can('post:publish')`, evaluated by policy.
22
- */
23
- export function requireRole(role: string, surface: string = DEFAULT_SURFACE): Actor {
24
- const actor = requireActor(surface);
25
- if (!hasRole(actor, role)) throw forbidden(surface, `actor lacks role "${role}"`);
26
- return actor;
27
- }
28
-
29
- /**
30
- * The api-key path: an agent's scopes are exactly its key's scopes, so a scope check is a
31
- * credential check, not an authorization decision.
32
- */
33
- export function requireScope(scope: string, surface: string = DEFAULT_SURFACE): Actor {
34
- const actor = requireActor(surface);
35
- if (!hasScope(actor, scope)) throw forbidden(surface, `actor lacks scope "${scope}"`);
36
- return actor;
37
- }
38
-
39
19
  /** Non-throwing form for a route that renders differently when signed out. */
40
20
  export function currentActor(): Actor | null {
41
21
  const { actor } = useContext();
package/src/id-token.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  // Single responsibility: turning a provider's id token into claims this handshake is allowed to
2
- // believe. Signature verification is deliberately absent: this token is read only where it was
3
- // fetched over TLS directly from the provider's token endpoint, which is the one case OIDC Core
4
- // 3.1.3.7 exempts a token that reaches the browser instead must never be parsed here.
2
+ // believe. Every caller must say where its trust comes from: `keys: 'token-endpoint-tls'` is the
3
+ // one channel OIDC Core 3.1.3.7 exempts from a signature check, and anything else IdP-initiated
4
+ // login, `response_mode=form_post`, back-channel logout, token exchange passes a `JwksKeySource`
5
+ // and gets the signature verified. The argument is required rather than defaulted because a
6
+ // default is what silently makes the second door as trusting as the first.
5
7
 
6
8
  import type { Clock } from '@ultimat3/core';
7
- import { oauthStateInvalid, oauthTokenInvalid } from './errors';
8
- import { OAUTH_PROVIDERS, type OAuthProvider, type OAuthProviderId } from './oauth';
9
+ import { renderCauseValue } from '@ultimat3/core';
10
+ import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './errors';
11
+ import { decodeJwtSegment } from './json';
12
+ import { type IdTokenKeys, verifyJwtSignature } from './jwks';
13
+ import type { OAuthProvider, OAuthProviderId } from './oauth';
14
+ import { providerFor } from './oauth-registry';
9
15
  import { timingSafeEqual } from './tokens';
10
16
 
11
17
  /** The subset of OIDC claims this package acts on. Provider-specific extras are ignored. */
@@ -25,26 +31,18 @@ export interface IdTokenClaims {
25
31
  /** Two servers rarely agree on the second. Anything wider hides a genuinely expired token. */
26
32
  export const ID_TOKEN_CLOCK_SKEW_MS = 60_000;
27
33
 
28
- const isRecord = (value: unknown): value is Record<string, unknown> =>
29
- typeof value === 'object' && value !== null && !Array.isArray(value);
30
-
31
34
  const stringOrUndefined = (value: unknown): string | undefined =>
32
35
  typeof value === 'string' ? value : undefined;
33
36
 
34
37
  function decodeSegment(provider: string, segment: string, fix: string): Record<string, unknown> {
35
- const padded = segment
36
- .replace(/-/g, '+')
37
- .replace(/_/g, '/')
38
- .padEnd(Math.ceil(segment.length / 4) * 4, '=');
39
- let parsed: unknown;
40
- try {
41
- const binary = atob(padded);
42
- const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
43
- parsed = JSON.parse(new TextDecoder().decode(bytes));
44
- } catch {
45
- throw oauthTokenInvalid(provider, 'the payload segment is not base64url-encoded JSON', fix);
38
+ const parsed = decodeJwtSegment(segment);
39
+ if (parsed === null) {
40
+ throw oauthTokenInvalid(
41
+ provider,
42
+ 'the payload segment is not base64url-encoded JSON describing an object',
43
+ fix,
44
+ );
46
45
  }
47
- if (!isRecord(parsed)) throw oauthTokenInvalid(provider, 'the payload is not an object', fix);
48
46
  return parsed;
49
47
  }
50
48
 
@@ -53,7 +51,7 @@ function decodeSegment(provider: string, segment: string, fix: string): Record<s
53
51
  * point every flow uses; this one exists because the two halves are worth reading apart.
54
52
  */
55
53
  export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTokenClaims {
56
- const fix = `check that ${OAUTH_PROVIDERS[provider].clientIdEnv} names an app whose id token is a JWT`;
54
+ const fix = `check that ${providerFor(provider).clientIdEnv} names an app whose id token is a JWT`;
57
55
  const segments = idToken.split('.');
58
56
  if (segments.length !== 3 || segments[1] === undefined || segments[1] === '') {
59
57
  throw oauthTokenInvalid(provider, 'the token is not a three-segment JWT', fix);
@@ -115,23 +113,47 @@ export interface VerifyIdTokenInput {
115
113
  /** `OAuthHandshake.nonce`. Checked whenever the provider was asked for one. */
116
114
  readonly nonce: string;
117
115
  readonly clock: Clock;
116
+ /**
117
+ * Required, and there is deliberately no default. `'token-endpoint-tls'` asserts this token was
118
+ * read off a TLS response from the provider's own token endpoint — the OIDC Core 3.1.3.7 case,
119
+ * and the only one where an unverified JWT is safe. Every other channel passes a key source:
120
+ * `providerJwks(providerFor(id))`, or a `createJwksClient({ jwksUri })` of its own.
121
+ */
122
+ readonly keys: IdTokenKeys;
118
123
  }
119
124
 
120
125
  /**
121
- * Issuer, audience, expiry and nonce, in that order. A nonce mismatch is
126
+ * Signature, issuer, audience, expiry and nonce, in that order signature first, because every
127
+ * claim below it is only worth reading once something proved who wrote them. A nonce mismatch is
122
128
  * `X_OAUTH_STATE_INVALID` rather than a token error on purpose: it is the same class of event
123
129
  * as a forged `state` — a token minted for another browser being replayed into this one.
124
130
  */
125
- export function verifyIdToken(input: VerifyIdTokenInput): IdTokenClaims {
131
+ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenClaims> {
126
132
  // Widened on purpose: `issuers` is a literal `readonly []` for a provider that issues no id
127
133
  // token, and `[].includes(string)` does not typecheck against `never`.
128
- const provider: OAuthProvider = OAUTH_PROVIDERS[input.provider];
134
+ const provider: OAuthProvider = providerFor(input.provider);
129
135
  const claims = decodeIdToken(input.provider, input.idToken);
130
136
 
137
+ if (
138
+ input.keys !== 'token-endpoint-tls' &&
139
+ !(await verifyJwtSignature(input.idToken, input.keys))
140
+ ) {
141
+ throw oauthTokenInvalid(
142
+ provider.id,
143
+ 'the signature does not verify against the published key set for this issuer',
144
+ `confirm the token came from ${provider.tokenUrl} and was not minted by whoever posted it here`,
145
+ );
146
+ }
147
+
131
148
  if (!provider.issuers.includes(claims.iss)) {
132
149
  throw oauthTokenInvalid(
133
150
  provider.id,
134
- `iss was "${claims.iss}", expected one of ${provider.issuers.join(', ')}`,
151
+ // `claims.iss` is a field of the JWT the caller just presented — whatever they put in it,
152
+ // newlines and quotes included. Hand-written quotes around it did not escape either, so a
153
+ // forged `iss` could close the sentence and write a second log line an operator reads as
154
+ // genuine. `renderCauseValue` renders it as a JSON string literal, quotes included, which
155
+ // is why the manual pair is gone. `issuers` is registered config and needs no rendering.
156
+ `iss was ${renderCauseValue(claims.iss)}, expected one of ${provider.issuers.join(', ')}`,
135
157
  `confirm the token came from ${provider.tokenUrl} and not from a proxy that re-signs it`,
136
158
  );
137
159
  }
@@ -149,7 +171,7 @@ export function verifyIdToken(input: VerifyIdTokenInput): IdTokenClaims {
149
171
  throw oauthTokenInvalid(
150
172
  provider.id,
151
173
  'the token is already expired',
152
- "sync this host's clock (`timedatectl status`), then restart the flow",
174
+ `sync this host's clock (\`timedatectl status\`), then ${restartAt(provider.id)}`,
153
175
  );
154
176
  }
155
177