@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.
- package/CLAUDE.md +275 -0
- package/README.md +438 -35
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +122 -18
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +239 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +109 -13
- package/src/json.ts +33 -0
- package/src/jwks.ts +246 -0
- package/src/kdf-gate.ts +86 -0
- package/src/memory-adapter.ts +66 -4
- package/src/mfa.ts +104 -9
- package/src/oauth-builtins.ts +77 -0
- package/src/oauth-cookie.ts +4 -3
- package/src/oauth-discovery.ts +132 -0
- package/src/oauth-exchange.ts +40 -18
- package/src/oauth-login-fixture.ts +53 -0
- package/src/oauth-login.ts +111 -14
- package/src/oauth-paths.ts +20 -0
- package/src/oauth-profile.ts +9 -10
- package/src/oauth-registry.ts +65 -0
- package/src/oauth-route.ts +293 -0
- package/src/oauth.ts +31 -58
- package/src/password.ts +65 -13
- package/src/policy-bridge.ts +11 -5
- package/src/privileges.ts +74 -0
- package/src/rate-limit.ts +178 -15
- package/src/revocation.ts +100 -0
- package/src/session.ts +33 -4
- package/src/tables.ts +18 -2
- package/src/tokens.ts +26 -17
- package/src/verify.ts +12 -5
- package/src/workload.ts +131 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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(
|
|
164
|
+
return { ok: true, needsRehash: needsRehash(hash, params) };
|
|
113
165
|
}
|
|
114
166
|
|
|
115
167
|
export interface StrengthOptions {
|
package/src/policy-bridge.ts
CHANGED
|
@@ -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
|
|
42
|
-
*
|
|
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
|
|
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
|
+
}
|