@ultimat3/auth 1.1.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.
@@ -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;
@@ -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
  }
@@ -0,0 +1,53 @@
1
+ // The fixtures the three `oauth-login` suites share: the frozen instant, a fresh
2
+ // `MemoryAdapter`-backed `Auth`, the two request bodies, the code of a rejection and a JSON
3
+ // `Response`. Shared rather than copied — three suites building their own `Auth` would be three
4
+ // flows that agree only by construction, the same reason `backfill-pass-fixture.ts` exists.
5
+
6
+ import { frozenClock, isUltimateError } from '@ultimat3/core';
7
+ import { type Auth, defineAuth } from './auth';
8
+ import { MemoryAdapter } from './memory-adapter';
9
+ import type { OAuthTokens } from './oauth-exchange';
10
+ import type { OAuthProfile } from './oauth-profile';
11
+
12
+ export const NOW = new Date('2026-08-09T12:00:00.000Z');
13
+
14
+ export const credentials = { clientId: 'client-id', clientSecret: 'client-secret' };
15
+
16
+ /** One adapter and the `Auth` over it, minted per `beforeEach` — never shared between tests. */
17
+ export const freshAuth = (): { adapter: MemoryAdapter; auth: Auth } => {
18
+ const adapter = new MemoryAdapter();
19
+ return {
20
+ adapter,
21
+ auth: defineAuth({ adapter, clock: frozenClock(NOW), providers: ['github', 'google'] }),
22
+ };
23
+ };
24
+
25
+ export const profile = (overrides: Partial<OAuthProfile> = {}): OAuthProfile => ({
26
+ provider: 'github',
27
+ providerAccountId: '583231',
28
+ email: 'ada@example.com',
29
+ emailVerified: true,
30
+ name: 'Ada Lovelace',
31
+ ...overrides,
32
+ });
33
+
34
+ export const tokens = (overrides: Partial<OAuthTokens> = {}): OAuthTokens => ({
35
+ accessToken: 'gho_first',
36
+ refreshToken: null,
37
+ expiresAt: null,
38
+ idToken: null,
39
+ claims: null,
40
+ ...overrides,
41
+ });
42
+
43
+ export const codeOf = async (call: Promise<unknown>): Promise<string> => {
44
+ try {
45
+ await call;
46
+ } catch (error) {
47
+ return isUltimateError(error) ? error.code : `not-an-UltimateError: ${String(error)}`;
48
+ }
49
+ return 'did-not-throw';
50
+ };
51
+
52
+ export const json = (body: unknown): Response =>
53
+ new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });