@ultimat3/auth 30.9.0 → 31.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 +55 -109
- package/README.md +100 -25
- package/package.json +5 -4
- package/src/adapter-subpath.ts +34 -0
- package/src/api-keys.ts +14 -1
- package/src/auth.ts +3 -3
- package/src/authorization-server-clients.ts +19 -1
- package/src/authorization-server-config.ts +13 -4
- package/src/authorization-server-installed.ts +34 -0
- package/src/authorization-server-postgres.ts +4 -2
- package/src/authorization-server-store.ts +14 -4
- package/src/authorization-server.ts +30 -10
- package/src/client-scope.ts +1 -1
- package/src/errors.ts +9 -1
- package/src/grant-authorization-code.ts +21 -16
- package/src/grant-device.ts +2 -2
- package/src/identity-provider-cookie.ts +89 -0
- package/src/identity-provider-oidc.ts +142 -0
- package/src/identity-provider-route-core.ts +253 -0
- package/src/identity-provider-routes.ts +78 -0
- package/src/identity-provider-status.ts +45 -0
- package/src/identity-provider.ts +112 -0
- package/src/index.ts +34 -54
- package/src/jwks.ts +2 -2
- package/src/kdf-gate.ts +24 -14
- package/src/limiter-install.ts +26 -16
- package/src/memory-adapter.ts +5 -5
- package/src/mfa.ts +1 -1
- package/src/oauth-discovery.ts +1 -1
- package/src/oauth-errors.ts +26 -2
- package/src/oauth-exchange.ts +2 -2
- package/src/oauth-paths.ts +5 -2
- package/src/oauth-route.ts +60 -297
- package/src/rate-limit.ts +7 -2
- package/src/saml-assertion.ts +156 -0
- package/src/saml-c14n.ts +118 -0
- package/src/saml-certificate.ts +47 -0
- package/src/saml-dom.ts +66 -0
- package/src/saml-dsig.ts +165 -0
- package/src/saml-email-trust.ts +49 -0
- package/src/saml-errors.ts +96 -0
- package/src/saml-metadata.ts +80 -0
- package/src/saml-provider.ts +179 -0
- package/src/saml-request.ts +51 -0
- package/src/saml-response.ts +131 -0
- package/src/saml-xml.ts +328 -0
package/src/oauth-route.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Single responsibility:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Single responsibility: `oauthLogin(auth)` — "log in with GitHub" in one call, as a front door
|
|
2
|
+
// over the ONE sign-in route implementation (`identity-provider-route-core.ts`): every registered
|
|
3
|
+
// OAuth provider this app enabled becomes an `oidcIdentityProvider` per request, so OAuth and SAML
|
|
4
|
+
// share the paths (`/auth/sso/:provider`), the sealed state cookie and the refusal body.
|
|
5
5
|
//
|
|
6
6
|
// A route DESCRIPTOR, never a mounted handler, for the reason `mcpHttpRoute()` is one:
|
|
7
7
|
// `@ultimat3/http` is tier 2 like this package, so auth may not import it — and `defineRoute`
|
|
@@ -9,56 +9,28 @@
|
|
|
9
9
|
// a test and mountable by any router that can match a `:param`.
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
EnvMissingError,
|
|
12
|
+
ConfigInvalidError,
|
|
14
13
|
isUltimateError,
|
|
15
14
|
logger,
|
|
16
|
-
renderCauseValue,
|
|
17
|
-
renderFixShellArg,
|
|
18
15
|
renderThrowable,
|
|
19
16
|
type UltimateError,
|
|
20
17
|
} from '@ultimat3/core';
|
|
21
|
-
import type { Auth
|
|
22
|
-
import {
|
|
18
|
+
import type { Auth } from './auth';
|
|
19
|
+
import { oidcIdentityProvider } from './identity-provider-oidc';
|
|
20
|
+
import {
|
|
21
|
+
type SignInRouteDescriptor,
|
|
22
|
+
type SignInRouteMeta,
|
|
23
|
+
signInRoutes,
|
|
24
|
+
} from './identity-provider-route-core';
|
|
23
25
|
import type { IdTokenKeys } from './jwks';
|
|
24
|
-
import {
|
|
26
|
+
import type { OAuthProviderId } from './oauth';
|
|
25
27
|
import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
|
|
26
|
-
import {
|
|
27
|
-
import { oauthExchangeFailed, oauthProviderUnknown, restartAt } from './oauth-errors';
|
|
28
|
+
import { oauthExchangeFailed, oauthProviderUnknown } from './oauth-errors';
|
|
28
29
|
import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
|
|
29
30
|
import { oauthCredentials } from './oauth-exchange';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
OAUTH_CALLBACK_ROUTE_PATH,
|
|
33
|
-
OAUTH_START_ROUTE_PATH,
|
|
34
|
-
oauthCallbackPath,
|
|
35
|
-
} from './oauth-paths';
|
|
31
|
+
import type { ResolveOAuthGrants } from './oauth-login';
|
|
36
32
|
import { hasOAuthProvider } from './oauth-registry';
|
|
37
33
|
|
|
38
|
-
/**
|
|
39
|
-
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
40
|
-
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
41
|
-
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
42
|
-
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
43
|
-
*/
|
|
44
|
-
export const oauthDenied = (
|
|
45
|
-
provider: string,
|
|
46
|
-
reason: string,
|
|
47
|
-
description: string | null,
|
|
48
|
-
): AuthError =>
|
|
49
|
-
new AuthError({
|
|
50
|
-
code: 'X_OAUTH_DENIED',
|
|
51
|
-
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
52
|
-
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
53
|
-
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
54
|
-
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
55
|
-
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
56
|
-
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
57
|
-
}`,
|
|
58
|
-
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
59
|
-
meta: { provider, reason },
|
|
60
|
-
});
|
|
61
|
-
|
|
62
34
|
/**
|
|
63
35
|
* What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
|
|
64
36
|
* `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
|
|
@@ -71,6 +43,8 @@ export interface AuthRouteDescriptor {
|
|
|
71
43
|
readonly name: string;
|
|
72
44
|
/** Both legs are public by definition — they are how an anonymous visitor stops being one. */
|
|
73
45
|
readonly auth: 'public';
|
|
46
|
+
/** What a router mounts as the route's `meta`, verbatim: `meta: leg.meta`. */
|
|
47
|
+
readonly meta: SignInRouteMeta;
|
|
74
48
|
handle(request: Request): Promise<Response>;
|
|
75
49
|
}
|
|
76
50
|
|
|
@@ -126,97 +100,10 @@ export interface OAuthLoginOptions {
|
|
|
126
100
|
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
127
101
|
}
|
|
128
102
|
|
|
129
|
-
/**
|
|
130
|
-
* HTTP status per code, for a descriptor driven OUTSIDE a pipeline — a bare `Request` in, a
|
|
131
|
-
* `Response` out, which is the whole point of a descriptor. `@ultimat3/http`'s `error-map.ts` OWNS
|
|
132
|
-
* these numbers and is where a new one is declared; this package is the same tier and can never
|
|
133
|
-
* import it, so the table is a copy the pin `scripts/oauth-route-status.test.ts` holds identical to
|
|
134
|
-
* `statusFor()`. Everything absent is the provider's fault until proven otherwise: 502.
|
|
135
|
-
*/
|
|
136
|
-
export const OAUTH_ROUTE_STATUS: Readonly<Record<string, number>> = {
|
|
137
|
-
X_OAUTH_PROVIDER_UNKNOWN: 404,
|
|
138
|
-
X_OAUTH_DENIED: 403,
|
|
139
|
-
X_OAUTH_STATE_INVALID: 400,
|
|
140
|
-
X_OAUTH_TOKEN_INVALID: 400,
|
|
141
|
-
X_UNAUTHENTICATED: 401,
|
|
142
|
-
X_MFA_REQUIRED: 401,
|
|
143
|
-
X_ACCOUNT_LOCKED: 429,
|
|
144
|
-
X_ENV_MISSING: 500,
|
|
145
|
-
X_CONFIG_INVALID: 500,
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* The status for a code, read as an OWN key of the table above.
|
|
150
|
-
*
|
|
151
|
-
* `OAUTH_ROUTE_STATUS[code] ?? 502` is an index read on a plain object, so `Object.prototype`
|
|
152
|
-
* answered for `toString`, `constructor`, `hasOwnProperty` and `__proto__` — and `toString`
|
|
153
|
-
* yields a FUNCTION, which was handed straight to `ResponseInit.status`. `code` is a plain
|
|
154
|
-
* `string` on every `UltimateError`, so no cast makes that unreachable. Fourth instance of the
|
|
155
|
-
* class in the framework, after i18n's catalog lookup, schema's `coerce` and mcp's `validate-args`.
|
|
156
|
-
*
|
|
157
|
-
* Exported so a test can drive it without building a `Response`; `OAUTH_ROUTE_STATUS` stays
|
|
158
|
-
* exported unchanged, because `scripts/oauth-route-status.test.ts` pins it against
|
|
159
|
-
* `@ultimat3/http`'s `statusFor`.
|
|
160
|
-
*/
|
|
161
|
-
export function oauthRouteStatus(code: string): number {
|
|
162
|
-
// 502 is the default: an uncoded throw on this path came out of the provider conversation.
|
|
163
|
-
return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** The one sentence every refusal carries outward. The event name is where the rest went. */
|
|
167
|
-
const PUBLIC_CAUSE =
|
|
168
|
-
'the sign-in was refused; its cause and its fix are in this server\u2019s log under auth.oauth.refused';
|
|
169
|
-
|
|
170
|
-
/** A code this framework would have minted — the only kind spliced into a command. */
|
|
171
|
-
const FRAMEWORK_CODE = /^X_[A-Z0-9_]+$/;
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* What an anonymous caller is allowed to read: the code, its registered title and docs link, and
|
|
175
|
-
* one fixed sentence. Both legs are public by definition, and an authored `cause`/`fix` is written
|
|
176
|
-
* for the developer — it names env vars, endpoints and what a provider answered — so neither is
|
|
177
|
-
* served, on any code: no per-code judgement about which sentence is safe today.
|
|
178
|
-
*/
|
|
179
|
-
const publicBody = (coded: UltimateError): Record<string, string> => ({
|
|
180
|
-
code: coded.code,
|
|
181
|
-
title: coded.title,
|
|
182
|
-
cause: PUBLIC_CAUSE,
|
|
183
|
-
fix: FRAMEWORK_CODE.test(coded.code)
|
|
184
|
-
? `x errors explain ${renderFixShellArg(coded.code, '<CODE>')} --json`
|
|
185
|
-
: 'x errors list --json',
|
|
186
|
-
docs: coded.docs,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Failure is JSON, never a redirect to a login page carrying `?error=`: the status and the code
|
|
191
|
-
* are the answer a client branches on. The developer's half — the cause and the fix line — is
|
|
192
|
-
* logged as `auth.oauth.refused`, where only an operator reads it. An app that wants a rendered
|
|
193
|
-
* page wraps these two descriptors.
|
|
194
|
-
*/
|
|
195
|
-
function problem(error: unknown, extraCookies: readonly string[]): Response {
|
|
196
|
-
const coded = isUltimateError(error) ? error : uncoded(error);
|
|
197
|
-
const status = oauthRouteStatus(coded.code);
|
|
198
|
-
const fields = { code: coded.code, status, cause: coded.cause, fix: coded.fix };
|
|
199
|
-
if (status >= 500) logger.error('auth.oauth.refused', fields);
|
|
200
|
-
else logger.info('auth.oauth.refused', fields);
|
|
201
|
-
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
|
|
202
|
-
for (const cookie of extraCookies) headers.append('set-cookie', cookie);
|
|
203
|
-
return new Response(JSON.stringify(publicBody(coded)), { status, headers });
|
|
204
|
-
}
|
|
205
|
-
|
|
206
103
|
/**
|
|
207
104
|
* A throw with no code: a bug in an `AuthAdapter` or an `OAuthFetch` this package does not own.
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* rendered value went straight into it — so a bare
|
|
211
|
-
* `Error('connect ECONNREFUSED 10.4.2.17:5432 (postgres://app:hunter2@db.internal/prod)')` from a
|
|
212
|
-
* pool inside a callback was served to whoever typed the URL, as a 502 with the connection string
|
|
213
|
-
* in the body. The comment above `publicBody` says a stack and `meta` must never reach that
|
|
214
|
-
* reader; the field carrying the message was the one exception nobody meant to make.
|
|
215
|
-
*
|
|
216
|
-
* `renderThrowable`, never `error.message`: the value came from code this package does not own,
|
|
217
|
-
* and a getter on `message` — or a `Proxy` trapping `getPrototypeOf` — would make the callback's
|
|
218
|
-
* last answer throw instead of send. Same shape `rate-limit.ts`'s `loginFailed()` uses: one fixed
|
|
219
|
-
* sentence outward, the discriminating detail kept where only an operator can read it.
|
|
105
|
+
* Its text goes to the LOG and never into the response — a pool error carrying a connection
|
|
106
|
+
* string was once served to whoever typed the URL. `renderThrowable`, never `error.message`.
|
|
220
107
|
*/
|
|
221
108
|
function uncoded(error: unknown): UltimateError {
|
|
222
109
|
logger.error('auth.oauth.uncoded_failure', { detail: renderThrowable(error) });
|
|
@@ -230,45 +117,12 @@ function uncoded(error: unknown): UltimateError {
|
|
|
230
117
|
});
|
|
231
118
|
}
|
|
232
119
|
|
|
233
|
-
/** `/auth/oauth/github` → `github`; `/auth/oauth/github/callback` → `github`. */
|
|
234
|
-
function providerSegment(request: Request, leg: 'start' | 'callback'): string {
|
|
235
|
-
const segments = new URL(request.url).pathname.split('/').filter((s) => s.length > 0);
|
|
236
|
-
const index = leg === 'start' ? segments.length - 1 : segments.length - 2;
|
|
237
|
-
return segments[index] ?? '';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** The app's answer for this provider, or nothing — so `undefined` falls through to the default. */
|
|
241
|
-
function idTokenKeysFor(
|
|
242
|
-
options: OAuthLoginOptions,
|
|
243
|
-
provider: OAuthProviderId,
|
|
244
|
-
): { readonly idTokenKeys?: IdTokenKeys } {
|
|
245
|
-
const keys = options.idTokenKeys?.(provider);
|
|
246
|
-
return keys === undefined ? {} : { idTokenKeys: keys };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
120
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* anonymous caller which of the two it hit describes the app's configuration for free.
|
|
253
|
-
*
|
|
254
|
-
* The list in the refusal is the BUILT-INS (`BUILTIN_OAUTH_PROVIDER_IDS`), never the live
|
|
255
|
-
* registry and never `defineAuth({ providers })`. Both of the latter are this deployment's own configuration, and
|
|
256
|
-
* this caller is an anonymous stranger who typed a URL: an app that registered an internal OP has
|
|
257
|
-
* put its own vocabulary into the registry, and echoing it back names a system the stranger had no
|
|
258
|
-
* way to know exists. The built-in list is a framework constant already in the public docs, so it
|
|
259
|
-
* discloses nothing while still making the fix executable — and `registerOAuthProvider` in the
|
|
260
|
-
* same sentence covers the other branch without enumerating anything.
|
|
261
|
-
*
|
|
262
|
-
* `providerFor()` keeps the full registered list for the same reason in reverse: its reader is a
|
|
263
|
-
* developer holding a stack trace, and there the list is exactly what makes the fix runnable.
|
|
121
|
+
* Unknown and not-enabled are one 404, listing the BUILT-INS only: the caller is an anonymous
|
|
122
|
+
* stranger, and the live registry is this deployment's own vocabulary.
|
|
264
123
|
*/
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (!hasOAuthProvider(segment)) throw oauthProviderUnknown(segment, supported);
|
|
268
|
-
const provider: OAuthProviderId = segment;
|
|
269
|
-
if (!auth.providers.includes(provider)) throw oauthProviderUnknown(segment, supported);
|
|
270
|
-
return provider;
|
|
271
|
-
}
|
|
124
|
+
const unknown = (segment: string): UltimateError =>
|
|
125
|
+
oauthProviderUnknown(segment, BUILTIN_OAUTH_PROVIDER_IDS);
|
|
272
126
|
|
|
273
127
|
/**
|
|
274
128
|
* The credentials for a provider, or the SAME 404 an unknown provider gets.
|
|
@@ -297,145 +151,54 @@ function credentialsFor(
|
|
|
297
151
|
cause: error.cause,
|
|
298
152
|
fix: error.fix,
|
|
299
153
|
});
|
|
300
|
-
throw
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* The declared origin, or a refusal. The request's own origin is the `Host` header, so a
|
|
306
|
-
* `redirect_uri` built from it is one the caller chose.
|
|
307
|
-
*/
|
|
308
|
-
function declaredOrigin(options: OAuthLoginOptions): string {
|
|
309
|
-
const env = options.env ?? Bun.env;
|
|
310
|
-
const declared = (options.baseUrl ?? env['APP_URL'] ?? '').trim();
|
|
311
|
-
if (declared === '') {
|
|
312
|
-
throw new EnvMissingError({
|
|
313
|
-
cause:
|
|
314
|
-
'APP_URL is not set and oauthLogin() was given no baseUrl, so there is no declared origin to build the provider redirect_uri from',
|
|
315
|
-
fix: 'export APP_URL=http://localhost:3000 # the origin this app is served from',
|
|
316
|
-
meta: { key: 'APP_URL' },
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
return declared.replace(/\/+$/, '');
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** The handshake's own options, assembled once so both legs seal and open it identically. */
|
|
323
|
-
const sealOptions = (
|
|
324
|
-
clock: Clock,
|
|
325
|
-
options: OAuthLoginOptions,
|
|
326
|
-
): { clock: Clock; secret?: string | undefined } => ({
|
|
327
|
-
clock,
|
|
328
|
-
...(options.secret === undefined ? {} : { secret: options.secret }),
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
async function startHandler(
|
|
332
|
-
auth: Auth,
|
|
333
|
-
options: OAuthLoginOptions,
|
|
334
|
-
request: Request,
|
|
335
|
-
): Promise<Response> {
|
|
336
|
-
try {
|
|
337
|
-
// Before the provider is looked at: asked after, a missing origin answered 500 for a provider
|
|
338
|
-
// this app enabled and 404 for one it did not.
|
|
339
|
-
const origin = declaredOrigin(options);
|
|
340
|
-
const provider = assertEnabled(auth, providerSegment(request, 'start'));
|
|
341
|
-
const credentials = credentialsFor(provider, options);
|
|
342
|
-
const handshake = beginOAuth({
|
|
343
|
-
provider,
|
|
344
|
-
clientId: credentials.clientId,
|
|
345
|
-
redirectUri: `${origin}${oauthCallbackPath(provider)}`,
|
|
346
|
-
scopes: options.scopes,
|
|
347
|
-
});
|
|
348
|
-
return new Response(null, {
|
|
349
|
-
// 302, the status every OAuth client already expects on this hop.
|
|
350
|
-
status: 302,
|
|
351
|
-
headers: {
|
|
352
|
-
location: handshake.authorizeUrl,
|
|
353
|
-
'set-cookie': handshakeCookie(handshake, sealOptions(auth.clock, options)),
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
} catch (error) {
|
|
357
|
-
return problem(error, []);
|
|
154
|
+
throw unknown(provider);
|
|
358
155
|
}
|
|
359
156
|
}
|
|
360
157
|
|
|
361
|
-
/**
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (declined === null || declined === '') return;
|
|
365
|
-
throw oauthDenied(provider, declined, url.searchParams.get('error_description'));
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
async function callbackHandler(
|
|
369
|
-
auth: Auth,
|
|
370
|
-
options: OAuthLoginOptions,
|
|
371
|
-
request: Request,
|
|
372
|
-
): Promise<Response> {
|
|
373
|
-
const segment = providerSegment(request, 'callback');
|
|
374
|
-
// Cleared on every outcome, success and failure alike: the code it authorised is spent either
|
|
375
|
-
// way, so a handshake that outlives its own callback is a replay window and nothing else.
|
|
376
|
-
const clear = hasOAuthProvider(segment) ? [clearHandshakeCookie(segment)] : [];
|
|
377
|
-
try {
|
|
378
|
-
const provider = assertEnabled(auth, segment);
|
|
379
|
-
// A provider with no credentials answers 404 on THIS leg too: one oracle, closed on both halves
|
|
380
|
-
// — a 404 on start and a 500 on callback is the same enumeration one request later. And the
|
|
381
|
-
// answer is what the exchange uses, or `options.env` would answer the start leg alone.
|
|
382
|
-
const credentials = credentialsFor(provider, options);
|
|
383
|
-
const url = new URL(request.url);
|
|
384
|
-
assertNoProviderError(url, provider);
|
|
385
|
-
const result: LoginResult = await completeOAuthLogin(auth, {
|
|
386
|
-
handshake: readHandshakeCookie(request, provider, sealOptions(auth.clock, options)),
|
|
387
|
-
callback: {
|
|
388
|
-
state: url.searchParams.get('state') ?? '',
|
|
389
|
-
code: url.searchParams.get('code') ?? '',
|
|
390
|
-
},
|
|
391
|
-
credentials,
|
|
392
|
-
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
393
|
-
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
394
|
-
...(options.resolveGrants === undefined ? {} : { resolveGrants: options.resolveGrants }),
|
|
395
|
-
...idTokenKeysFor(options, provider),
|
|
396
|
-
ip: options.clientIp?.(request) ?? null,
|
|
397
|
-
userAgent: request.headers.get('user-agent'),
|
|
398
|
-
});
|
|
399
|
-
const headers = new Headers({
|
|
400
|
-
// A fixed path, never `?next=`: an attacker-supplied return target on the one endpoint whose
|
|
401
|
-
// job is to hand out a session is the classic open redirect, and `@ultimat3/http`'s
|
|
402
|
-
// `nextAfterSignIn` is the one implementation of that check. Two copies is one that drifts.
|
|
403
|
-
location: options.successPath ?? '/',
|
|
404
|
-
});
|
|
405
|
-
// 303: the callback may arrive as a `form_post`, and the destination is a GET either way.
|
|
406
|
-
headers.append('set-cookie', result.cookie);
|
|
407
|
-
for (const cookie of clear) headers.append('set-cookie', cookie);
|
|
408
|
-
return new Response(null, { status: 303, headers });
|
|
409
|
-
} catch (error) {
|
|
410
|
-
return problem(error, clear);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
158
|
+
/** A descriptor of a GET leg, narrowed to the `AuthRouteDescriptor` the type has always promised. */
|
|
159
|
+
const asGet = (descriptor: SignInRouteDescriptor): AuthRouteDescriptor =>
|
|
160
|
+
Object.freeze({ ...descriptor, method: 'GET' as const });
|
|
413
161
|
|
|
414
162
|
/**
|
|
415
163
|
* The two routes. Mount them and "log in with GitHub" is a button pointing at
|
|
416
|
-
* `/auth/
|
|
164
|
+
* `/auth/sso/github` — no handshake store, no PKCE bookkeeping, no state check to forget.
|
|
417
165
|
*
|
|
418
166
|
* ```ts
|
|
419
167
|
* const { start, callback } = oauthLogin(auth);
|
|
420
|
-
* // start.path → '/auth/
|
|
421
|
-
* // callback.path → '/auth/
|
|
168
|
+
* // start.path → '/auth/sso/:provider'
|
|
169
|
+
* // callback.path → '/auth/sso/:provider/callback'
|
|
422
170
|
* ```
|
|
423
171
|
*/
|
|
424
172
|
export function oauthLogin(auth: Auth, options: OAuthLoginOptions = {}): OAuthLoginRoutes {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
173
|
+
const routes = signInRoutes(auth, options, {
|
|
174
|
+
resolve(segment, leg) {
|
|
175
|
+
if (!hasOAuthProvider(segment) || !auth.providers.includes(segment)) throw unknown(segment);
|
|
176
|
+
// Asked on the callback only: the start leg verifies nothing.
|
|
177
|
+
const keys = leg === 'callback' ? options.idTokenKeys?.(segment) : undefined;
|
|
178
|
+
return oidcIdentityProvider({
|
|
179
|
+
provider: segment,
|
|
180
|
+
credentials: credentialsFor(segment, options),
|
|
181
|
+
scopes: options.scopes,
|
|
182
|
+
fetch: options.fetch,
|
|
183
|
+
timeoutMs: options.timeoutMs,
|
|
184
|
+
...(keys === undefined ? {} : { idTokenKeys: keys }),
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
unknown,
|
|
188
|
+
event: 'auth.oauth',
|
|
189
|
+
names: { start: 'auth.oauth.start', callback: 'auth.oauth.callback', acs: 'auth.oauth.acs' },
|
|
190
|
+
uncoded,
|
|
191
|
+
grants(identity) {
|
|
192
|
+
const resolve = options.resolveGrants;
|
|
193
|
+
if (resolve === undefined) return undefined;
|
|
194
|
+
if (identity.oauth === undefined) {
|
|
195
|
+
throw new ConfigInvalidError({
|
|
196
|
+
cause: `${identity.profile.provider} returned no OAuth grant context, so resolveGrants(profile, context) cannot be called`,
|
|
197
|
+
fix: 'identityProviderRoutes(auth, { providers, resolveGrants: (identity) => ({ roles }) }) // for a provider that is not OAuth',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return resolve(identity.profile, identity.oauth);
|
|
201
|
+
},
|
|
440
202
|
});
|
|
203
|
+
return Object.freeze({ start: asGet(routes.start), callback: asGet(routes.callback) });
|
|
441
204
|
}
|
package/src/rate-limit.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// many accounts) and per account (stops a spray against one) — because either alone is
|
|
4
4
|
// bypassable. `loginFailed()` lives here so the throttle and the message can never drift apart.
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { addressNetwork, type Clock } from '@ultimat3/core';
|
|
7
7
|
import { normaliseEmail } from './email';
|
|
8
8
|
import {
|
|
9
9
|
AuthError,
|
|
@@ -127,7 +127,12 @@ export interface MemoryAuthLimiter extends AuthLimiter {
|
|
|
127
127
|
*/
|
|
128
128
|
export const accountKey = (email: string): string => `account:${normaliseEmail(email)}`;
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
/**
|
|
131
|
+
* The bucket counts a NETWORK, by core's `addressNetwork`: IPv4 whole, an IPv4-mapped IPv6 as that
|
|
132
|
+
* IPv4, any other IPv6 as its /64. One subscriber is handed a /64, so keying on the full address
|
|
133
|
+
* gave every holder 2^64 fresh budgets. Text that is no address literal stays as it is.
|
|
134
|
+
*/
|
|
135
|
+
export const ipKey = (ip: string): string => `ip:${addressNetwork(ip)}`;
|
|
131
136
|
|
|
132
137
|
/**
|
|
133
138
|
* The tenant bucket's key shape, declared here even though the general noisy-neighbour case lives
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Single responsibility: what a VERIFIED assertion says, checked — issuer, the bearer subject
|
|
2
|
+
// confirmation (recipient, InResponseTo, its window), the conditions (window, audience) — and read
|
|
3
|
+
// out as one `SamlAssertion`. It only ever sees the node whose signature `saml-response.ts` just
|
|
4
|
+
// verified: it is handed that node, never a document to search.
|
|
5
|
+
|
|
6
|
+
import { attribute, BEARER, childElements, NAMEID_TRANSIENT, SAML_NS, textOf } from './saml-dom';
|
|
7
|
+
import type { SamlCheck } from './saml-errors';
|
|
8
|
+
import type { XmlElement } from './saml-xml';
|
|
9
|
+
|
|
10
|
+
export interface SamlAssertion {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly issuer: string;
|
|
13
|
+
readonly nameId: string;
|
|
14
|
+
readonly nameIdFormat: string | null;
|
|
15
|
+
/** Every Attribute by `Name`, values in document order. */
|
|
16
|
+
readonly attributes: Readonly<Record<string, readonly string[]>>;
|
|
17
|
+
/** The last instant this assertion is acceptable: the earlier of the two NotOnOrAfter. */
|
|
18
|
+
readonly notOnOrAfter: Date;
|
|
19
|
+
/** `SessionNotOnOrAfter`, when the IdP bounds the session. */
|
|
20
|
+
readonly sessionNotOnOrAfter: Date | null;
|
|
21
|
+
readonly sessionIndex: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AssertionExpectations {
|
|
25
|
+
readonly issuer: string;
|
|
26
|
+
readonly audience: string;
|
|
27
|
+
readonly recipient: string;
|
|
28
|
+
readonly requestId: string;
|
|
29
|
+
readonly now: Date;
|
|
30
|
+
readonly skewMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AssertionRefusal {
|
|
34
|
+
constructor(
|
|
35
|
+
readonly check: SamlCheck,
|
|
36
|
+
readonly detail: string,
|
|
37
|
+
) {}
|
|
38
|
+
}
|
|
39
|
+
const refuse = (check: SamlCheck, detail: string): never => {
|
|
40
|
+
throw new AssertionRefusal(check, detail);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** xs:dateTime, which SAML requires in UTC; anything unparseable is absent-and-wrong. */
|
|
44
|
+
const instant = (value: string | undefined): Date | null => {
|
|
45
|
+
if (value === undefined || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value))
|
|
46
|
+
return null;
|
|
47
|
+
const parsed = new Date(value);
|
|
48
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const one = (parent: XmlElement, local: string, check: SamlCheck): XmlElement => {
|
|
52
|
+
const found = childElements(parent, SAML_NS, local);
|
|
53
|
+
const first = found[0];
|
|
54
|
+
return found.length === 1 && first !== undefined
|
|
55
|
+
? first
|
|
56
|
+
: refuse(check, `expected one saml:${local}`);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const leaf = (element: XmlElement, check: SamlCheck): string =>
|
|
60
|
+
textOf(element) ?? refuse(check, `saml:${element.local} holds an element`);
|
|
61
|
+
|
|
62
|
+
function window(element: XmlElement, expect: AssertionExpectations, required: boolean): Date {
|
|
63
|
+
const now = expect.now.getTime();
|
|
64
|
+
const notBefore = attribute(element, 'NotBefore');
|
|
65
|
+
if (notBefore !== undefined) {
|
|
66
|
+
const at = instant(notBefore) ?? refuse('not-before', 'NotBefore is not a UTC instant');
|
|
67
|
+
if (at.getTime() - expect.skewMs > now)
|
|
68
|
+
refuse('not-before', `${element.local} is not yet valid`);
|
|
69
|
+
}
|
|
70
|
+
const raw = attribute(element, 'NotOnOrAfter');
|
|
71
|
+
if (raw === undefined && !required) return new Date(Number.POSITIVE_INFINITY);
|
|
72
|
+
const end = instant(raw) ?? refuse('expired', `${element.local} has no UTC NotOnOrAfter`);
|
|
73
|
+
if (end.getTime() + expect.skewMs <= now) refuse('expired', `${element.local} has expired`);
|
|
74
|
+
return end;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** At least one bearer confirmation must hold — SAML Profiles §4.1.4.2. */
|
|
78
|
+
function confirmation(subject: XmlElement, expect: AssertionExpectations): Date {
|
|
79
|
+
let last: AssertionRefusal = new AssertionRefusal('subject', 'no bearer SubjectConfirmation');
|
|
80
|
+
for (const confirmation of childElements(subject, SAML_NS, 'SubjectConfirmation')) {
|
|
81
|
+
if (attribute(confirmation, 'Method') !== BEARER) continue;
|
|
82
|
+
try {
|
|
83
|
+
const data = one(confirmation, 'SubjectConfirmationData', 'subject');
|
|
84
|
+
if (attribute(data, 'Recipient') !== expect.recipient) {
|
|
85
|
+
refuse('recipient', 'the Recipient is not this ACS URL');
|
|
86
|
+
}
|
|
87
|
+
if (attribute(data, 'InResponseTo') !== expect.requestId) {
|
|
88
|
+
refuse('in-response-to', 'the confirmation answers another request');
|
|
89
|
+
}
|
|
90
|
+
return window(data, expect, true);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!(error instanceof AssertionRefusal)) throw error;
|
|
93
|
+
last = error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw last;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function conditions(assertion: XmlElement, expect: AssertionExpectations): Date {
|
|
100
|
+
const element = one(assertion, 'Conditions', 'audience');
|
|
101
|
+
const end = window(element, expect, false);
|
|
102
|
+
const restrictions = childElements(element, SAML_NS, 'AudienceRestriction');
|
|
103
|
+
if (restrictions.length === 0) refuse('audience', 'the assertion names no audience');
|
|
104
|
+
for (const restriction of restrictions) {
|
|
105
|
+
const audiences = childElements(restriction, SAML_NS, 'Audience').map((a) =>
|
|
106
|
+
leaf(a, 'audience'),
|
|
107
|
+
);
|
|
108
|
+
if (!audiences.includes(expect.audience)) refuse('audience', 'this SP is not an audience');
|
|
109
|
+
}
|
|
110
|
+
return end;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function attributes(assertion: XmlElement): Record<string, string[]> {
|
|
114
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
115
|
+
for (const statement of childElements(assertion, SAML_NS, 'AttributeStatement')) {
|
|
116
|
+
for (const item of childElements(statement, SAML_NS, 'Attribute')) {
|
|
117
|
+
const name = attribute(item, 'Name');
|
|
118
|
+
if (name === undefined) continue;
|
|
119
|
+
const values = childElements(item, SAML_NS, 'AttributeValue').map((v) => leaf(v, 'subject'));
|
|
120
|
+
out[name] = [...(out[name] ?? []), ...values];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Check and read. Throws `AssertionRefusal`, which the caller turns into the coded error. */
|
|
127
|
+
export function readAssertion(assertion: XmlElement, expect: AssertionExpectations): SamlAssertion {
|
|
128
|
+
const issuer = leaf(one(assertion, 'Issuer', 'issuer'), 'issuer');
|
|
129
|
+
if (issuer !== expect.issuer)
|
|
130
|
+
refuse('issuer', 'the assertion Issuer is not the metadata entityID');
|
|
131
|
+
const subject = one(assertion, 'Subject', 'subject');
|
|
132
|
+
const nameIdElement = one(subject, 'NameID', 'subject');
|
|
133
|
+
const nameId = leaf(nameIdElement, 'subject').trim();
|
|
134
|
+
const nameIdFormat = attribute(nameIdElement, 'Format') ?? null;
|
|
135
|
+
if (nameId === '') refuse('subject', 'the NameID is empty');
|
|
136
|
+
// A transient NameID is a new id per login: linked by it, every sign-in is a stranger.
|
|
137
|
+
if (nameIdFormat === NAMEID_TRANSIENT) refuse('subject', 'the NameID is transient');
|
|
138
|
+
const confirmed = confirmation(subject, expect);
|
|
139
|
+
const conditioned = conditions(assertion, expect);
|
|
140
|
+
const authn = childElements(assertion, SAML_NS, 'AuthnStatement');
|
|
141
|
+
const statement = authn[0] ?? refuse('structure', 'the assertion carries no AuthnStatement');
|
|
142
|
+
const session = attribute(statement, 'SessionNotOnOrAfter');
|
|
143
|
+
return {
|
|
144
|
+
id: attribute(assertion, 'ID') ?? '',
|
|
145
|
+
issuer,
|
|
146
|
+
nameId,
|
|
147
|
+
nameIdFormat,
|
|
148
|
+
attributes: attributes(assertion),
|
|
149
|
+
notOnOrAfter: new Date(Math.min(confirmed.getTime(), conditioned.getTime())),
|
|
150
|
+
sessionNotOnOrAfter:
|
|
151
|
+
session === undefined
|
|
152
|
+
? null
|
|
153
|
+
: (instant(session) ?? refuse('expired', 'bad SessionNotOnOrAfter')),
|
|
154
|
+
sessionIndex: attribute(statement, 'SessionIndex') ?? null,
|
|
155
|
+
};
|
|
156
|
+
}
|