@ultimat3/auth 1.2.0 → 3.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.
@@ -12,7 +12,9 @@ import type {
12
12
  CreateUserInput,
13
13
  SessionPatch,
14
14
  UserPatch,
15
+ UserQuery,
15
16
  } from './adapter';
17
+ import { timingSafeEqual } from './tokens';
16
18
 
17
19
  const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
18
20
 
@@ -24,10 +26,15 @@ export class MemoryAdapter implements AuthAdapter {
24
26
  readonly #verifications = new Map<string, AuthVerification>();
25
27
  readonly #apiKeys = new Map<string, AuthApiKeyRecord>();
26
28
 
29
+ /**
30
+ * Exact match, because `BuiltinAdapter` issues `where email = $1` against a plain `text ...
31
+ * unique` column and nothing folds case there. Normalising here instead made this the ONE
32
+ * adapter that found an account Postgres would not, which is a linked account under `x dev` and
33
+ * a duplicate one in production. `normaliseEmail` is the caller's, above the seam.
34
+ */
27
35
  async findUserByEmail(email: string): Promise<AuthUser | null> {
28
- const wanted = email.trim().toLowerCase();
29
36
  for (const user of this.#users.values()) {
30
- if (user.email === wanted) return user;
37
+ if (user.email === email) return user;
31
38
  }
32
39
  return null;
33
40
  }
@@ -39,14 +46,17 @@ export class MemoryAdapter implements AuthAdapter {
39
46
  async createUser(input: CreateUserInput): Promise<AuthUser> {
40
47
  const user: AuthUser = {
41
48
  id: input.id,
42
- email: input.email.trim().toLowerCase(),
49
+ // Stored as handed over, exactly as the `insert into x_users` binds it.
50
+ email: input.email,
43
51
  emailVerifiedAt: null,
44
52
  passwordHash: input.passwordHash,
45
53
  orgId: input.orgId,
46
54
  roles: [...input.roles],
47
55
  permissions: [],
56
+ scopes: [...(input.scopes ?? [])],
48
57
  mfaSecret: null,
49
58
  recoveryCodeHashes: [],
59
+ externalId: input.externalId ?? null,
50
60
  disabledAt: null,
51
61
  createdAt: input.createdAt,
52
62
  };
@@ -66,11 +76,30 @@ export class MemoryAdapter implements AuthAdapter {
66
76
  recoveryCodeHashes: patch.recoveryCodeHashes ?? user.recoveryCodeHashes,
67
77
  disabledAt: patch.disabledAt === undefined ? user.disabledAt : patch.disabledAt,
68
78
  roles: patch.roles ?? user.roles,
79
+ permissions: patch.permissions ?? user.permissions,
80
+ scopes: patch.scopes ?? user.scopes,
81
+ orgId: patch.orgId === undefined ? user.orgId : patch.orgId,
82
+ externalId: patch.externalId === undefined ? user.externalId : patch.externalId,
69
83
  };
70
84
  this.#users.set(id, next);
71
85
  return next;
72
86
  }
73
87
 
88
+ async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
89
+ for (const user of this.#users.values()) {
90
+ if (user.externalId === externalId) return user;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
96
+ return [...this.#users.values()]
97
+ .filter((user) => user.orgId === orgId)
98
+ .filter((user) => query?.includeDisabled === true || user.disabledAt === null)
99
+ .filter((user) => query?.role === undefined || user.roles.includes(query.role))
100
+ .sort((a, b) => a.email.localeCompare(b.email));
101
+ }
102
+
74
103
  async getSession(id: string): Promise<AuthSession | null> {
75
104
  return this.#sessions.get(id) ?? null;
76
105
  }
@@ -108,6 +137,32 @@ export class MemoryAdapter implements AuthAdapter {
108
137
  return killed;
109
138
  }
110
139
 
140
+ async deleteSessionsForUser(userId: string): Promise<number> {
141
+ return this.#deleteSessionsWhere((session) => session.userId === userId);
142
+ }
143
+
144
+ /** Joins through the user map, which is what the Postgres adapter's subselect does. */
145
+ async deleteSessionsForOrg(orgId: string): Promise<number> {
146
+ const members = new Set(
147
+ [...this.#users.values()].filter((user) => user.orgId === orgId).map((user) => user.id),
148
+ );
149
+ return this.#deleteSessionsWhere((session) => members.has(session.userId));
150
+ }
151
+
152
+ async deleteSessionsCreatedBefore(before: Date): Promise<number> {
153
+ return this.#deleteSessionsWhere((session) => session.createdAt.getTime() < before.getTime());
154
+ }
155
+
156
+ #deleteSessionsWhere(matches: (session: AuthSession) => boolean): number {
157
+ let killed = 0;
158
+ for (const [id, session] of this.#sessions) {
159
+ if (!matches(session)) continue;
160
+ this.#sessions.delete(id);
161
+ killed += 1;
162
+ }
163
+ return killed;
164
+ }
165
+
111
166
  async listSessions(userId: string): Promise<readonly AuthSession[]> {
112
167
  return [...this.#sessions.values()]
113
168
  .filter((session) => session.userId === userId)
@@ -131,10 +186,17 @@ export class MemoryAdapter implements AuthAdapter {
131
186
  this.#verifications.set(verificationKey(record.purpose, record.identifier), record);
132
187
  }
133
188
 
134
- async takeVerification(purpose: string, identifier: string): Promise<AuthVerification | null> {
189
+ async takeVerification(
190
+ purpose: string,
191
+ identifier: string,
192
+ tokenHash: string,
193
+ ): Promise<AuthVerification | null> {
135
194
  const key = verificationKey(purpose, identifier);
136
195
  const record = this.#verifications.get(key);
137
196
  if (record === undefined || record.consumedAt !== null) return null;
197
+ // Before the write, never after: a wrong guess that consumed the row would be an
198
+ // unauthenticated way to kill the victim's live link, which is the Postgres adapter's rule too.
199
+ if (!timingSafeEqual(tokenHash, record.tokenHash)) return null;
138
200
  const consumed: AuthVerification = { ...record, consumedAt: new Date(record.createdAt) };
139
201
  this.#verifications.set(key, consumed);
140
202
  return consumed;
package/src/mfa.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
4
4
  // and single-use, so a database dump is not a permanent MFA bypass.
5
5
 
6
+ import type { Auth } from './auth';
6
7
  import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
7
8
 
8
9
  const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -63,17 +64,28 @@ export interface TotpEnrolment {
63
64
  }
64
65
 
65
66
  export interface EnrolTotpInput {
66
- readonly issuer: string;
67
+ /**
68
+ * Omitted, the issuer is `auth.mfa.issuer` — one declaration, at `defineAuth`, so the product
69
+ * name an authenticator app shows is not restated at every enrolment. Named here only when one
70
+ * call needs a different one (a separate admin console entry, say).
71
+ */
72
+ readonly issuer?: string | undefined;
67
73
  readonly account: string;
68
74
  readonly secret?: string | undefined;
69
75
  }
70
76
 
71
- export function enrolTotp(input: EnrolTotpInput): TotpEnrolment {
77
+ /**
78
+ * Takes the `Auth` every other entry point in this package takes, and for the same reason: the
79
+ * issuer is configuration, and a pure function that could not read the configuration is what made
80
+ * `defineAuth({ mfa: { issuer } })` a string the framework wrote down and never read.
81
+ */
82
+ export function enrolTotp(auth: Auth, input: EnrolTotpInput): TotpEnrolment {
72
83
  const secret = input.secret ?? generateTotpSecret();
73
- const label = `${encodeURIComponent(input.issuer)}:${encodeURIComponent(input.account)}`;
84
+ const issuer = input.issuer ?? auth.mfa.issuer;
85
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(input.account)}`;
74
86
  const query = new URLSearchParams({
75
87
  secret,
76
- issuer: input.issuer,
88
+ issuer,
77
89
  algorithm: 'SHA1',
78
90
  digits: String(TOTP_DIGITS),
79
91
  period: String(TOTP_STEP_SECONDS),
@@ -148,23 +160,106 @@ export interface TotpReplayGuard {
148
160
  remember(subject: string, step: number, at: Date): void;
149
161
  }
150
162
 
163
+ /** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
164
+ export interface MemoryTotpReplayGuard extends TotpReplayGuard {
165
+ readonly size: number;
166
+ }
167
+
168
+ /**
169
+ * Hard bound on tracked subjects. One entry is one user who has completed a TOTP check inside the
170
+ * last drift window, so the natural cardinality is far below this — the cap is the backstop, the
171
+ * same one `DEFAULT_MAX_AUTH_LIMIT_KEYS` is for the limiter's table.
172
+ */
173
+ export const DEFAULT_MAX_TOTP_SUBJECTS = 10_000;
174
+
175
+ /** An idle guard still sweeps this often, so one burst's subjects do not sit until the next. */
176
+ const SWEEP_EVERY_STEPS = 2;
177
+
178
+ /**
179
+ * The cap arithmetic ran on the caller's number unchecked, and the two values a misread config
180
+ * hands you each defeated the bound in their own way: `Infinity` makes `used.size > cap` never
181
+ * true, so the table is exactly as unbounded as before it was capped; `NaN` makes EVERY comparison
182
+ * false, so `used.size <= evictTo` never stops the eviction loop and one sweep empties the table —
183
+ * including the subject who just authenticated, whose step is then replayable. Anything that is
184
+ * not a positive finite integer is a config the caller did not mean, so it takes the default
185
+ * rather than a bound derived from it. A fraction still floors: `2.5` is a caller who meant 2.
186
+ */
187
+ function boundedSubjects(maxSubjects: number): number {
188
+ if (!Number.isFinite(maxSubjects) || maxSubjects < 1) return DEFAULT_MAX_TOTP_SUBJECTS;
189
+ return Math.floor(maxSubjects);
190
+ }
191
+
192
+ /** The last step this subject has spent — how close their entry still is to the live window. */
193
+ const newestStep = (steps: ReadonlySet<number>): number => {
194
+ let newest = Number.NEGATIVE_INFINITY;
195
+ for (const step of steps) newest = Math.max(newest, step);
196
+ return newest;
197
+ };
198
+
151
199
  /**
152
200
  * In-memory by default because a single web process is the common case; a multi-process
153
201
  * deployment passes a Redis-backed guard with the same two methods. Steps older than the
154
202
  * drift window are dropped — nothing outside it can be replayed anyway.
203
+ *
204
+ * Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
205
+ * and never revisited a subject who stopped signing in, so the table carried one permanent entry
206
+ * per user for the life of the process. Two rules keep it flat, and the ORDER is the guarantee —
207
+ * evicting a subject makes a step they have already spent replayable again, so it may never be
208
+ * the subject who just authenticated. A subject whose every step has fallen below the drift floor
209
+ * is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
210
+ * entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
211
+ * is not enough does the cap evict live state, furthest from the live window first — the shape
212
+ * `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
155
213
  */
156
- export function createTotpReplayGuard(drift: number = TOTP_DRIFT_STEPS): TotpReplayGuard {
214
+ export function createTotpReplayGuard(
215
+ drift: number = TOTP_DRIFT_STEPS,
216
+ maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
217
+ ): MemoryTotpReplayGuard {
157
218
  const used = new Map<string, Set<number>>();
219
+ const cap = boundedSubjects(maxSubjects);
220
+ // Batched down to 90% of the cap so the sort below is paid once per 10% of it, not per check.
221
+ const evictTo = Math.max(1, Math.floor(cap * 0.9));
222
+ let lastSweepStep = Number.NEGATIVE_INFINITY;
223
+
224
+ const prune = (steps: Set<number>, floor: number): void => {
225
+ for (const known of steps) {
226
+ if (known < floor) steps.delete(known);
227
+ }
228
+ };
229
+
230
+ const sweep = (now: number, floor: number): void => {
231
+ lastSweepStep = now;
232
+ for (const [subject, steps] of used) {
233
+ prune(steps, floor);
234
+ if (steps.size === 0) used.delete(subject);
235
+ }
236
+ if (used.size <= cap) return;
237
+ // Map iteration is insertion order and `remember` re-files the subject it touches, so this
238
+ // sort — stable by specification — breaks a tie on the newest step by least recently seen.
239
+ // Both keys point the same way: the subject who just proved a code is the last one out.
240
+ const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
241
+ for (const [subject] of furthest) {
242
+ if (used.size <= evictTo) break;
243
+ used.delete(subject);
244
+ }
245
+ };
246
+
158
247
  return {
248
+ get size() {
249
+ return used.size;
250
+ },
159
251
  isUsed: (subject, step) => used.get(subject)?.has(step) === true,
160
252
  remember: (subject, step, at) => {
253
+ const now = totpStep(at);
254
+ const floor = now - drift;
161
255
  const steps = used.get(subject) ?? new Set<number>();
162
- const floor = totpStep(at) - drift;
163
- for (const known of steps) {
164
- if (known < floor) steps.delete(known);
165
- }
256
+ prune(steps, floor);
166
257
  steps.add(step);
258
+ // Deleted before it is set, so this subject moves to the back of the iteration order and
259
+ // that order is least-recently-remembered first. Nothing else observes it.
260
+ used.delete(subject);
167
261
  used.set(subject, steps);
262
+ if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
168
263
  },
169
264
  };
170
265
  }
@@ -0,0 +1,77 @@
1
+ // Single responsibility: the three consumer IdPs Ultimate ships with, as plain data. They live
2
+ // apart from `oauth-registry.ts` so the registry can be seeded from them while `oauth.ts` reads
3
+ // the registry — the only edge back to `oauth.ts` is the type, and a type is erased, so there is
4
+ // no runtime cycle between the three files.
5
+
6
+ import type { OAuthProvider } from './oauth';
7
+
8
+ export const GITHUB_PROVIDER: OAuthProvider = {
9
+ id: 'github',
10
+ authorizeUrl: 'https://github.com/login/oauth/authorize',
11
+ tokenUrl: 'https://github.com/login/oauth/access_token',
12
+ userInfoUrl: 'https://api.github.com/user',
13
+ // GitHub omits a private address from the profile; the identity is still incomplete
14
+ // without it, so the flow asks for the verified list rather than guessing.
15
+ userEmailsUrl: 'https://api.github.com/user/emails',
16
+ issuers: [],
17
+ // GitHub is OAuth2 and not OIDC: no id token, therefore no key set to verify one against.
18
+ jwksUri: null,
19
+ scopes: ['read:user', 'user:email'],
20
+ usesPkce: true,
21
+ usesNonce: false,
22
+ clientIdEnv: 'GITHUB_CLIENT_ID',
23
+ clientSecretEnv: 'GITHUB_CLIENT_SECRET',
24
+ };
25
+
26
+ export const GOOGLE_PROVIDER: OAuthProvider = {
27
+ id: 'google',
28
+ authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
29
+ tokenUrl: 'https://oauth2.googleapis.com/token',
30
+ // Reached only when a narrowed `scopes` leaves the id token without an email claim.
31
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
32
+ userEmailsUrl: null,
33
+ issuers: ['https://accounts.google.com', 'accounts.google.com'],
34
+ jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
35
+ scopes: ['openid', 'email', 'profile'],
36
+ usesPkce: true,
37
+ usesNonce: true,
38
+ clientIdEnv: 'GOOGLE_CLIENT_ID',
39
+ clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
40
+ };
41
+
42
+ export const APPLE_PROVIDER: OAuthProvider = {
43
+ id: 'apple',
44
+ authorizeUrl: 'https://appleid.apple.com/auth/authorize',
45
+ tokenUrl: 'https://appleid.apple.com/auth/token',
46
+ // Apple returns claims in the id token only; there is no userinfo endpoint to call.
47
+ userInfoUrl: null,
48
+ userEmailsUrl: null,
49
+ issuers: ['https://appleid.apple.com'],
50
+ jwksUri: 'https://appleid.apple.com/auth/keys',
51
+ scopes: ['name', 'email'],
52
+ usesPkce: true,
53
+ usesNonce: true,
54
+ clientIdEnv: 'APPLE_CLIENT_ID',
55
+ // Apple alone does not accept a static secret: `APPLE_CLIENT_SECRET` must hold the ES256
56
+ // client-secret JWT signed with the .p8 key, which Apple expires every six months.
57
+ clientSecretEnv: 'APPLE_CLIENT_SECRET',
58
+ };
59
+
60
+ /** Seeded into the registry at import, through the same call an app registers its own IdP with. */
61
+ export const BUILTIN_OAUTH_PROVIDERS: readonly OAuthProvider[] = Object.freeze([
62
+ GITHUB_PROVIDER,
63
+ GOOGLE_PROVIDER,
64
+ APPLE_PROVIDER,
65
+ ]);
66
+
67
+ /**
68
+ * The three ids, and the only list an ANONYMOUS caller is ever shown. It is a framework constant
69
+ * already in the docs, so naming it discloses nothing; the live registry is not, because an app
70
+ * that registered `acme-internal-sso` has put its own vocabulary in there. `oauth-route.ts` is the
71
+ * one reader in this package — every other refusal reaches a developer and gets
72
+ * `oauthProviderIds()` instead. Off `index.ts` too, because an app writing its own login route
73
+ * owes an anonymous caller the same narrow list and must not have to restate the three ids.
74
+ */
75
+ export const BUILTIN_OAUTH_PROVIDER_IDS: readonly string[] = Object.freeze(
76
+ BUILTIN_OAUTH_PROVIDERS.map((provider) => provider.id),
77
+ );
@@ -8,7 +8,8 @@
8
8
  import type { Clock } from '@ultimat3/core';
9
9
  import { EnvMissingError, systemClock } from '@ultimat3/core';
10
10
  import { oauthStateInvalid } from './errors';
11
- import { OAUTH_PROVIDERS, type OAuthHandshake, type OAuthProviderId } from './oauth';
11
+ import type { OAuthHandshake, OAuthProviderId } from './oauth';
12
+ import { hasOAuthProvider } from './oauth-registry';
12
13
  import { type RequestLike, readCookie } from './session';
13
14
  import { base64Url, timingSafeEqual } from './tokens';
14
15
 
@@ -138,7 +139,7 @@ export function openHandshake(
138
139
  ) {
139
140
  throw oauthStateInvalid(provider, 'the stored handshake is not a handshake');
140
141
  }
141
- if (!Object.hasOwn(OAUTH_PROVIDERS, sealedProvider) || sealedProvider !== provider) {
142
+ if (!hasOAuthProvider(sealedProvider) || sealedProvider !== provider) {
142
143
  throw oauthStateInvalid(provider, 'the stored handshake belongs to a different provider');
143
144
  }
144
145
 
@@ -157,7 +158,7 @@ export function openHandshake(
157
158
  * session cookie's: the callback is a top-level cross-site GET from the provider, which `Lax`
158
159
  * still attaches the cookie to and `Strict` would strip — leaving every login to fail its state
159
160
  * check. A provider answering with `response_mode=form_post` POSTs instead, and this cookie
160
- * would not reach it; no provider in `OAUTH_PROVIDERS` is configured that way.
161
+ * would not reach it; none of the three built-in providers is configured that way.
161
162
  */
162
163
  export function handshakeCookie(
163
164
  handshake: OAuthHandshake,
@@ -0,0 +1,132 @@
1
+ // Single responsibility: turning an OIDC issuer URL into an `OAuthProvider`, by reading the
2
+ // discovery document every conforming OP publishes (OIDC Discovery 1.0, §4). One `fetch`, at
3
+ // boot, no dependency — an enterprise IdP is then three lines instead of a hand-copied table of
4
+ // four endpoints that nobody re-checks when the vendor moves one.
5
+
6
+ import { oauthExchangeFailed } from './errors';
7
+ import { isRecord } from './json';
8
+ import type { OAuthProvider } from './oauth';
9
+ import type { OAuthFetch } from './oauth-exchange';
10
+
11
+ const DEFAULT_TIMEOUT_MS = 10_000;
12
+
13
+ /** The document lives at a fixed suffix off the issuer; the issuer keeps its own path. */
14
+ export const discoveryUrl = (issuer: string): string =>
15
+ `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
16
+
17
+ export interface DiscoverOAuthProviderInput {
18
+ /** The id the URL segment carries: `/auth/oauth/<id>`. Also names the two env vars. */
19
+ readonly id: string;
20
+ readonly issuer: string;
21
+ /** Defaults to the OIDC minimum this package needs to identify somebody. */
22
+ readonly scopes?: readonly string[] | undefined;
23
+ readonly clientIdEnv?: string | undefined;
24
+ readonly clientSecretEnv?: string | undefined;
25
+ /** Injected in tests; production uses the global. */
26
+ readonly fetch?: OAuthFetch | undefined;
27
+ readonly timeoutMs?: number | undefined;
28
+ }
29
+
30
+ const stringOrNull = (body: Record<string, unknown>, key: string): string | null => {
31
+ const value = body[key];
32
+ return typeof value === 'string' && value !== '' ? value : null;
33
+ };
34
+
35
+ /** `bigco-sso` → `BIGCO_SSO_CLIENT_ID`, the same shape the three built-ins use. */
36
+ const envPrefix = (id: string): string => id.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
37
+
38
+ /**
39
+ * Reads the issuer's discovery document and returns the provider. It does **not** register:
40
+ * `registerOAuthProvider(await discoverOAuthProvider({ id, issuer }))` keeps one install point,
41
+ * and an app that wants to override one endpoint spreads the result before registering it.
42
+ *
43
+ * `issuers` is pinned to the `issuer` the document itself declares, not the URL that was asked
44
+ * for — a document that names a different issuer is answering for somebody else, and pinning the
45
+ * requested URL would let it.
46
+ */
47
+ export async function discoverOAuthProvider(
48
+ input: DiscoverOAuthProviderInput,
49
+ ): Promise<OAuthProvider> {
50
+ const url = discoveryUrl(input.issuer);
51
+ const doFetch: OAuthFetch = input.fetch ?? ((target, init) => globalThis.fetch(target, init));
52
+
53
+ let response: Response;
54
+ try {
55
+ response = await doFetch(url, {
56
+ method: 'GET',
57
+ headers: { Accept: 'application/json' },
58
+ signal: AbortSignal.timeout(input.timeoutMs ?? DEFAULT_TIMEOUT_MS),
59
+ });
60
+ } catch (error) {
61
+ throw oauthExchangeFailed({
62
+ provider: input.id,
63
+ stage: 'discovery',
64
+ detail:
65
+ error instanceof Error ? error.message : 'the request failed before a response arrived',
66
+ fix: `curl -sS -m 5 ${url}`,
67
+ });
68
+ }
69
+
70
+ if (!response.ok) {
71
+ throw oauthExchangeFailed({
72
+ provider: input.id,
73
+ stage: 'discovery',
74
+ detail: 'the issuer published no discovery document at the well-known path',
75
+ status: response.status,
76
+ fix: `curl -sS -m 5 ${url} # then pass the real issuer URL to discoverOAuthProvider({ issuer })`,
77
+ });
78
+ }
79
+
80
+ const body: unknown = await response.json().catch(() => undefined);
81
+ if (!isRecord(body)) {
82
+ throw oauthExchangeFailed({
83
+ provider: input.id,
84
+ stage: 'discovery',
85
+ detail: 'the discovery document is not a JSON object',
86
+ fix: `confirm ${url} is the OP's own discovery document and not a login page`,
87
+ });
88
+ }
89
+
90
+ const issuer = stringOrNull(body, 'issuer');
91
+ const authorizeUrl = stringOrNull(body, 'authorization_endpoint');
92
+ const tokenUrl = stringOrNull(body, 'token_endpoint');
93
+ if (issuer === null || authorizeUrl === null || tokenUrl === null) {
94
+ throw oauthExchangeFailed({
95
+ provider: input.id,
96
+ stage: 'discovery',
97
+ detail:
98
+ 'the discovery document is missing issuer, authorization_endpoint or token_endpoint, ' +
99
+ 'so no handshake can be built from it',
100
+ fix: `curl -sS -m 5 ${url} | jq '{issuer, authorization_endpoint, token_endpoint}'`,
101
+ });
102
+ }
103
+
104
+ const jwksUri = stringOrNull(body, 'jwks_uri');
105
+ if (jwksUri === null) {
106
+ // Without a key set there is nothing to verify an id token against, and the only remaining
107
+ // trust is the TLS channel to the token endpoint — which is the exemption, not a default.
108
+ throw oauthExchangeFailed({
109
+ provider: input.id,
110
+ stage: 'discovery',
111
+ detail:
112
+ 'the discovery document publishes no jwks_uri, so no id token from it can be verified',
113
+ fix: `register this provider by hand with an explicit jwksUri: registerOAuthProvider({ id: '${input.id}', ... })`,
114
+ });
115
+ }
116
+
117
+ const prefix = envPrefix(input.id);
118
+ return {
119
+ id: input.id,
120
+ authorizeUrl,
121
+ tokenUrl,
122
+ userInfoUrl: stringOrNull(body, 'userinfo_endpoint'),
123
+ userEmailsUrl: null,
124
+ issuers: [issuer],
125
+ jwksUri,
126
+ scopes: input.scopes ?? ['openid', 'email', 'profile'],
127
+ usesPkce: true,
128
+ usesNonce: true,
129
+ clientIdEnv: input.clientIdEnv ?? `${prefix}_CLIENT_ID`,
130
+ clientSecretEnv: input.clientSecretEnv ?? `${prefix}_CLIENT_SECRET`,
131
+ };
132
+ }
@@ -4,16 +4,18 @@
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, systemClock } from '@ultimat3/core';
8
- import { oauthExchangeFailed } from './errors';
7
+ import { EnvMissingError, renderCauseValue, systemClock } from '@ultimat3/core';
8
+ import { oauthExchangeFailed, restartAt } from './errors';
9
9
  import { type IdTokenClaims, verifyIdToken } from './id-token';
10
+ import { isRecord } from './json';
11
+ import type { IdTokenKeys } from './jwks';
10
12
  import {
11
13
  assertOAuthCallback,
12
- OAUTH_PROVIDERS,
13
14
  type OAuthCallback,
14
15
  type OAuthHandshake,
15
16
  type OAuthProviderId,
16
17
  } from './oauth';
18
+ import { providerFor } from './oauth-registry';
17
19
 
18
20
  /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
19
21
  export type OAuthFetch = (input: string, init: RequestInit) => Promise<Response>;
@@ -45,6 +47,13 @@ export interface OAuthExchangeOptions {
45
47
  /** Injected in tests; production uses the global. */
46
48
  readonly fetch?: OAuthFetch | undefined;
47
49
  readonly timeoutMs?: number | undefined;
50
+ /**
51
+ * Defaults to `'token-endpoint-tls'`, and this is the one call site where that default is
52
+ * correct: the id token below was read off a TLS response from the provider's own token
53
+ * endpoint, which is the channel OIDC Core 3.1.3.7 exempts. An app that pins the key set anyway
54
+ * — a corporate egress proxy, a compliance requirement — passes `providerJwks(providerFor(id))`.
55
+ */
56
+ readonly keys?: IdTokenKeys | undefined;
48
57
  }
49
58
 
50
59
  /**
@@ -55,7 +64,7 @@ export function oauthCredentials(
55
64
  provider: OAuthProviderId,
56
65
  env: Readonly<Record<string, string | undefined>> = Bun.env,
57
66
  ): OAuthClientCredentials {
58
- const { clientIdEnv, clientSecretEnv } = OAUTH_PROVIDERS[provider];
67
+ const { clientIdEnv, clientSecretEnv } = providerFor(provider);
59
68
  const clientId = env[clientIdEnv]?.trim() ?? '';
60
69
  const clientSecret = env[clientSecretEnv]?.trim() ?? '';
61
70
  const missing = [
@@ -72,10 +81,16 @@ export function oauthCredentials(
72
81
  return { clientId, clientSecret };
73
82
  }
74
83
 
75
- const isRecord = (value: unknown): value is Record<string, unknown> =>
76
- typeof value === 'object' && value !== null && !Array.isArray(value);
77
-
78
- /** Prefers the provider's own words; falls back to raw text, capped so a login page can't flood logs. */
84
+ /**
85
+ * Prefers the provider's own words; falls back to raw text, capped so a login page can't flood logs.
86
+ *
87
+ * Every branch but the empty-body sentence is a REMOTE server's bytes, and this string is spliced
88
+ * into an `X_OAUTH_EXCHANGE_FAILED` cause — so a token endpoint that is compromised, impersonated
89
+ * or merely behind a hostile proxy could otherwise write a second log line an operator reads as
90
+ * genuine. Rendered here rather than at the factory because `OAuthExchangeFailure.detail` is
91
+ * mostly prose this package authored, and quoting that would make every readable message unread-
92
+ * able. The rule is "remote text is rendered", and this function is where remote text is made.
93
+ */
79
94
  export async function providerDetail(response: Response): Promise<string> {
80
95
  const text = await response.text().catch(() => '');
81
96
  if (text === '') return 'the response body was empty';
@@ -83,21 +98,25 @@ export async function providerDetail(response: Response): Promise<string> {
83
98
  const parsed: unknown = JSON.parse(text);
84
99
  if (isRecord(parsed)) {
85
100
  const description = parsed['error_description'] ?? parsed['error'] ?? parsed['message'];
86
- if (typeof description === 'string' && description !== '') return description;
101
+ if (typeof description === 'string' && description !== '') {
102
+ return renderCauseValue(description);
103
+ }
87
104
  }
88
105
  } catch {
89
106
  // Not JSON — fall through to the truncated raw text below.
90
107
  }
91
- return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
108
+ return renderCauseValue(
109
+ text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text,
110
+ );
92
111
  }
93
112
 
94
113
  function fixForStatus(provider: OAuthProviderId, status: number): string {
95
- const { clientIdEnv, clientSecretEnv } = OAUTH_PROVIDERS[provider];
114
+ const { clientIdEnv, clientSecretEnv } = providerFor(provider);
96
115
  if (status === 401 || status === 403) {
97
116
  return `set ${clientIdEnv} and ${clientSecretEnv} to the current values in the ${provider} app settings`;
98
117
  }
99
118
  if (status === 400) {
100
- return `register this exact redirect_uri in the ${provider} app settings, then restart the flow`;
119
+ return `register this exact redirect_uri in the ${provider} app settings, then ${restartAt(provider)}`;
101
120
  }
102
121
  return `retry; if it persists, check the ${provider} status page before changing anything`;
103
122
  }
@@ -108,7 +127,7 @@ async function postForm(
108
127
  options: OAuthExchangeOptions,
109
128
  ): Promise<Record<string, unknown>> {
110
129
  const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
111
- const url = OAUTH_PROVIDERS[provider].tokenUrl;
130
+ const url = providerFor(provider).tokenUrl;
112
131
 
113
132
  let response: Response;
114
133
  try {
@@ -166,7 +185,7 @@ async function postForm(
166
185
  detail: typeof description === 'string' && description !== '' ? description : error,
167
186
  fix:
168
187
  error === 'bad_verification_code' || error === 'invalid_grant'
169
- ? 'restart the flow — an authorization code is single-use and short-lived'
188
+ ? `${restartAt(provider)} — an authorization code is single-use and short-lived`
170
189
  : fixForStatus(provider, 400),
171
190
  });
172
191
  }
@@ -183,7 +202,7 @@ export async function exchangeOAuthCode(
183
202
  options: OAuthExchangeOptions,
184
203
  ): Promise<OAuthTokens> {
185
204
  assertOAuthCallback(handshake, callback);
186
- const provider = OAUTH_PROVIDERS[handshake.provider];
205
+ const provider = providerFor(handshake.provider);
187
206
  const clock = options.clock ?? systemClock;
188
207
 
189
208
  const body = new URLSearchParams({
@@ -193,7 +212,9 @@ export async function exchangeOAuthCode(
193
212
  client_id: options.credentials.clientId,
194
213
  client_secret: options.credentials.clientSecret,
195
214
  });
196
- if (provider.usesPkce) body.set('code_verifier', handshake.verifier);
215
+ // Unconditional: PKCE is not provider-dependent, and `OAuthProvider.usesPkce` is the type-level
216
+ // statement of that — there is no configuration in which this line is skipped.
217
+ body.set('code_verifier', handshake.verifier);
197
218
 
198
219
  const payload = await postForm(handshake.provider, body, options);
199
220
  const accessToken = payload['access_token'];
@@ -202,7 +223,7 @@ export async function exchangeOAuthCode(
202
223
  provider: provider.id,
203
224
  stage: 'token',
204
225
  detail: 'the response carried no access_token',
205
- fix: `confirm the ${provider.id} app grants the scopes in OAUTH_PROVIDERS.${provider.id}.scopes`,
226
+ fix: `confirm the ${provider.id} app grants the scopes in providerFor('${provider.id}').scopes`,
206
227
  });
207
228
  }
208
229
 
@@ -233,12 +254,13 @@ export async function exchangeOAuthCode(
233
254
  claims:
234
255
  idToken === null
235
256
  ? null
236
- : verifyIdToken({
257
+ : await verifyIdToken({
237
258
  provider: handshake.provider,
238
259
  idToken,
239
260
  clientId: options.credentials.clientId,
240
261
  nonce: handshake.nonce,
241
262
  clock,
263
+ keys: options.keys ?? 'token-endpoint-tls',
242
264
  }),
243
265
  };
244
266
  }