@udibo/oauth2 0.25.0 → 0.27.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.
@@ -19,6 +19,7 @@ import { sanitizeProviderText } from "../utils/text.js";
19
19
  import { assertField, assertTokenResponse, errorFromResponse, receiveJson, sendGuarded, } from "./_http.js";
20
20
  import { mergedHeaders, OAuth2ClientBase, SIGNED_OUT, } from "./base.js";
21
21
  import { SessionStorageAuthRequestStorage } from "./browser-storage.js";
22
+ import { issuerRefusal } from "./_issuer-refusal.js";
22
23
  import { DEFAULT_DISCOVERY_TTL_MS, } from "./discovery-cache.js";
23
24
  import { MemoryAuthRequestStorage, MemoryRefreshTokenStorage, MemoryTokenStorage, } from "./storage.js";
24
25
  const REFRESH_SKEW_MS = 30_000;
@@ -383,13 +384,20 @@ export class DirectClient extends OAuth2ClientBase {
383
384
  * than redeeming the single-use code twice. A call after it settles redeems
384
385
  * again, and fails.
385
386
  *
387
+ * The response's `iss` is checked first, error responses included, as
388
+ * {@link verifyAuthorizationResponseIssuer} describes. A refused response
389
+ * is never redeemed.
390
+ *
386
391
  * @param input The callback URL, its query string, or the parsed params.
392
+ * @param options The `origin` given to {@link login}, when it retargeted
393
+ * the authorize URL, so the issuer at that origin is accepted as `iss`.
387
394
  * @returns The persisted tokens and the `returnTo` recorded at login.
388
395
  * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
389
396
  * reached or its response does not arrive in full before the deadline;
390
397
  * `cause` is the transport failure.
391
398
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
392
399
  * exchange fails.
400
+ * @throws {InvalidRequestError} when the response's `iss` is refused.
393
401
  * @throws {TypeError} when `code` or `state` is missing, or outside a
394
402
  * browser when the client has no `authRequestStorage` or no
395
403
  * `refreshTokenStorage`; checked before the pending request is claimed.
@@ -406,22 +414,12 @@ export class DirectClient extends OAuth2ClientBase {
406
414
  * location.replace(returnTo ?? "/");
407
415
  * ```
408
416
  */
409
- handleAuthorizationCallback(input) {
417
+ handleAuthorizationCallback(input, options = {}) {
410
418
  const params = toSearchParams(input);
419
+ const { origin } = options;
411
420
  const error = params.get("error");
412
- if (error) {
413
- const description = params.get("error_description") ?? undefined;
414
- const errorUri = params.get("error_uri") ?? undefined;
415
- const instance = new OAuth2ErrorClass(description ?? "authorization error", {
416
- extensions: {
417
- error,
418
- error_description: description,
419
- error_uri: errorUri,
420
- },
421
- });
422
- this.emit({ type: "error", error: instance });
423
- return Promise.reject(instance);
424
- }
421
+ if (error)
422
+ return this.#rejectAuthorizationError(error, params, origin);
425
423
  try {
426
424
  this.#requireAuthRequestStorage();
427
425
  this.#requireRefreshTokenStorage();
@@ -439,19 +437,91 @@ export class DirectClient extends OAuth2ClientBase {
439
437
  const existing = this.#callbackPromises.get(code);
440
438
  if (existing)
441
439
  return existing;
442
- const promise = this.#exchangeCode(code, state);
440
+ const promise = this.#exchangeCode(code, state, params, origin);
443
441
  this.#callbackPromises.set(code, promise);
444
442
  const cleanup = () => queueMicrotask(() => this.#callbackPromises.delete(code));
445
443
  promise.then(cleanup, cleanup);
446
444
  return promise;
447
445
  }
448
- async #exchangeCode(code, state) {
446
+ async #rejectAuthorizationError(code, params, origin) {
447
+ const error = (await this.#issuerRefusal(params, origin)) ??
448
+ authorizationResponseError(code, params);
449
+ this.emit({ type: "error", error });
450
+ throw error;
451
+ }
452
+ async #exchangeCode(code, state, params, origin) {
449
453
  const version = ++this.#sessionVersion;
454
+ const refusal = await this.#issuerRefusal(params, origin);
455
+ if (refusal) {
456
+ this.emit({ type: "error", error: refusal });
457
+ throw refusal;
458
+ }
450
459
  const result = await this.exchangeAuthorizationCode(code, state);
451
460
  const tokens = await this.#saveTokens(result.raw, version);
452
461
  this.emit({ type: "authenticated", tokens });
453
462
  return { tokens, returnTo: result.returnTo };
454
463
  }
464
+ /**
465
+ * Checks an authorization response's `iss` parameter (RFC 9207), which
466
+ * tells a client that signs in with more than one authorization server
467
+ * which of them answered. {@link handleAuthorizationCallback} calls it; a
468
+ * server that completes the flow with {@link exchangeAuthorizationCode}
469
+ * calls it first, on every response, error responses included.
470
+ *
471
+ * For a client configured with an `issuer`, it refuses an `iss` that is
472
+ * not exactly the discovered issuer, an `iss` given more than once, and a
473
+ * missing `iss` when the metadata advertises
474
+ * `authorization_response_iss_parameter_supported: true`. A missing `iss`
475
+ * is accepted from a server that does not advertise it. A client
476
+ * configured with `endpoints` has no issuer to compare against, so it
477
+ * accepts every response.
478
+ *
479
+ * @param input The callback URL, its query string, or the parsed params.
480
+ * @param options The `origin` the authorize URL was retargeted at, if any.
481
+ * @throws {InvalidRequestError} when the response is refused.
482
+ * @throws {ServerError} when discovery fails.
483
+ * @throws {TemporarilyUnavailableError} when the discovery endpoint cannot
484
+ * be reached.
485
+ *
486
+ * @example
487
+ * ```ts
488
+ * import { DirectClient } from "@udibo/oauth2/client";
489
+ *
490
+ * declare const client: DirectClient;
491
+ * declare const callbackUrl: string;
492
+ * await client.verifyAuthorizationResponseIssuer(callbackUrl);
493
+ * ```
494
+ */
495
+ async verifyAuthorizationResponseIssuer(input, options = {}) {
496
+ const refusal = await this.#issuerRefusal(toSearchParams(input), options.origin);
497
+ if (refusal)
498
+ throw refusal;
499
+ }
500
+ async #issuerRefusal(params, origin) {
501
+ if (!this.#issuer)
502
+ return undefined;
503
+ const metadata = await this.discover();
504
+ const received = params.getAll("iss");
505
+ if (received.length > 1) {
506
+ return issuerRefusal("the authorization response carries more than one `iss` (RFC 9207)");
507
+ }
508
+ const [iss] = received;
509
+ if (iss === undefined) {
510
+ if (metadata.authorization_response_iss_parameter_supported !== true) {
511
+ return undefined;
512
+ }
513
+ return issuerRefusal("the authorization response is missing `iss`, which the issuer " +
514
+ "advertises with `authorization_response_iss_parameter_supported` " +
515
+ "(RFC 9207 §2.4)");
516
+ }
517
+ const accepted = [metadata.issuer];
518
+ if (origin)
519
+ accepted.push(issuerAtOrigin(metadata.issuer, origin));
520
+ if (accepted.includes(iss))
521
+ return undefined;
522
+ return issuerRefusal("the authorization response `iss` does not match the issuer the " +
523
+ "request was sent to (RFC 9207 §2.4)");
524
+ }
455
525
  /**
456
526
  * **Advanced (BFF / multi-session servers).** SPA, mobile, and CLI consumers
457
527
  * should call {@link handleAuthorizationCallback} instead — that variant
@@ -470,7 +540,8 @@ export class DirectClient extends OAuth2ClientBase {
470
540
  * two callbacks racing on one `state` reach the token endpoint at most once
471
541
  * (atomically so for a storage that implements
472
542
  * {@link AuthRequestStorage.take}). Emits nothing — the caller owns session
473
- * semantics.
543
+ * semantics. It does not see the response's `iss`: call
544
+ * {@link verifyAuthorizationResponseIssuer} before it.
474
545
  *
475
546
  * @param code The authorization code from the callback.
476
547
  * @param state The `state` the callback echoed back.
@@ -1249,6 +1320,19 @@ function metadataToEndpoints(meta) {
1249
1320
  out.endSession = meta.end_session_endpoint;
1250
1321
  return out;
1251
1322
  }
1323
+ function authorizationResponseError(error, params) {
1324
+ const description = params.get("error_description") ?? undefined;
1325
+ return new OAuth2ErrorClass(description ?? "authorization error", {
1326
+ extensions: {
1327
+ error,
1328
+ error_description: description,
1329
+ error_uri: params.get("error_uri") ?? undefined,
1330
+ },
1331
+ });
1332
+ }
1333
+ function issuerAtOrigin(issuer, origin) {
1334
+ return `${new URL(origin).origin}${issuer.slice(new URL(issuer).origin.length)}`;
1335
+ }
1252
1336
  function toSearchParams(input) {
1253
1337
  if (input instanceof URLSearchParams)
1254
1338
  return input;
@@ -27,7 +27,7 @@
27
27
  */
28
28
  export { type BaseLoginOptions, type BaseOptions, type LoginRedirect, type LogoutOptions, type LogoutRedirect, OAuth2ClientBase, type SessionState, SIGNED_OUT, type UserInfoClaims, } from "./base.ts";
29
29
  export { BffClient, type BffClientOptions, type BffEndpoints, } from "./bff-client.ts";
30
- export { type AuthorizationCallbackResult, type AuthorizationRedirect, type AuthorizationServerEndpoints, type DeviceAuthorizationOptions, DirectClient, type DirectClientOptions, type ExchangeOptions, type ExchangeResult, type IntrospectionResult, type LoginOptions, type PollDeviceTokenOptions, } from "./direct-client.ts";
30
+ export { type AuthorizationCallbackResult, type AuthorizationRedirect, type AuthorizationServerEndpoints, type DeviceAuthorizationOptions, DirectClient, type DirectClientOptions, type ExchangeOptions, type ExchangeResult, type IntrospectionResult, type LoginOptions, type PollDeviceTokenOptions, type VerifyIssuerOptions, } from "./direct-client.ts";
31
31
  export { DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES, DEFAULT_DISCOVERY_TTL_MS, type DiscoveryCache, type DiscoveryCacheEntry, MemoryDiscoveryCache, type MemoryDiscoveryCacheOptions, } from "./discovery-cache.ts";
32
32
  export { type AuthenticatedEvent, type ErrorEvent, type LoggedOutEvent, type OAuth2ClientEvent, type OAuth2ClientEventListener, type TokenRefreshedEvent, } from "./events.ts";
33
33
  export { type AuthRequestRecord, type AuthRequestStorage, MemoryAuthRequestStorage, type MemoryAuthRequestStorageOptions, MemoryRefreshTokenStorage, MemoryTokenStorage, type RefreshTokenStorage, type TokenBundle, type TokenStorage, } from "./storage.ts";
package/dist/errors.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * - RFC 6749 Section 5.2 (Token errors)
7
7
  * - RFC 6750 Section 3.1 (Bearer token errors)
8
8
  * - RFC 8628 Section 3.5 (Device flow errors)
9
+ * - OpenID Connect Core 1.0 Section 3.1.2.6 (Authentication errors)
9
10
  *
10
11
  * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
11
12
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
@@ -204,3 +205,36 @@ export declare const SlowDownError: OAuth2ErrorClass;
204
205
  * @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
205
206
  */
206
207
  export declare const ExpiredTokenError: OAuth2ErrorClass;
208
+ /**
209
+ * The authorization request requires the user to authenticate, and the server
210
+ * cannot show a login page (`prompt=none`) or cannot re-authenticate the user
211
+ * as `prompt=login` or `max_age` requires.
212
+ *
213
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
214
+ */
215
+ export declare const LoginRequiredError: OAuth2ErrorClass;
216
+ /**
217
+ * The authorization request requires the user's consent, and the server
218
+ * cannot obtain it: `prompt=none` forbids the consent page, or `prompt=consent`
219
+ * reached a server with no consent handler.
220
+ *
221
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
222
+ */
223
+ export declare const ConsentRequiredError: OAuth2ErrorClass;
224
+ /**
225
+ * The authorization request requires user interaction other than login or
226
+ * consent, and `prompt=none` forbids showing it. Throw it from
227
+ * `authenticateUser` or `handleConsent` for an interstitial the request
228
+ * cannot skip.
229
+ *
230
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
231
+ */
232
+ export declare const InteractionRequiredError: OAuth2ErrorClass;
233
+ /**
234
+ * The user must choose among several accounts and the server cannot ask:
235
+ * `prompt=none` with more than one signed-in account, or `prompt=select_account`
236
+ * that the application cannot honor.
237
+ *
238
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
239
+ */
240
+ export declare const AccountSelectionRequiredError: OAuth2ErrorClass;
package/dist/errors.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * - RFC 6749 Section 5.2 (Token errors)
7
7
  * - RFC 6750 Section 3.1 (Bearer token errors)
8
8
  * - RFC 8628 Section 3.5 (Device flow errors)
9
+ * - OpenID Connect Core 1.0 Section 3.1.2.6 (Authentication errors)
9
10
  *
10
11
  * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
11
12
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
@@ -286,3 +287,60 @@ export const ExpiredTokenError = createHttpErrorClass({
286
287
  error: "expired_token",
287
288
  },
288
289
  });
290
+ /**
291
+ * The authorization request requires the user to authenticate, and the server
292
+ * cannot show a login page (`prompt=none`) or cannot re-authenticate the user
293
+ * as `prompt=login` or `max_age` requires.
294
+ *
295
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
296
+ */
297
+ export const LoginRequiredError = createHttpErrorClass({
298
+ name: "Login Required",
299
+ status: 400,
300
+ extensions: {
301
+ error: "login_required",
302
+ },
303
+ });
304
+ /**
305
+ * The authorization request requires the user's consent, and the server
306
+ * cannot obtain it: `prompt=none` forbids the consent page, or `prompt=consent`
307
+ * reached a server with no consent handler.
308
+ *
309
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
310
+ */
311
+ export const ConsentRequiredError = createHttpErrorClass({
312
+ name: "Consent Required",
313
+ status: 400,
314
+ extensions: {
315
+ error: "consent_required",
316
+ },
317
+ });
318
+ /**
319
+ * The authorization request requires user interaction other than login or
320
+ * consent, and `prompt=none` forbids showing it. Throw it from
321
+ * `authenticateUser` or `handleConsent` for an interstitial the request
322
+ * cannot skip.
323
+ *
324
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
325
+ */
326
+ export const InteractionRequiredError = createHttpErrorClass({
327
+ name: "Interaction Required",
328
+ status: 400,
329
+ extensions: {
330
+ error: "interaction_required",
331
+ },
332
+ });
333
+ /**
334
+ * The user must choose among several accounts and the server cannot ask:
335
+ * `prompt=none` with more than one signed-in account, or `prompt=select_account`
336
+ * that the application cannot honor.
337
+ *
338
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
339
+ */
340
+ export const AccountSelectionRequiredError = createHttpErrorClass({
341
+ name: "Account Selection Required",
342
+ status: 400,
343
+ extensions: {
344
+ error: "account_selection_required",
345
+ },
346
+ });
@@ -22,3 +22,31 @@ export interface AuthenticationContext {
22
22
  /** Truthful authentication methods; omitted when the complete set is unknown. */
23
23
  readonly amr?: readonly string[];
24
24
  }
25
+ /**
26
+ * The OpenID Connect authentication controls of one authorization request,
27
+ * validated by the authorization server and handed to the application's
28
+ * `authenticateUser` and `handleConsent` callbacks.
29
+ *
30
+ * The server enforces what it can verify: `prompt=none` never reaches a login
31
+ * or consent page, and a code is issued only when the returned authentication
32
+ * satisfies `prompt=login` and `max_age` (see
33
+ * `satisfiesAuthenticationRequest`). `acrValues` is a preference the
34
+ * application meets as it can; report the `acr` it actually achieved.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * const authentication: AuthenticationRequest = {
39
+ * prompt: ["login"],
40
+ * maxAge: 300,
41
+ * acrValues: ["urn:example:mfa"],
42
+ * };
43
+ * ```
44
+ */
45
+ export interface AuthenticationRequest {
46
+ /** Requested `prompt` values, each one the server supports; empty when absent. */
47
+ readonly prompt: readonly string[];
48
+ /** Requested `max_age` in seconds, when present. */
49
+ readonly maxAge?: number;
50
+ /** Requested `acr_values` in preference order; empty when absent. */
51
+ readonly acrValues: readonly string[];
52
+ }
@@ -38,11 +38,11 @@ export interface AuthorizationCode<Client extends ClientInterface, User, Scope e
38
38
  * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
39
39
  */
40
40
  export interface AuthorizeParameters {
41
- /** Raw space-delimited ACR preferences; application policy must validate and honor them. */
41
+ /** Raw space-delimited ACR preferences; `authenticateUser` receives them validated. */
42
42
  acrValues?: string;
43
- /** Raw maximum authentication age in seconds; application policy must validate and enforce it. */
43
+ /** Raw maximum authentication age in seconds; the authorize endpoint validates and enforces it. */
44
44
  maxAge?: string;
45
- /** Raw prompt values; application policy must honor or refuse every supported control. */
45
+ /** Raw prompt values; the authorize endpoint validates them and enforces `none`, `login` and `consent`. */
46
46
  prompt?: string;
47
47
  /** Must be "code" for authorization code flow. */
48
48
  responseType?: string;
@@ -4,12 +4,14 @@
4
4
  * Includes the RFC 6750 Section 3.1 bearer-token error codes
5
5
  * (`invalid_token`, `insufficient_scope`) emitted by resource servers, in
6
6
  * addition to the RFC 6749 Section 5.2 / Section 4.1.2.1 codes and the RFC
7
- * 8628 device-flow codes.
7
+ * 8628 device-flow codes and the OpenID Connect Core §3.1.2.6
8
+ * authentication-error codes.
8
9
  *
9
10
  * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
10
11
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
12
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
11
13
  */
12
- export type OAuth2ErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "unsupported_token_type" | "invalid_scope" | "invalid_token" | "insufficient_scope" | "access_denied" | "server_error" | "temporarily_unavailable" | "authorization_pending" | "slow_down" | "expired_token";
14
+ export type OAuth2ErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "unsupported_token_type" | "invalid_scope" | "invalid_token" | "insufficient_scope" | "access_denied" | "server_error" | "temporarily_unavailable" | "authorization_pending" | "slow_down" | "expired_token" | "login_required" | "consent_required" | "interaction_required" | "account_selection_required";
13
15
  /**
14
16
  * Standard OAuth2 error response.
15
17
  *
@@ -61,6 +63,11 @@ export interface AuthorizationServerMetadata {
61
63
  scopes_supported?: string[];
62
64
  /** Supported response types. */
63
65
  response_types_supported?: string[];
66
+ /**
67
+ * Whether every authorization response carries the `iss` parameter
68
+ * (RFC 9207 §3). A client that sees `true` refuses a response without one.
69
+ */
70
+ authorization_response_iss_parameter_supported?: boolean;
64
71
  /** Supported grant types. */
65
72
  grant_types_supported?: string[];
66
73
  /** URL of the JWKS document (OIDC Discovery / RFC 8414). */
@@ -69,6 +76,10 @@ export interface AuthorizationServerMetadata {
69
76
  id_token_signing_alg_values_supported?: string[];
70
77
  /** Subject identifier types supported (OIDC Discovery). */
71
78
  subject_types_supported?: string[];
79
+ /** `prompt` values the authorization endpoint honors (OIDC Prompt Create). */
80
+ prompt_values_supported?: string[];
81
+ /** Authentication context class references the server can achieve (OIDC Discovery). */
82
+ acr_values_supported?: string[];
72
83
  }
73
84
  /**
74
85
  * Device authorization response as defined in RFC 8628.
@@ -0,0 +1,60 @@
1
+ import type { AuthenticationContext, AuthenticationRequest } from "../models/authentication.ts";
2
+ /** The `prompt` values the authorization server enforces on its own. */
3
+ export declare const DEFAULT_PROMPT_VALUES_SUPPORTED: readonly string[];
4
+ /**
5
+ * What an `authenticateUser` callback reports about the authentication behind
6
+ * the user it returns, as {@linkcode satisfiesAuthenticationRequest} reads it.
7
+ */
8
+ export interface AuthenticationOutcome {
9
+ /** The verified authentication event, when known. */
10
+ authenticationContext?: AuthenticationContext;
11
+ /**
12
+ * `true` only when the user actively authenticated in response to this
13
+ * authorization request, which is what `prompt=login` and `max_age=0` ask
14
+ * for. A session that merely exists, or was refreshed, is not that.
15
+ */
16
+ reauthenticated?: boolean;
17
+ }
18
+ export declare function assertSupportedValues(name: string, values: readonly string[]): readonly string[];
19
+ /**
20
+ * Validates the `prompt`, `max_age` and `acr_values` of an authorization
21
+ * request. Parameters sent without a value are treated as omitted (OIDC Core
22
+ * §3.1.2.1).
23
+ *
24
+ * @throws {InvalidRequestError} For a repeated parameter, a `prompt` value not
25
+ * in `promptValuesSupported`, `none` combined with another `prompt` value, or
26
+ * a `max_age` that is not a non-negative integer.
27
+ */
28
+ export declare function parseAuthenticationRequest(params: URLSearchParams, promptValuesSupported: readonly string[]): AuthenticationRequest;
29
+ /**
30
+ * Why `outcome` does not satisfy `request`, as an `error_description`, or
31
+ * `undefined` when it does.
32
+ */
33
+ export declare function authenticationShortfall(request: AuthenticationRequest, outcome: AuthenticationOutcome, now: number): string | undefined;
34
+ /**
35
+ * Whether an authentication meets the request's `prompt=login` and `max_age`
36
+ * controls — the check the authorization server applies to what
37
+ * `authenticateUser` returns before it issues a code. Call it from
38
+ * `authenticateUser` with the session's event to decide whether to send the
39
+ * user to sign in again instead of returning them.
40
+ *
41
+ * `prompt=login` and `max_age=0`, which OIDC Core makes equivalent, need
42
+ * `reauthenticated: true`. Any `max_age` needs a recorded `auth_time`, since
43
+ * the ID token must carry one, never more than a minute in the future. Above
44
+ * zero, it must also be no older than `max_age` seconds, whether or not the
45
+ * user re-authenticated. `acr_values` is a preference and never fails the
46
+ * check.
47
+ *
48
+ * @param now The current time in epoch milliseconds. Defaults to `Date.now()`.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * import { satisfiesAuthenticationRequest } from "@udibo/oauth2/server";
53
+ *
54
+ * const fresh = satisfiesAuthenticationRequest(
55
+ * { prompt: [], maxAge: 300, acrValues: [] },
56
+ * { authenticationContext: { auth_time: Math.floor(Date.now() / 1000) } },
57
+ * );
58
+ * ```
59
+ */
60
+ export declare function satisfiesAuthenticationRequest(request: AuthenticationRequest, outcome: AuthenticationOutcome, now?: number): boolean;
@@ -0,0 +1,102 @@
1
+ import { InvalidRequestError } from "../errors.js";
2
+ /** The `prompt` values the authorization server enforces on its own. */
3
+ export const DEFAULT_PROMPT_VALUES_SUPPORTED = Object.freeze(["none", "login", "consent"]);
4
+ const MAX_AUTH_TIME_SKEW_SECONDS = 60;
5
+ function spaceDelimited(value) {
6
+ return [...new Set((value ?? "").split(" ").filter(Boolean))];
7
+ }
8
+ export function assertSupportedValues(name, values) {
9
+ if (values.some((value) => typeof value !== "string" || !/^[\x21-\x7e]+$/.test(value))) {
10
+ throw new TypeError(`${name} entries must be non-empty strings of visible ASCII without spaces`);
11
+ }
12
+ return Object.freeze([...new Set(values)]);
13
+ }
14
+ /**
15
+ * Validates the `prompt`, `max_age` and `acr_values` of an authorization
16
+ * request. Parameters sent without a value are treated as omitted (OIDC Core
17
+ * §3.1.2.1).
18
+ *
19
+ * @throws {InvalidRequestError} For a repeated parameter, a `prompt` value not
20
+ * in `promptValuesSupported`, `none` combined with another `prompt` value, or
21
+ * a `max_age` that is not a non-negative integer.
22
+ */
23
+ export function parseAuthenticationRequest(params, promptValuesSupported) {
24
+ for (const name of ["prompt", "max_age", "acr_values"]) {
25
+ if (params.getAll(name).length > 1) {
26
+ throw new InvalidRequestError(`${name} parameter must not be repeated`);
27
+ }
28
+ }
29
+ const prompt = spaceDelimited(params.get("prompt"));
30
+ if (prompt.some((value) => !promptValuesSupported.includes(value))) {
31
+ throw new InvalidRequestError("prompt value not supported");
32
+ }
33
+ if (prompt.includes("none") && prompt.length > 1) {
34
+ throw new InvalidRequestError("prompt=none cannot be combined with other prompt values");
35
+ }
36
+ const rawMaxAge = params.get("max_age") || undefined;
37
+ if (rawMaxAge !== undefined &&
38
+ (!/^\d+$/.test(rawMaxAge) || !Number.isSafeInteger(Number(rawMaxAge)))) {
39
+ throw new InvalidRequestError("max_age must be a non-negative integer");
40
+ }
41
+ return Object.freeze({
42
+ prompt: Object.freeze(prompt),
43
+ ...(rawMaxAge === undefined ? {} : { maxAge: Number(rawMaxAge) }),
44
+ acrValues: Object.freeze(spaceDelimited(params.get("acr_values"))),
45
+ });
46
+ }
47
+ /**
48
+ * Why `outcome` does not satisfy `request`, as an `error_description`, or
49
+ * `undefined` when it does.
50
+ */
51
+ export function authenticationShortfall(request, outcome, now) {
52
+ const reauthenticated = outcome.reauthenticated === true;
53
+ if (request.prompt.includes("login") && !reauthenticated) {
54
+ return "prompt=login requires the user to authenticate again";
55
+ }
56
+ if (request.maxAge === 0 && !reauthenticated) {
57
+ return "max_age=0 requires the user to authenticate again";
58
+ }
59
+ if (request.maxAge === undefined)
60
+ return undefined;
61
+ const authTime = outcome.authenticationContext?.auth_time;
62
+ if (typeof authTime !== "number" || !Number.isFinite(authTime)) {
63
+ return "max_age requires the time the user authenticated";
64
+ }
65
+ const age = Math.floor(now / 1000) - authTime;
66
+ if (age < -MAX_AUTH_TIME_SKEW_SECONDS) {
67
+ return "the user's authentication time is in the future";
68
+ }
69
+ if (request.maxAge > 0 && age > request.maxAge) {
70
+ return "the user's last authentication is older than max_age";
71
+ }
72
+ return undefined;
73
+ }
74
+ /**
75
+ * Whether an authentication meets the request's `prompt=login` and `max_age`
76
+ * controls — the check the authorization server applies to what
77
+ * `authenticateUser` returns before it issues a code. Call it from
78
+ * `authenticateUser` with the session's event to decide whether to send the
79
+ * user to sign in again instead of returning them.
80
+ *
81
+ * `prompt=login` and `max_age=0`, which OIDC Core makes equivalent, need
82
+ * `reauthenticated: true`. Any `max_age` needs a recorded `auth_time`, since
83
+ * the ID token must carry one, never more than a minute in the future. Above
84
+ * zero, it must also be no older than `max_age` seconds, whether or not the
85
+ * user re-authenticated. `acr_values` is a preference and never fails the
86
+ * check.
87
+ *
88
+ * @param now The current time in epoch milliseconds. Defaults to `Date.now()`.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { satisfiesAuthenticationRequest } from "@udibo/oauth2/server";
93
+ *
94
+ * const fresh = satisfiesAuthenticationRequest(
95
+ * { prompt: [], maxAge: 300, acrValues: [] },
96
+ * { authenticationContext: { auth_time: Math.floor(Date.now() / 1000) } },
97
+ * );
98
+ * ```
99
+ */
100
+ export function satisfiesAuthenticationRequest(request, outcome, now = Date.now()) {
101
+ return authenticationShortfall(request, outcome, now) === undefined;
102
+ }