@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.
@@ -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
+ }
package/src/oauth.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  // Single responsibility: the OAuth2/OIDC handshake. PKCE is mandatory rather than
2
2
  // provider-dependent — an authorization code with no proof-of-possession is stealable from a
3
3
  // redirect, and "this provider does not need it" is how that becomes a real incident. Provider
4
- // configs are pure data: importing this file performs no network I/O and reads no env.
4
+ // configs are pure data and live in `oauth-registry.ts`: importing this file performs no network
5
+ // I/O and reads no env.
5
6
 
6
7
  import { oauthStateInvalid } from './errors';
8
+ import { providerFor } from './oauth-registry';
7
9
  import { base64Url, randomToken, sha256Bytes, timingSafeEqual } from './tokens';
8
10
 
9
11
  export interface OAuthProvider {
@@ -18,67 +20,37 @@ export interface OAuthProvider {
18
20
  * token. A list rather than one string because Google has issued both forms for years.
19
21
  */
20
22
  readonly issuers: readonly string[];
23
+ /**
24
+ * Where this provider publishes the keys its id tokens are signed with. `null` for a provider
25
+ * that issues no id token (GitHub). A provider with a key set can have a token from it verified
26
+ * on a channel that is not the token endpoint — IdP-initiated login, `form_post`, back-channel
27
+ * logout — which is what `jwks.ts` and `verifyIdToken({ keys })` exist for.
28
+ */
29
+ readonly jwksUri: string | null;
21
30
  readonly scopes: readonly string[];
22
- readonly usesPkce: boolean;
31
+ /**
32
+ * The literal `true`, not `boolean`. A provider config saying `usesPkce: false` was always
33
+ * invalid — an authorization code with no proof-of-possession is stealable from a redirect —
34
+ * and a comment saying so is not a build error. This is, and it deletes every downstream
35
+ * `if (provider.usesPkce)` branch along with the state it could ever have been false in.
36
+ *
37
+ * It stays the literal now that `registerOAuthProvider` is open to any app: the mechanism this
38
+ * package exists to own has to survive the opening, so an app cannot register a PKCE-less IdP.
39
+ */
40
+ readonly usesPkce: true;
23
41
  /** OIDC providers echo `nonce` in the id token; it binds the token to this browser. */
24
42
  readonly usesNonce: boolean;
25
43
  readonly clientIdEnv: string;
26
44
  readonly clientSecretEnv: string;
27
45
  }
28
46
 
29
- export const OAUTH_PROVIDERS = {
30
- github: {
31
- id: 'github',
32
- authorizeUrl: 'https://github.com/login/oauth/authorize',
33
- tokenUrl: 'https://github.com/login/oauth/access_token',
34
- userInfoUrl: 'https://api.github.com/user',
35
- // GitHub omits a private address from the profile; the identity is still incomplete
36
- // without it, so the flow asks for the verified list rather than guessing.
37
- userEmailsUrl: 'https://api.github.com/user/emails',
38
- issuers: [],
39
- scopes: ['read:user', 'user:email'],
40
- usesPkce: true,
41
- usesNonce: false,
42
- clientIdEnv: 'GITHUB_CLIENT_ID',
43
- clientSecretEnv: 'GITHUB_CLIENT_SECRET',
44
- },
45
- google: {
46
- id: 'google',
47
- authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
48
- tokenUrl: 'https://oauth2.googleapis.com/token',
49
- // Reached only when a narrowed `scopes` leaves the id token without an email claim.
50
- userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
51
- userEmailsUrl: null,
52
- issuers: ['https://accounts.google.com', 'accounts.google.com'],
53
- scopes: ['openid', 'email', 'profile'],
54
- usesPkce: true,
55
- usesNonce: true,
56
- clientIdEnv: 'GOOGLE_CLIENT_ID',
57
- clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
58
- },
59
- apple: {
60
- id: 'apple',
61
- authorizeUrl: 'https://appleid.apple.com/auth/authorize',
62
- tokenUrl: 'https://appleid.apple.com/auth/token',
63
- // Apple returns claims in the id token only; there is no userinfo endpoint to call.
64
- userInfoUrl: null,
65
- userEmailsUrl: null,
66
- issuers: ['https://appleid.apple.com'],
67
- scopes: ['name', 'email'],
68
- usesPkce: true,
69
- usesNonce: true,
70
- clientIdEnv: 'APPLE_CLIENT_ID',
71
- // Apple alone does not accept a static secret: `APPLE_CLIENT_SECRET` must hold the ES256
72
- // client-secret JWT signed with the .p8 key, which Apple expires every six months.
73
- clientSecretEnv: 'APPLE_CLIENT_SECRET',
74
- },
75
- } as const satisfies Readonly<Record<string, OAuthProvider>>;
76
-
77
- export type OAuthProviderId = keyof typeof OAUTH_PROVIDERS;
78
-
79
- export const OAUTH_PROVIDER_IDS: readonly OAuthProviderId[] = Object.freeze(
80
- Object.keys(OAUTH_PROVIDERS) as OAuthProviderId[],
81
- );
47
+ /**
48
+ * Any registered provider's id — `string`, not a closed union, since 1.3.0. The union of three
49
+ * consumer IdPs made an enterprise OP unrepresentable at the type level, which is a constraint no
50
+ * configuration can escape. `providerFor(id)` is the runtime check that replaces it, and it
51
+ * throws the same `X_OAUTH_PROVIDER_UNKNOWN` the route already answered with.
52
+ */
53
+ export type OAuthProviderId = string;
82
54
 
83
55
  export interface PkcePair {
84
56
  readonly verifier: string;
@@ -115,7 +87,7 @@ export interface BeginOAuthInput {
115
87
  }
116
88
 
117
89
  export function beginOAuth(input: BeginOAuthInput): OAuthHandshake {
118
- const provider = OAUTH_PROVIDERS[input.provider];
90
+ const provider = providerFor(input.provider);
119
91
  const pkce = createPkce();
120
92
  const state = randomToken(16);
121
93
  const nonce = randomToken(16);
@@ -155,11 +127,12 @@ export interface OAuthCallback {
155
127
  * tells an attacker which half to keep guessing at.
156
128
  */
157
129
  export function assertOAuthCallback(handshake: OAuthHandshake, callback: OAuthCallback): void {
158
- const provider = OAUTH_PROVIDERS[handshake.provider];
130
+ const provider = providerFor(handshake.provider);
159
131
  if (!timingSafeEqual(handshake.state, callback.state)) {
160
132
  throw oauthStateInvalid(provider.id, 'state did not match the stored handshake');
161
133
  }
162
- if (provider.usesPkce && handshake.verifier.length < 43) {
134
+ // Unconditional: `usesPkce` is the literal `true`, so there is no provider to exempt.
135
+ if (handshake.verifier.length < 43) {
163
136
  throw oauthStateInvalid(provider.id, 'no PKCE verifier was stored for this handshake');
164
137
  }
165
138
  if (callback.nonce !== undefined && !timingSafeEqual(handshake.nonce, callback.nonce)) {
package/src/password.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  // a migration. Verification always burns a full KDF even when the user does not exist —
4
4
  // otherwise response time answers "is this email registered?" for free.
5
5
 
6
- import { passwordWeak } from './errors';
6
+ import { AuthError, passwordWeak } from './errors';
7
+ import { kdfGate } from './kdf-gate';
7
8
 
8
9
  export interface PasswordParams {
9
10
  readonly algorithm: 'argon2id';
@@ -57,15 +58,24 @@ const COMMON_PASSWORDS: ReadonlySet<string> = new Set([
57
58
  'correcthorse',
58
59
  ]);
59
60
 
61
+ /**
62
+ * Through `kdfGate()`, like every other KDF call here: 19 MiB of arena per hash and no per-source
63
+ * limiter that an IPv6 /64 cannot walk around means the ONLY thing bounding argon2 memory on this
64
+ * box is this gate. Past its queue it refuses with `X_OVERLOADED`, the same shed http's `admit`
65
+ * stage performs — a refusal that costs one comparison, not one arena.
66
+ */
60
67
  export async function hashPassword(
61
68
  password: string,
62
69
  params: PasswordParams = DEFAULT_PASSWORD_PARAMS,
63
70
  ): Promise<string> {
64
- return await Bun.password.hash(password, {
65
- algorithm: params.algorithm,
66
- memoryCost: params.memoryCost,
67
- timeCost: params.timeCost,
68
- });
71
+ return await kdfGate().run(
72
+ async () =>
73
+ await Bun.password.hash(password, {
74
+ algorithm: params.algorithm,
75
+ memoryCost: params.memoryCost,
76
+ timeCost: params.timeCost,
77
+ }),
78
+ );
69
79
  }
70
80
 
71
81
  /** Reads the parameters back out of a PHC string. `null` means "not a hash we recognise". */
@@ -90,12 +100,51 @@ export function needsRehash(
90
100
  }
91
101
 
92
102
  export interface VerifyPasswordInput {
93
- /** `null` when no user matched. The KDF still runs, on a throwaway hash. */
103
+ /**
104
+ * `null` when no user matched. The KDF still runs, on a throwaway hash — and a stored hash Bun
105
+ * cannot read takes that same branch, so neither is separable from a wrong password.
106
+ */
94
107
  readonly hash: string | null;
95
108
  readonly password: string;
96
109
  readonly params?: PasswordParams | undefined;
97
110
  }
98
111
 
112
+ /** The KDF the happy path would have burnt, then the one failure shape. Never a cheap answer. */
113
+ async function burnAndFail(
114
+ password: string,
115
+ params: PasswordParams,
116
+ ): Promise<PasswordVerification> {
117
+ await hashPassword(password, params);
118
+ return FAILED;
119
+ }
120
+
121
+ /**
122
+ * `false` is a wrong password, `null` is a stored hash Bun cannot read at all.
123
+ *
124
+ * `Bun.password.verify` THROWS rather than answering on a hash it cannot parse — measured, bun
125
+ * 1.3.14: a Django `pbkdf2_sha256$...` row is `UnsupportedAlgorithm`, a truncated bcrypt string is
126
+ * `InvalidEncoding`. Letting that escape was two faults. A bare `Error` reached `login()`, so the
127
+ * caller answered 500 instead of the one credential failure; and it landed on exactly the rows
128
+ * that have not migrated off the legacy scheme, which makes "has this account been migrated" —
129
+ * and therefore "does this account exist" — readable from the outside. That is the enumeration
130
+ * oracle the whole file is built to close, on the one table where a foreign hash is normal.
131
+ *
132
+ * Supported-but-old is a different thing and stays a verdict: bcrypt verifies natively here and
133
+ * `needsRehash` flags it, which is the lever a legacy migration rewrites rows with.
134
+ *
135
+ * Nothing is logged. The algorithm of an unreadable hash is the oracle again, one layer down.
136
+ */
137
+ async function verifyAgainst(password: string, hash: string): Promise<boolean | null> {
138
+ try {
139
+ return await kdfGate().run(async () => await Bun.password.verify(password, hash));
140
+ } catch (error) {
141
+ // The gate shedding (`X_OVERLOADED`) is load, never a verdict on the credential: swallowing it
142
+ // would answer "wrong password" for a request this process refused to do the work for.
143
+ if (error instanceof AuthError) throw error;
144
+ return null;
145
+ }
146
+ }
147
+
99
148
  /**
100
149
  * Never short-circuits on a missing user: the `hashPassword` call in the `null` branch costs
101
150
  * the same order of magnitude as the verify in the happy branch, so the two are not separable
@@ -103,13 +152,16 @@ export interface VerifyPasswordInput {
103
152
  */
104
153
  export async function verifyPassword(input: VerifyPasswordInput): Promise<PasswordVerification> {
105
154
  const params = input.params ?? DEFAULT_PASSWORD_PARAMS;
106
- if (input.hash === null) {
107
- await hashPassword(input.password, params);
108
- return FAILED;
109
- }
110
- const ok = await Bun.password.verify(input.password, input.hash);
155
+ // Read into a local so the two branches below narrow it without a cast.
156
+ const hash = input.hash;
157
+ // `''` joins the no-user branch rather than reaching the KDF: an account with no password
158
+ // credential (oauth-only) answers `false` from Bun for free, and a failure that costs nothing
159
+ // is a stopwatch away from "this address exists but has never set a password".
160
+ if (hash === null || hash === '') return await burnAndFail(input.password, params);
161
+ const ok = await verifyAgainst(input.password, hash);
162
+ if (ok === null) return await burnAndFail(input.password, params);
111
163
  if (!ok) return FAILED;
112
- return { ok: true, needsRehash: needsRehash(input.hash, params) };
164
+ return { ok: true, needsRehash: needsRehash(hash, params) };
113
165
  }
114
166
 
115
167
  export interface StrengthOptions {
@@ -38,12 +38,18 @@ const withPermissions = (actor: Actor, permissions: readonly string[]): PolicyAc
38
38
  });
39
39
 
40
40
  /**
41
- * A human. Roles come from the row and are expanded to permissions by policy; scopes stay
42
- * empty because a browser session is not scope-limited — the role set is the limit.
41
+ * A human. Roles come from the row and are expanded to permissions by policy; scopes come from
42
+ * the row too, and they are almost always empty.
43
+ *
44
+ * `scopes: []` used to be hardcoded here, which made a scope a thing no human could ever hold —
45
+ * so `hasScope(actor, 'tenancy:cross')`, whose own reasons name "an admin surface listing every
46
+ * org" and "support tooling", could only ever be satisfied by minting a `serviceActor` inside the
47
+ * handler. That discards the operator's identity and makes the sweep unattributable, which is the
48
+ * exact property the scope's required reason string exists to preserve.
43
49
  *
44
50
  * A session that has not satisfied an enrolled second factor resolves to an actor with no
45
- * roles and no permissions rather than an error, so a half-authenticated request can still
46
- * reach the "finish MFA" route and nothing else. Login throws `X_MFA_REQUIRED` separately.
51
+ * roles, no permissions and no scopes rather than an error, so a half-authenticated request can
52
+ * still reach the "finish MFA" route and nothing else. Login throws `X_MFA_REQUIRED` separately.
47
53
  */
48
54
  export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor {
49
55
  const mfaPending = user.mfaSecret !== null && !session.mfaSatisfied;
@@ -52,7 +58,7 @@ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor
52
58
  id: user.id,
53
59
  orgId: user.orgId ?? undefined,
54
60
  roles: mfaPending ? [] : user.roles,
55
- scopes: [],
61
+ scopes: mfaPending ? [] : user.scopes,
56
62
  }),
57
63
  mfaPending ? [] : user.permissions,
58
64
  );
@@ -0,0 +1,74 @@
1
+ // Single responsibility: changing what a user may do, and rotating the credential that was issued
2
+ // under the old answer. `packages/auth/CLAUDE.md` has listed "rotate the session id on any
3
+ // privilege change (`rotateSession`)" as a non-negotiable since 1.0, and
4
+ // `SessionPolicy.rotateOnPrivilegeChange` has defaulted `true` — while `rotateSession` had no
5
+ // caller anywhere outside its own test and the flag was read by nothing. Per axiom 3 the rule did
6
+ // not exist. This is the caller that makes it exist.
7
+
8
+ import type { AuthUser, UserPatch } from './adapter';
9
+ import type { Auth } from './auth';
10
+ import { authWriteFailed } from './errors';
11
+ import { type IssuedSession, rotateSession, sessionCookie } from './session';
12
+
13
+ /**
14
+ * The fields whose change invalidates whatever the current cookie was issued under. `roles`,
15
+ * `permissions` and `scopes` are what an actor is built from; `passwordHash` is the credential
16
+ * itself; `orgId` moves every tenant-scoped read the session can perform.
17
+ */
18
+ const PRIVILEGE_FIELDS = ['roles', 'permissions', 'scopes', 'orgId', 'passwordHash'] as const;
19
+
20
+ export interface UpdatePrivilegesResult {
21
+ readonly user: AuthUser;
22
+ /**
23
+ * The replacement session, present only when a current session was passed AND a privilege field
24
+ * actually changed. Set `cookie` on the response — the old id is already deleted, so a caller
25
+ * that drops this signs the user out rather than leaving a stale-privilege cookie live.
26
+ */
27
+ readonly session?: IssuedSession | undefined;
28
+ readonly cookie?: string | undefined;
29
+ /** Which of `PRIVILEGE_FIELDS` the patch actually named. Empty means nothing rotated. */
30
+ readonly changed: readonly string[];
31
+ }
32
+
33
+ const changedFields = (patch: UserPatch): readonly string[] =>
34
+ PRIVILEGE_FIELDS.filter((field) => patch[field] !== undefined);
35
+
36
+ /**
37
+ * Apply the patch, then mint a new session id for the caller's own session when the patch touched
38
+ * privilege. Rotation is not about propagation — `authenticate` re-reads the user row on every
39
+ * request, so a revoked role takes effect on the very next one with no token-expiry lag, which is
40
+ * a better property than any claims-in-a-JWT design. It is about fixation: whoever planted or
41
+ * lifted the old cookie before the grant must not inherit the grant with it.
42
+ *
43
+ * `session` is optional because the common caller is an admin changing somebody ELSE's roles, and
44
+ * there is no cookie of theirs to rotate. That case wants `revokeUserSessions()` instead, and the
45
+ * two are deliberately separate calls — silently killing an operator's own session mid-request is
46
+ * not something a role edit should decide on its own.
47
+ */
48
+ export async function updatePrivileges(
49
+ auth: Auth,
50
+ userId: string,
51
+ patch: UserPatch,
52
+ session?: IssuedSession['session'] | undefined,
53
+ ): Promise<UpdatePrivilegesResult> {
54
+ const user = await auth.adapter.updateUser(userId, patch);
55
+ if (user === null) throw authWriteFailed('updateUser', 'x_users');
56
+
57
+ const changed = changedFields(patch);
58
+ if (
59
+ changed.length === 0 ||
60
+ session === undefined ||
61
+ session.userId !== userId ||
62
+ !auth.sessions.policy.rotateOnPrivilegeChange
63
+ ) {
64
+ return { user, changed };
65
+ }
66
+
67
+ const issued = await rotateSession(auth.sessions, session);
68
+ return {
69
+ user,
70
+ changed,
71
+ session: issued,
72
+ cookie: sessionCookie(issued.token, auth.sessions.policy),
73
+ };
74
+ }