@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.
@@ -3,14 +3,18 @@
3
3
  // matter which door the user came through. `completeOAuthLogin` is the blessed entry point;
4
4
  // the three steps under it are exported because a custom flow needs the seams, not a second path.
5
5
 
6
- import { ConfigInvalidError, uuid } from '@ultimat3/core';
6
+ import { ConfigInvalidError, logger, uuid } from '@ultimat3/core';
7
7
  import type { AuthAccount, AuthUser } from './adapter';
8
8
  import type { Auth, LoginResult } from './auth';
9
+ import { normaliseEmail } from './email';
9
10
  import {
11
+ authWriteFailed,
10
12
  emailVerifiedNotStored,
11
13
  mfaRequired,
12
14
  oauthAccountNotLinked,
13
15
  oauthExchangeFailed,
16
+ oauthLinkingDisabled,
17
+ restartAt,
14
18
  } from './errors';
15
19
  import type { OAuthCallback, OAuthHandshake } from './oauth';
16
20
  import {
@@ -25,14 +29,43 @@ import { resolveActor } from './policy-bridge';
25
29
  import { loginFailed } from './rate-limit';
26
30
  import { createSession, sessionCookie } from './session';
27
31
 
32
+ /**
33
+ * What the IdP's answer entitles this identity to, in the app's own vocabulary. Which group maps
34
+ * to which role is business convention and never ships (axiom 8) — this is the seam it arrives
35
+ * through, and every field is independently optional so a seam that only knows the org says only
36
+ * that.
37
+ */
38
+ export interface OAuthGrants {
39
+ readonly orgId?: string | null | undefined;
40
+ readonly roles?: readonly string[] | undefined;
41
+ readonly scopes?: readonly string[] | undefined;
42
+ /**
43
+ * The IdP's own stable id for this person, if the app has one. Deliberately NOT derived from
44
+ * `profile.providerAccountId`: `x_users.external_id` is unique across the table, and two
45
+ * different OPs are free to issue the same `sub`, so auto-filling it would make one tenant's
46
+ * google login collide with another's okta login. The app knows which id its provisioning
47
+ * system uses; the framework does not.
48
+ */
49
+ readonly externalId?: string | null | undefined;
50
+ }
51
+
52
+ /** Called once per callback, after the profile is proven and before the session is minted. */
53
+ export type ResolveOAuthGrants = (profile: OAuthProfile) => Promise<OAuthGrants> | OAuthGrants;
54
+
28
55
  export interface OAuthSignInInput {
29
56
  readonly profile: OAuthProfile;
30
57
  readonly tokens: OAuthTokens;
31
58
  readonly ip?: string | null | undefined;
32
59
  readonly userAgent?: string | null | undefined;
33
- /** Granted to a user this flow creates. An existing user's roles are never rewritten here. */
34
- readonly roles?: readonly string[] | undefined;
35
- readonly orgId?: string | null | undefined;
60
+ /**
61
+ * Authoritative on EVERY login, not only the first. The IdP is the source of truth for who is
62
+ * in which group, so a member removed from a group there has to lose the role here on their
63
+ * next sign-in — an apply-once-at-creation rule made "revoke in the IdP" a no-op, and left the
64
+ * first-time user with `roles: []`, `orgId: null` and an actor every `can()` denies.
65
+ *
66
+ * Absent means "the app has no opinion": the stored row is left exactly as it is.
67
+ */
68
+ readonly grants?: OAuthGrants | undefined;
36
69
  }
37
70
 
38
71
  async function userForAccount(auth: Auth, account: AuthAccount): Promise<AuthUser> {
@@ -41,8 +74,48 @@ async function userForAccount(auth: Auth, account: AuthAccount): Promise<AuthUse
41
74
  return user;
42
75
  }
43
76
 
77
+ /**
78
+ * The grant seam applied to a row that already exists. Only the fields the seam actually returned
79
+ * are written, and only when they differ — an unconditional `updateUser` would make every SSO
80
+ * login a write, which is the same mistake `verifySession` used to make on every request.
81
+ */
82
+ async function applyGrants(auth: Auth, user: AuthUser, grants: OAuthGrants): Promise<AuthUser> {
83
+ const same = (a: readonly string[] | undefined, b: readonly string[]): boolean =>
84
+ a === undefined || (a.length === b.length && a.every((one, index) => one === b[index]));
85
+ const unchanged = (declared: string | null | undefined, stored: string | null): boolean =>
86
+ declared === undefined || (declared ?? null) === stored;
87
+ if (
88
+ unchanged(grants.orgId, user.orgId) &&
89
+ unchanged(grants.externalId, user.externalId) &&
90
+ same(grants.roles, user.roles) &&
91
+ same(grants.scopes, user.scopes)
92
+ ) {
93
+ return user;
94
+ }
95
+ const patched = await auth.adapter.updateUser(user.id, {
96
+ ...(grants.orgId === undefined ? {} : { orgId: grants.orgId ?? null }),
97
+ ...(grants.roles === undefined ? {} : { roles: grants.roles }),
98
+ ...(grants.scopes === undefined ? {} : { scopes: grants.scopes }),
99
+ ...(grants.externalId === undefined ? {} : { externalId: grants.externalId ?? null }),
100
+ });
101
+ // Failing closed rather than signing in with the stale row: the whole point of the seam is that
102
+ // the session about to be minted carries what the IdP says today.
103
+ if (patched === null) throw authWriteFailed('updateUser', 'x_users');
104
+ return patched;
105
+ }
106
+
107
+ /**
108
+ * The provider's address, put through the one normalisation every other door uses. Providers send
109
+ * display casing (`Ada@Example.com`) and change it between logins, and `x_users.email` is a plain
110
+ * case-sensitive `unique` column — so unnormalised, the lookup below missed the account the user
111
+ * registered and `createUserFor` minted a second one at the same address, which `login()` could
112
+ * then never reach. `MemoryAdapter` used to fold case itself, which is why no test saw it.
113
+ */
114
+ const profileEmail = (profile: OAuthProfile): string | null =>
115
+ profile.email === null ? null : normaliseEmail(profile.email);
116
+
44
117
  async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthUser> {
45
- const email = input.profile.email;
118
+ const email = profileEmail(input.profile);
46
119
  // The provider authenticated somebody and told us no address. There is nothing to create an
47
120
  // account from, and saying "wrong password" here would send the developer hunting the wrong bug.
48
121
  if (email === null) {
@@ -50,7 +123,17 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
50
123
  provider: input.profile.provider,
51
124
  stage: 'userinfo',
52
125
  detail: 'the provider returned an identity with no email address',
53
- fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then restart the flow`,
126
+ fix: `request the email scope for ${input.profile.provider} in beginOAuth(), then ${restartAt(input.profile.provider)}`,
127
+ });
128
+ }
129
+ const grants = input.grants ?? {};
130
+ if ((grants.roles ?? []).length === 0 && (grants.orgId ?? null) === null) {
131
+ // Not an error — an app may genuinely want a roleless account until somebody approves it —
132
+ // but silent is how "SSO works and the user can do nothing" survives to production. Every
133
+ // `can()` denies a roleless actor, and a tenant-scoped read throws before the query is built.
134
+ logger.warn('auth.oauth.user_created_without_grants', {
135
+ provider: input.profile.provider,
136
+ seam: 'oauthLogin(auth, { resolveGrants })',
54
137
  });
55
138
  }
56
139
  const created = await auth.adapter.createUser({
@@ -59,8 +142,10 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
59
142
  // An OAuth-only account has no password to store, and must never be given a random one:
60
143
  // a hash nobody knows the input to is still a credential a reset flow could hand over.
61
144
  passwordHash: null,
62
- orgId: input.orgId ?? null,
63
- roles: input.roles ?? [],
145
+ orgId: grants.orgId ?? null,
146
+ roles: grants.roles ?? [],
147
+ scopes: grants.scopes ?? [],
148
+ externalId: grants.externalId ?? null,
64
149
  createdAt: auth.clock.now(),
65
150
  });
66
151
  if (!input.profile.emailVerified) return created;
@@ -79,13 +164,17 @@ async function createUserFor(auth: Auth, input: OAuthSignInInput): Promise<AuthU
79
164
  * An address alone is not proof of ownership on either side. Attaching a provider identity to a
80
165
  * local account that never verified its own email hands the login to whoever registered that
81
166
  * address first, so both halves must have proven it before they are treated as one person.
167
+ *
168
+ * `auth.link` is the app's one say in that. `'never'` skips the address step entirely and refuses
169
+ * a collision; `'verified-email'` — the default — is the both-halves-proven rule below.
82
170
  */
83
171
  async function resolveUser(
84
172
  auth: Auth,
85
173
  input: OAuthSignInInput,
86
174
  linked: AuthAccount | null,
87
175
  ): Promise<AuthUser> {
88
- const { provider, email, emailVerified } = input.profile;
176
+ const { provider, emailVerified } = input.profile;
177
+ const email = profileEmail(input.profile);
89
178
  if (linked !== null) return await userForAccount(auth, linked);
90
179
 
91
180
  if (email === null) return await createUserFor(auth, input);
@@ -94,6 +183,12 @@ async function resolveUser(
94
183
  if (existing.disabledAt !== null) throw loginFailed();
95
184
  // The provider did not vouch for the address, so nothing here proves the two are one person.
96
185
  if (!emailVerified) throw loginFailed();
186
+ // Only past that line is the caller known to own the address, which is what makes naming the
187
+ // collision safe. Refusing `'never'` any earlier answered an UNVERIFIED provider address with a
188
+ // distinct code and a `meta.email` where `'verified-email'` answers `loginFailed()` — so the
189
+ // strict policy confirmed an account exists at an address its caller never proved. `disabledAt`
190
+ // above is generic and reveals nothing, which is why it can be asked first.
191
+ if (auth.link === 'never') throw oauthLinkingDisabled(provider, email);
97
192
  // It did vouch, and the local account never did: say so, because this caller owns the address.
98
193
  if (existing.emailVerifiedAt === null) throw oauthAccountNotLinked(provider, email);
99
194
  return existing;
@@ -127,7 +222,9 @@ export async function signInWithOAuth(auth: Auth, input: OAuthSignInInput): Prom
127
222
  }
128
223
 
129
224
  const linked = await auth.adapter.findAccount(provider, input.profile.providerAccountId);
130
- const user = await resolveUser(auth, input, linked);
225
+ const resolved = await resolveUser(auth, input, linked);
226
+ const user =
227
+ input.grants === undefined ? resolved : await applyGrants(auth, resolved, input.grants);
131
228
  // Linked before the MFA gate on purpose: the second factor is finished on another request,
132
229
  // and that request must find the identity already attached. A re-link refreshes the tokens
133
230
  // and keeps the row's own identity — the provider account never changes hands.
@@ -162,8 +259,8 @@ export interface CompleteOAuthLoginInput {
162
259
  readonly timeoutMs?: number | undefined;
163
260
  readonly ip?: string | null | undefined;
164
261
  readonly userAgent?: string | null | undefined;
165
- readonly roles?: readonly string[] | undefined;
166
- readonly orgId?: string | null | undefined;
262
+ /** The app's grant seam. Called once, after the profile is proven. */
263
+ readonly resolveGrants?: ResolveOAuthGrants | undefined;
167
264
  }
168
265
 
169
266
  /** Callback → session, in one call: exchange, identify, sign in. */
@@ -182,12 +279,12 @@ export async function completeOAuthLogin(
182
279
  fetch: input.fetch,
183
280
  timeoutMs: input.timeoutMs,
184
281
  });
282
+ const grants = input.resolveGrants === undefined ? undefined : await input.resolveGrants(profile);
185
283
  return await signInWithOAuth(auth, {
186
284
  profile,
187
285
  tokens,
188
286
  ip: input.ip,
189
287
  userAgent: input.userAgent,
190
- roles: input.roles,
191
- orgId: input.orgId,
288
+ ...(grants === undefined ? {} : { grants }),
192
289
  });
193
290
  }
@@ -0,0 +1,20 @@
1
+ // Single responsibility: the one declaration of where the OAuth login routes live. It imports
2
+ // nothing so that `errors.ts` and `oauth-route.ts` can both read it without a cycle — which is
3
+ // the point: three shipped `fix:` lines told the caller to restart at `GET /auth/oauth/<provider>`
4
+ // while no route was mounted anywhere. Deriving both the mount and the fix from here makes a fix
5
+ // line that names a route nothing serves impossible rather than merely discouraged.
6
+
7
+ /** Not configurable, on purpose: a movable base path is a `fix:` line that can go stale again. */
8
+ export const OAUTH_BASE_PATH = '/auth/oauth';
9
+
10
+ /** The pattern a router mounts. `:provider` is the segment `oauthStartPath` fills in. */
11
+ export const OAUTH_START_ROUTE_PATH = `${OAUTH_BASE_PATH}/:provider` as const;
12
+
13
+ export const OAUTH_CALLBACK_ROUTE_PATH = `${OAUTH_BASE_PATH}/:provider/callback` as const;
14
+
15
+ /** Where a browser starts a login. This exact string is what the `fix:` lines quote. */
16
+ export const oauthStartPath = (provider: string): string => `${OAUTH_BASE_PATH}/${provider}`;
17
+
18
+ /** Where the provider sends it back. Registered with the provider as the `redirect_uri`. */
19
+ export const oauthCallbackPath = (provider: string): string =>
20
+ `${OAUTH_BASE_PATH}/${provider}/callback`;
@@ -4,15 +4,17 @@
4
4
  // decides whether this login may attach itself to an existing account by address.
5
5
 
6
6
  import { logger } from '@ultimat3/core';
7
- import { oauthExchangeFailed } from './errors';
7
+ import { oauthExchangeFailed, restartAt } from './errors';
8
8
  import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
9
- import { OAUTH_PROVIDERS, type OAuthProviderId } from './oauth';
9
+ import { isRecord } from './json';
10
+ import type { OAuthProviderId } from './oauth';
10
11
  import {
11
12
  OAUTH_USER_AGENT,
12
13
  type OAuthFetch,
13
14
  type OAuthTokens,
14
15
  providerDetail,
15
16
  } from './oauth-exchange';
17
+ import { providerFor } from './oauth-registry';
16
18
 
17
19
  const DEFAULT_TIMEOUT_MS = 10_000;
18
20
 
@@ -37,9 +39,6 @@ interface GithubEmail {
37
39
  readonly verified: boolean;
38
40
  }
39
41
 
40
- const isRecord = (value: unknown): value is Record<string, unknown> =>
41
- typeof value === 'object' && value !== null && !Array.isArray(value);
42
-
43
42
  const stringOrNull = (value: unknown): string | null =>
44
43
  typeof value === 'string' && value !== '' ? value : null;
45
44
 
@@ -108,14 +107,14 @@ async function fromUserInfo(
108
107
  tokens: OAuthTokens,
109
108
  options: OAuthProfileOptions,
110
109
  ): Promise<OAuthProfile> {
111
- const config = OAUTH_PROVIDERS[provider];
110
+ const config = providerFor(provider);
112
111
  const url = config.userInfoUrl;
113
112
  if (url === null) {
114
113
  throw oauthExchangeFailed({
115
114
  provider,
116
115
  stage: 'userinfo',
117
116
  detail: `${provider} publishes no userinfo endpoint and its id token carried no identity`,
118
- fix: `request the "email" scope for ${provider} in beginOAuth(), then restart the flow`,
117
+ fix: `request the "email" scope for ${provider} in beginOAuth(), then ${restartAt(provider)}`,
119
118
  });
120
119
  }
121
120
 
@@ -126,7 +125,7 @@ async function fromUserInfo(
126
125
  stage: 'userinfo',
127
126
  detail: result.detail,
128
127
  status: result.status,
129
- fix: `confirm the ${provider} app still grants ${config.scopes.join(' ')}, then restart the flow`,
128
+ fix: `confirm the ${provider} app still grants ${config.scopes.join(' ')}, then ${restartAt(provider)}`,
130
129
  });
131
130
  }
132
131
  if (!isRecord(result.body)) {
@@ -148,7 +147,7 @@ async function fromUserInfo(
148
147
  provider,
149
148
  stage: 'userinfo',
150
149
  detail: 'the profile carried no stable account id',
151
- fix: `confirm the ${provider} app requests ${config.scopes.join(' ')} and restart the flow`,
150
+ fix: `confirm the ${provider} app requests ${config.scopes.join(' ')}, then ${restartAt(provider)}`,
152
151
  });
153
152
  }
154
153
 
@@ -186,7 +185,7 @@ export async function oauthProfile(
186
185
  if (claims === null) return await fromUserInfo(provider, tokens, options);
187
186
 
188
187
  const email = stringOrNull(claims.email);
189
- const userInfoUrl = OAUTH_PROVIDERS[provider].userInfoUrl;
188
+ const userInfoUrl = providerFor(provider).userInfoUrl;
190
189
  if (email === null && userInfoUrl !== null) {
191
190
  const profile = await fromUserInfo(provider, tokens, options);
192
191
  // Two surfaces, one identity — or this is not that identity. Overwriting the subject and
@@ -0,0 +1,65 @@
1
+ // Single responsibility: which OAuth providers this process knows about. A closed union of three
2
+ // consumer IdPs made an enterprise OP — Okta, Entra, Ping, an in-house OP — *unrepresentable*: the
3
+ // constraint was a type, so there was no runtime escape and the only ways out were forking the
4
+ // package or bypassing the whole subsystem, losing PKCE, the sealed handshake, issuer pinning and
5
+ // account linking with it. The three built-ins register through the same call an app uses, so the
6
+ // opening does not create a second path.
7
+
8
+ import { oauthProviderDuplicate, oauthProviderUnknown } from './errors';
9
+ import type { OAuthProvider } from './oauth';
10
+ import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
11
+
12
+ const registry = new Map<string, OAuthProvider>();
13
+
14
+ /**
15
+ * Frozen on the way in, arrays included: a registered provider is read on every login of its
16
+ * kind, and a caller that kept a reference to the object it passed could otherwise repoint
17
+ * `tokenUrl` or widen `issuers` after boot.
18
+ */
19
+ const seal = (provider: OAuthProvider): OAuthProvider =>
20
+ Object.freeze({
21
+ ...provider,
22
+ issuers: Object.freeze([...provider.issuers]),
23
+ scopes: Object.freeze([...provider.scopes]),
24
+ });
25
+
26
+ /**
27
+ * Add an IdP. The one extension point, and the same one `oauth-builtins.ts` goes through.
28
+ *
29
+ * `usesPkce` stays the literal `true` on `OAuthProvider`, so this opening cannot be used to
30
+ * register a PKCE-less provider: the mechanism the package exists to own survives the widening.
31
+ *
32
+ * A duplicate id is `X_OAUTH_PROVIDER_DUPLICATE` rather than a silent replacement — two modules
33
+ * each registering `'okta'` is one of them quietly deciding where every okta login goes.
34
+ */
35
+ export function registerOAuthProvider(provider: OAuthProvider): OAuthProvider {
36
+ if (registry.has(provider.id)) throw oauthProviderDuplicate(provider.id);
37
+ const sealed = seal(provider);
38
+ registry.set(sealed.id, sealed);
39
+ return sealed;
40
+ }
41
+
42
+ for (const provider of BUILTIN_OAUTH_PROVIDERS) registerOAuthProvider(provider);
43
+
44
+ export function hasOAuthProvider(id: string): boolean {
45
+ return registry.has(id);
46
+ }
47
+
48
+ /**
49
+ * The one lookup. Throws rather than answering `undefined`, because every caller in this package
50
+ * would otherwise have to decide what an unknown provider means — and one of them would decide
51
+ * wrong. `X_OAUTH_PROVIDER_UNKNOWN` already existed for exactly this.
52
+ */
53
+ export function providerFor(id: string): OAuthProvider {
54
+ const provider = registry.get(id);
55
+ if (provider === undefined) throw oauthProviderUnknown(id, oauthProviderIds());
56
+ return provider;
57
+ }
58
+
59
+ /**
60
+ * Live, never a snapshot taken at import: `defineAuth({ providers })` defaults to this, and a
61
+ * frozen copy would leave an IdP registered after this module loaded enabled nowhere.
62
+ */
63
+ export function oauthProviderIds(): readonly string[] {
64
+ return [...registry.keys()];
65
+ }
@@ -0,0 +1,293 @@
1
+ // Single responsibility: the two HTTP routes the OAuth library functions have always been
2
+ // missing — the redirect out and the callback back — mounted at one fixed pair of paths so the
3
+ // `fix:` lines that name them cannot go stale. Everything below composes `beginOAuth`,
4
+ // `handshakeCookie` and `completeOAuthLogin`; no new protocol lives here.
5
+ //
6
+ // A route DESCRIPTOR, never a mounted handler, for the reason `mcpHttpRoute()` is one:
7
+ // `@ultimat3/http` is tier 2 like this package, so auth may not import it — and `defineRoute`
8
+ // is tier 4 and describes a rendered page. A bare `Request` in, a `Response` out, drivable from
9
+ // a test and mountable by any router that can match a `:param`.
10
+
11
+ import { type Clock, isUltimateError, renderThrowable, type UltimateError } from '@ultimat3/core';
12
+ import type { Auth, LoginResult } from './auth';
13
+ import { oauthDenied, oauthExchangeFailed, oauthProviderUnknown } from './errors';
14
+ import { beginOAuth, type OAuthProviderId } from './oauth';
15
+ import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
16
+ import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
17
+ import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
18
+ import { oauthCredentials } from './oauth-exchange';
19
+ import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
20
+ import {
21
+ OAUTH_CALLBACK_ROUTE_PATH,
22
+ OAUTH_START_ROUTE_PATH,
23
+ oauthCallbackPath,
24
+ } from './oauth-paths';
25
+ import { hasOAuthProvider } from './oauth-registry';
26
+
27
+ /**
28
+ * What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
29
+ * `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
30
+ */
31
+ export interface AuthRouteDescriptor {
32
+ readonly method: 'GET';
33
+ /** The mount pattern, `:provider` included. The handler re-reads it off the request itself. */
34
+ readonly path: string;
35
+ /** Stable id for a route table, a trace and the manifest. */
36
+ readonly name: string;
37
+ /** Both legs are public by definition — they are how an anonymous visitor stops being one. */
38
+ readonly auth: 'public';
39
+ handle(request: Request): Promise<Response>;
40
+ }
41
+
42
+ export interface OAuthLoginRoutes {
43
+ readonly start: AuthRouteDescriptor;
44
+ readonly callback: AuthRouteDescriptor;
45
+ }
46
+
47
+ export interface OAuthLoginOptions {
48
+ /**
49
+ * The origin the provider redirects back to. Defaults to `APP_URL`, then to the request's own
50
+ * origin — which is the `Host` header, so it is preferred last: a forged one only ever produces
51
+ * a `redirect_uri` the provider refuses, but naming the canonical origin costs nothing.
52
+ */
53
+ readonly baseUrl?: string | undefined;
54
+ /** Defaults to the two env vars in the provider table, read per request, never at import. */
55
+ readonly credentials?: OAuthClientCredentials | undefined;
56
+ /** Defaults to `SESSION_SECRET`. Seals the handshake across the two requests. */
57
+ readonly secret?: string | undefined;
58
+ /** Injected in tests; production uses the global. */
59
+ readonly fetch?: OAuthFetch | undefined;
60
+ readonly timeoutMs?: number | undefined;
61
+ /** Where a signed-in browser lands. A fixed path — never read from the request. See below. */
62
+ readonly successPath?: string | undefined;
63
+ /** Narrower scopes than the provider's defaults, when the app wants less. */
64
+ readonly scopes?: readonly string[] | undefined;
65
+ /**
66
+ * The client address recorded on the session. Defaults to none: the only honest source is a
67
+ * trusted-proxy chain, and this package cannot see one. `@ultimat3/http` can, and passes it.
68
+ */
69
+ readonly clientIp?: ((request: Request) => string | null | undefined) | undefined;
70
+ /**
71
+ * What the IdP's answer entitles this identity to. **Omit it and a first-time SSO user is
72
+ * created with `roles: []` and `orgId: null`** — an actor every `can()` denies, and a
73
+ * tenant-scoped read that throws `X_TENANCY_ACTOR_ORG_REQUIRED` before the query is built. SSO
74
+ * "works" and the person can do nothing until somebody runs SQL.
75
+ *
76
+ * A seam and not a group-to-role table, because which IdP group means which role is business
77
+ * convention and business convention never ships (axiom 8). The framework's part is calling it
78
+ * on every login, so removing somebody from a group in the IdP takes effect at their next
79
+ * sign-in rather than never.
80
+ */
81
+ readonly resolveGrants?: ResolveOAuthGrants | undefined;
82
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
83
+ }
84
+
85
+ /**
86
+ * HTTP status per code, for a descriptor driven OUTSIDE a pipeline — a bare `Request` in, a
87
+ * `Response` out, which is the whole point of a descriptor. `@ultimat3/http`'s `error-map.ts` OWNS
88
+ * these numbers and is where a new one is declared; this package is the same tier and can never
89
+ * import it, so the table is a copy the pin `scripts/oauth-route-status.test.ts` holds identical to
90
+ * `statusFor()`. Everything absent is the provider's fault until proven otherwise: 502.
91
+ */
92
+ export const OAUTH_ROUTE_STATUS: Readonly<Record<string, number>> = {
93
+ X_OAUTH_PROVIDER_UNKNOWN: 404,
94
+ X_OAUTH_DENIED: 403,
95
+ X_OAUTH_STATE_INVALID: 400,
96
+ X_OAUTH_TOKEN_INVALID: 400,
97
+ X_UNAUTHENTICATED: 401,
98
+ X_MFA_REQUIRED: 401,
99
+ X_ACCOUNT_LOCKED: 429,
100
+ X_ENV_MISSING: 500,
101
+ X_CONFIG_INVALID: 500,
102
+ };
103
+
104
+ /**
105
+ * What an anonymous caller is allowed to read. `UltimateError.toJSON()` carries `meta` and `stack`
106
+ * — a developer's fields — and BOTH legs of this flow are public by definition, so serialising it
107
+ * whole published a stack trace and whatever a factory put in `meta` to whoever typed the URL. Four
108
+ * fields, the same four on every code: no per-code judgement about which `meta` key is safe today.
109
+ */
110
+ const publicBody = (coded: UltimateError): Record<string, string> => ({
111
+ code: coded.code,
112
+ title: coded.title,
113
+ cause: coded.cause,
114
+ fix: coded.fix,
115
+ docs: coded.docs,
116
+ });
117
+
118
+ /**
119
+ * Failure is JSON, never a redirect to a login page carrying `?error=`. A callback is the one
120
+ * request in the flow whose failure the developer has to read, and a redirect that drops the code
121
+ * and the fix line is exactly how three dead `fix:` strings survived a whole release. An app that
122
+ * wants a rendered page wraps these two descriptors; the framework ships the debuggable answer.
123
+ */
124
+ function problem(error: unknown, extraCookies: readonly string[]): Response {
125
+ const coded = isUltimateError(error)
126
+ ? error
127
+ : oauthExchangeFailed({
128
+ provider: 'oauth',
129
+ stage: 'token',
130
+ // `renderThrowable`, never `error.message`: the throw came from an adapter or a `fetch`
131
+ // this package does not own, and a getter on `message` — or a `Proxy` trapping
132
+ // `getPrototypeOf` — would make the callback's last answer throw instead of send.
133
+ detail: renderThrowable(error),
134
+ fix: 'throw an UltimateError from the AuthAdapter or OAuthFetch that failed — the factories are in packages/auth/src/errors.ts',
135
+ });
136
+ const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
137
+ for (const cookie of extraCookies) headers.append('set-cookie', cookie);
138
+ return new Response(JSON.stringify(publicBody(coded)), {
139
+ // 502 is the default: an uncoded throw on this path came out of the provider conversation.
140
+ status: OAUTH_ROUTE_STATUS[coded.code] ?? 502,
141
+ headers,
142
+ });
143
+ }
144
+
145
+ /** `/auth/oauth/github` → `github`; `/auth/oauth/github/callback` → `github`. */
146
+ function providerSegment(request: Request, leg: 'start' | 'callback'): string {
147
+ const segments = new URL(request.url).pathname.split('/').filter((s) => s.length > 0);
148
+ const index = leg === 'start' ? segments.length - 1 : segments.length - 2;
149
+ return segments[index] ?? '';
150
+ }
151
+
152
+ /**
153
+ * Both halves of "is this a provider", in one refusal. An unknown segment and a known provider
154
+ * the app left out of `defineAuth({ providers })` are the same 404 on purpose — telling an
155
+ * anonymous caller which of the two it hit describes the app's configuration for free.
156
+ *
157
+ * The list in the refusal is the THREE BUILT-INS, never the live registry and never
158
+ * `defineAuth({ providers })`. Both of the latter are this deployment's own configuration, and
159
+ * this caller is an anonymous stranger who typed a URL: an app that registered an internal OP has
160
+ * put its own vocabulary into the registry, and echoing it back names a system the stranger had no
161
+ * way to know exists. The built-in list is a framework constant already in the public docs, so it
162
+ * discloses nothing while still making the fix executable — and `registerOAuthProvider` in the
163
+ * same sentence covers the other branch without enumerating anything.
164
+ *
165
+ * `providerFor()` keeps the full registered list for the same reason in reverse: its reader is a
166
+ * developer holding a stack trace, and there the list is exactly what makes the fix runnable.
167
+ */
168
+ function assertEnabled(auth: Auth, segment: string): OAuthProviderId {
169
+ const supported = BUILTIN_OAUTH_PROVIDER_IDS;
170
+ if (!hasOAuthProvider(segment)) throw oauthProviderUnknown(segment, supported);
171
+ const provider: OAuthProviderId = segment;
172
+ if (!auth.providers.includes(provider)) throw oauthProviderUnknown(segment, supported);
173
+ return provider;
174
+ }
175
+
176
+ function originFor(request: Request, options: OAuthLoginOptions): string {
177
+ const env = options.env ?? Bun.env;
178
+ const declared = options.baseUrl ?? env['APP_URL']?.trim() ?? '';
179
+ return declared === '' ? new URL(request.url).origin : declared.replace(/\/+$/, '');
180
+ }
181
+
182
+ /** The handshake's own options, assembled once so both legs seal and open it identically. */
183
+ const sealOptions = (
184
+ clock: Clock,
185
+ options: OAuthLoginOptions,
186
+ ): { clock: Clock; secret?: string | undefined } => ({
187
+ clock,
188
+ ...(options.secret === undefined ? {} : { secret: options.secret }),
189
+ });
190
+
191
+ async function startHandler(
192
+ auth: Auth,
193
+ options: OAuthLoginOptions,
194
+ request: Request,
195
+ ): Promise<Response> {
196
+ try {
197
+ const provider = assertEnabled(auth, providerSegment(request, 'start'));
198
+ const credentials = options.credentials ?? oauthCredentials(provider, options.env ?? Bun.env);
199
+ const handshake = beginOAuth({
200
+ provider,
201
+ clientId: credentials.clientId,
202
+ redirectUri: `${originFor(request, options)}${oauthCallbackPath(provider)}`,
203
+ scopes: options.scopes,
204
+ });
205
+ return new Response(null, {
206
+ // 302, the status every OAuth client already expects on this hop.
207
+ status: 302,
208
+ headers: {
209
+ location: handshake.authorizeUrl,
210
+ 'set-cookie': handshakeCookie(handshake, sealOptions(auth.clock, options)),
211
+ },
212
+ });
213
+ } catch (error) {
214
+ return problem(error, []);
215
+ }
216
+ }
217
+
218
+ /** The provider declining is `error=` on the redirect, and there is no code to exchange. */
219
+ function assertNoProviderError(url: URL, provider: string): void {
220
+ const declined = url.searchParams.get('error');
221
+ if (declined === null || declined === '') return;
222
+ throw oauthDenied(provider, declined, url.searchParams.get('error_description'));
223
+ }
224
+
225
+ async function callbackHandler(
226
+ auth: Auth,
227
+ options: OAuthLoginOptions,
228
+ request: Request,
229
+ ): Promise<Response> {
230
+ const segment = providerSegment(request, 'callback');
231
+ // Cleared on every outcome, success and failure alike: the code it authorised is spent either
232
+ // way, so a handshake that outlives its own callback is a replay window and nothing else.
233
+ const clear = hasOAuthProvider(segment) ? [clearHandshakeCookie(segment)] : [];
234
+ try {
235
+ const provider = assertEnabled(auth, segment);
236
+ const url = new URL(request.url);
237
+ assertNoProviderError(url, provider);
238
+ const result: LoginResult = await completeOAuthLogin(auth, {
239
+ handshake: readHandshakeCookie(request, provider, sealOptions(auth.clock, options)),
240
+ callback: {
241
+ state: url.searchParams.get('state') ?? '',
242
+ code: url.searchParams.get('code') ?? '',
243
+ },
244
+ ...(options.credentials === undefined ? {} : { credentials: options.credentials }),
245
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
246
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
247
+ ...(options.resolveGrants === undefined ? {} : { resolveGrants: options.resolveGrants }),
248
+ ip: options.clientIp?.(request) ?? null,
249
+ userAgent: request.headers.get('user-agent'),
250
+ });
251
+ const headers = new Headers({
252
+ // A fixed path, never `?next=`: an attacker-supplied return target on the one endpoint whose
253
+ // job is to hand out a session is the classic open redirect, and `@ultimat3/http`'s
254
+ // `nextAfterSignIn` is the one implementation of that check. Two copies is one that drifts.
255
+ location: options.successPath ?? '/',
256
+ });
257
+ // 303: the callback may arrive as a `form_post`, and the destination is a GET either way.
258
+ headers.append('set-cookie', result.cookie);
259
+ for (const cookie of clear) headers.append('set-cookie', cookie);
260
+ return new Response(null, { status: 303, headers });
261
+ } catch (error) {
262
+ return problem(error, clear);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * The two routes. Mount them and "log in with GitHub" is a button pointing at
268
+ * `/auth/oauth/github` — no handshake store, no PKCE bookkeeping, no state check to forget.
269
+ *
270
+ * ```ts
271
+ * const { start, callback } = oauthLogin(auth);
272
+ * // start.path → '/auth/oauth/:provider'
273
+ * // callback.path → '/auth/oauth/:provider/callback'
274
+ * ```
275
+ */
276
+ export function oauthLogin(auth: Auth, options: OAuthLoginOptions = {}): OAuthLoginRoutes {
277
+ return Object.freeze({
278
+ start: Object.freeze({
279
+ method: 'GET',
280
+ path: OAUTH_START_ROUTE_PATH,
281
+ name: 'auth.oauth.start',
282
+ auth: 'public',
283
+ handle: (request: Request) => startHandler(auth, options, request),
284
+ } as const),
285
+ callback: Object.freeze({
286
+ method: 'GET',
287
+ path: OAUTH_CALLBACK_ROUTE_PATH,
288
+ name: 'auth.oauth.callback',
289
+ auth: 'public',
290
+ handle: (request: Request) => callbackHandler(auth, options, request),
291
+ } as const),
292
+ });
293
+ }