@udibo/oauth2 0.26.0 → 0.28.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.
Files changed (36) hide show
  1. package/README.md +5 -3
  2. package/dist/adapters/hono/authorization-server.d.ts +21 -8
  3. package/dist/adapters/hono/authorization-server.js +2 -2
  4. package/dist/adapters/hono/bff/bff.d.ts +18 -4
  5. package/dist/adapters/hono/bff/bff.js +69 -3
  6. package/dist/cli/idp/server.js +35 -14
  7. package/dist/client/_http.d.ts +6 -1
  8. package/dist/client/_http.js +15 -2
  9. package/dist/client/_issuer-refusal.d.ts +7 -0
  10. package/dist/client/_issuer-refusal.js +22 -0
  11. package/dist/client/direct-client.d.ts +2 -1
  12. package/dist/client/direct-client.js +9 -22
  13. package/dist/errors.d.ts +34 -0
  14. package/dist/errors.js +58 -0
  15. package/dist/identity/external/_shared.d.ts +18 -0
  16. package/dist/identity/external/_shared.js +15 -0
  17. package/dist/identity/external/apple.d.ts +3 -0
  18. package/dist/identity/external/apple.js +10 -1
  19. package/dist/identity/external/errors.d.ts +3 -1
  20. package/dist/identity/external/flow.d.ts +4 -2
  21. package/dist/identity/external/flow.js +10 -2
  22. package/dist/identity/external/oauth2.d.ts +19 -1
  23. package/dist/identity/external/oauth2.js +21 -2
  24. package/dist/identity/external/oidc.d.ts +6 -0
  25. package/dist/identity/external/oidc.js +13 -1
  26. package/dist/identity/external/provider.d.ts +12 -2
  27. package/dist/models/authentication.d.ts +28 -0
  28. package/dist/models/authorization-code.d.ts +3 -3
  29. package/dist/models/responses.d.ts +8 -2
  30. package/dist/server/authentication-request.d.ts +60 -0
  31. package/dist/server/authentication-request.js +102 -0
  32. package/dist/server/authorization-server.d.ts +55 -7
  33. package/dist/server/authorization-server.js +54 -11
  34. package/dist/server/mod.d.ts +3 -2
  35. package/dist/server/mod.js +2 -1
  36. package/package.json +1 -1
@@ -53,6 +53,24 @@ export declare function readProviderText(res: Response, provider: string, source
53
53
  * the body, so the caller must not read it again.
54
54
  */
55
55
  export declare function readErrorBody(res: Response): Promise<string>;
56
+ /** Input to {@link assertAuthorizationResponseIssuer}. */
57
+ export interface AuthorizationResponseIssuerInput {
58
+ /** The connector id, for the error prefix. */
59
+ provider: string;
60
+ /** The callback's `iss`, from {@link ExternalProfileInput.iss}. */
61
+ iss: string | undefined;
62
+ /** The provider's issuer identifier; `iss` must equal it exactly. */
63
+ issuer: string;
64
+ /** Whether the provider advertises `authorization_response_iss_parameter_supported`. */
65
+ required: boolean;
66
+ }
67
+ /**
68
+ * Refuses a callback whose `iss` (RFC 9207 §2.4) is not exactly `issuer`, or
69
+ * is absent while `required`. Call it before redeeming the code.
70
+ *
71
+ * @throws {ExternalAuthError} `invalid_callback` when the response is refused.
72
+ */
73
+ export declare function assertAuthorizationResponseIssuer(input: AuthorizationResponseIssuerInput): void;
56
74
  /**
57
75
  * Drops one trailing `"/"` so issuer URLs compare and concatenate consistently
58
76
  * (`"https://x/"` and `"https://x"` identify the same issuer).
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * @module
10
10
  */
11
+ import { authorizationResponseIssuerProblem } from "../../client/_issuer-refusal.js";
11
12
  import { isOAuth2Error } from "../../errors.js";
12
13
  import { readBoundedBody } from "../../utils/_body.js";
13
14
  import { timingSafeEqualString } from "../../utils/crypto.js";
@@ -90,6 +91,20 @@ export async function readErrorBody(res) {
90
91
  return "";
91
92
  }
92
93
  }
94
+ /**
95
+ * Refuses a callback whose `iss` (RFC 9207 §2.4) is not exactly `issuer`, or
96
+ * is absent while `required`. Call it before redeeming the code.
97
+ *
98
+ * @throws {ExternalAuthError} `invalid_callback` when the response is refused.
99
+ */
100
+ export function assertAuthorizationResponseIssuer(input) {
101
+ const problem = authorizationResponseIssuerProblem(input.iss === undefined ? [] : [input.iss], [input.issuer], input.required);
102
+ if (problem === undefined)
103
+ return;
104
+ throw new ExternalAuthError(input.provider, "invalid_callback", `${problem}. The callback did not come from "${input.issuer}" — a ` +
105
+ `response from another provider replayed here, or a provider that ` +
106
+ `does not send the issuer it advertises. Ask the user to start again.`);
107
+ }
93
108
  /**
94
109
  * Drops one trailing `"/"` so issuer URLs compare and concatenate consistently
95
110
  * (`"https://x/"` and `"https://x"` identify the same issuer).
@@ -50,6 +50,9 @@ export interface AppleProviderOptions extends Pick<AppleClientSecretOptions, "te
50
50
  * `id_token`'s RS256 signature against Apple's published keys before trusting
51
51
  * its claims (`iss`, `aud`, `azp`, `exp`, and the `nonce` when one was sent,
52
52
  * checked as {@link oidcProvider} checks them, with the default `azp` policy).
53
+ * A callback `iss` (RFC 9207) other than `https://appleid.apple.com` is
54
+ * refused before the code is redeemed; Apple does not advertise the
55
+ * parameter, so a callback without one is accepted.
53
56
  * `emailVerified` is `true` when Apple's `email_verified` claim is `true` or
54
57
  * the string `"true"`; private-relay addresses
55
58
  * (`@privaterelay.appleid.com`) are returned as-is. The raw `id_token`
@@ -18,7 +18,7 @@
18
18
  import { toArrayBuffer } from "../../utils/_buffer.js";
19
19
  import { base64urlDecode } from "../../utils/crypto.js";
20
20
  import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
21
- import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
21
+ import { assertAuthorizationResponseIssuer, assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
22
22
  import { runTokenExchange } from "./_token-exchange.js";
23
23
  import { createAppleClientSecretFactory, } from "./apple-client-secret.js";
24
24
  import { ExternalAuthError } from "./errors.js";
@@ -41,6 +41,9 @@ const SIGNATURE_NOT_VERIFIED = `Apple id_token signature did not verify against
41
41
  * `id_token`'s RS256 signature against Apple's published keys before trusting
42
42
  * its claims (`iss`, `aud`, `azp`, `exp`, and the `nonce` when one was sent,
43
43
  * checked as {@link oidcProvider} checks them, with the default `azp` policy).
44
+ * A callback `iss` (RFC 9207) other than `https://appleid.apple.com` is
45
+ * refused before the code is redeemed; Apple does not advertise the
46
+ * parameter, so a callback without one is accepted.
44
47
  * `emailVerified` is `true` when Apple's `email_verified` claim is `true` or
45
48
  * the string `"true"`; private-relay addresses
46
49
  * (`@privaterelay.appleid.com`) are returned as-is. The raw `id_token`
@@ -205,6 +208,12 @@ export function appleProvider(options) {
205
208
  return Promise.resolve(url.toString());
206
209
  },
207
210
  async fetchProfile(input) {
211
+ assertAuthorizationResponseIssuer({
212
+ provider: id,
213
+ iss: input.iss,
214
+ issuer: APPLE_ISSUER,
215
+ required: false,
216
+ });
208
217
  const idToken = await exchangeCode(input);
209
218
  const claims = await verifyIdToken(idToken, input.nonce);
210
219
  return profileFromClaims(claims);
@@ -19,7 +19,9 @@
19
19
  * - `provider_error` — the provider rejected the request (callback `error`
20
20
  * param, token-endpoint error, profile-endpoint failure, malformed
21
21
  * response). The message carries the provider's error code/description.
22
- * - `invalid_callback` — the callback URL is missing `code` or `state`.
22
+ * - `invalid_callback` — the callback URL is missing `code` or `state`, or
23
+ * its `iss` (RFC 9207) is not the provider's issuer: repeated, naming
24
+ * another issuer, or missing from a provider that advertises it.
23
25
  * - `state_mismatch` — the callback `state` does not match the transient
24
26
  * (possible CSRF or a stale/mixed-up sign-in attempt).
25
27
  * - `transient_expired` — the sign-in attempt outlived the configured max
@@ -99,8 +99,10 @@ export declare class ExternalAuthFlow {
99
99
  * Completes a sign-in attempt from the provider callback: checks the
100
100
  * transient belongs to this flow's provider, enforces the max transient
101
101
  * age, verifies `state` with a timing-safe compare, and only then surfaces a
102
- * provider `error` callback param; finally the provider exchanges the code
103
- * and (for nonce-using providers) validates the id_token nonce, and the
102
+ * provider `error` callback param. A callback with more than one `iss` is
103
+ * refused; a single `iss` is handed to the provider, which checks it against
104
+ * its issuer (RFC 9207) before it exchanges the code. The provider then
105
+ * (for nonce-using providers) validates the id_token nonce, and the
104
106
  * normalized profile is returned.
105
107
  *
106
108
  * @throws {ExternalAuthError} with a code describing the failure — see
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import { DUPLICATE_ISS_PROBLEM } from "../../client/_issuer-refusal.js";
9
10
  import { timingSafeEqualString } from "../../utils/crypto.js";
10
11
  import { generateCodeVerifier, generateState } from "../../utils/pkce.js";
11
12
  import { ExternalAuthError } from "./errors.js";
@@ -91,8 +92,10 @@ export class ExternalAuthFlow {
91
92
  * Completes a sign-in attempt from the provider callback: checks the
92
93
  * transient belongs to this flow's provider, enforces the max transient
93
94
  * age, verifies `state` with a timing-safe compare, and only then surfaces a
94
- * provider `error` callback param; finally the provider exchanges the code
95
- * and (for nonce-using providers) validates the id_token nonce, and the
95
+ * provider `error` callback param. A callback with more than one `iss` is
96
+ * refused; a single `iss` is handed to the provider, which checks it against
97
+ * its issuer (RFC 9207) before it exchanges the code. The provider then
98
+ * (for nonce-using providers) validates the id_token nonce, and the
96
99
  * normalized profile is returned.
97
100
  *
98
101
  * @throws {ExternalAuthError} with a code describing the failure — see
@@ -134,11 +137,16 @@ export class ExternalAuthFlow {
134
137
  if (!code) {
135
138
  throw new ExternalAuthError(providerId, "invalid_callback", `callback is missing the "code" parameter.`);
136
139
  }
140
+ const issValues = params.getAll("iss");
141
+ if (issValues.length > 1) {
142
+ throw new ExternalAuthError(providerId, "invalid_callback", `${DUPLICATE_ISS_PROBLEM}. Ask the user to start again.`);
143
+ }
137
144
  return await this.provider.fetchProfile({
138
145
  code,
139
146
  redirectUri: transient.redirectUri,
140
147
  codeVerifier: transient.codeVerifier,
141
148
  nonce: transient.nonce,
149
+ ...(issValues.length === 1 ? { iss: issValues[0] } : {}),
142
150
  });
143
151
  }
144
152
  }
@@ -85,6 +85,20 @@ export interface OAuth2ProviderOptions {
85
85
  * downgrade PKCE.
86
86
  */
87
87
  authorizationParams?: Record<string, string>;
88
+ /**
89
+ * The provider's issuer identifier, for the RFC 9207 check. When set, a
90
+ * callback `iss` that is not exactly this value is refused before the code
91
+ * is redeemed. Unset, the callback's `iss` is not checked.
92
+ */
93
+ issuer?: string;
94
+ /**
95
+ * Set `true` when the provider always sends `iss` in its authorization
96
+ * responses (its metadata advertises
97
+ * `authorization_response_iss_parameter_supported`), so a callback without
98
+ * one is refused. Requires {@link OAuth2ProviderOptions.issuer}. Defaults to
99
+ * `false`.
100
+ */
101
+ authorizationResponseIssParameterSupported?: boolean;
88
102
  /**
89
103
  * Fetch implementation for all provider traffic. Defaults to
90
104
  * `globalThis.fetch`. A wrapper must pass `init` through intact: its
@@ -100,7 +114,11 @@ export interface OAuth2ProviderOptions {
100
114
  * credentials in the body (`client_secret_post`); the profile is read from
101
115
  * `userInfoEndpoint` with the bearer token when set, else from the token
102
116
  * response, and handed to `mapProfile`. Presets like {@link discordProvider}
103
- * are thin wrappers over this.
117
+ * are thin wrappers over this. With `issuer` set, the callback's `iss`
118
+ * (RFC 9207) is checked before the exchange.
119
+ *
120
+ * @throws {ExternalAuthError} `configuration` when
121
+ * `authorizationResponseIssParameterSupported` is set without `issuer`.
104
122
  *
105
123
  * @example
106
124
  * ```ts
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
11
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
12
- import { describeError, PROVIDER_REQUEST_TIMEOUT_MS, readErrorBody, readProviderText, } from "./_shared.js";
12
+ import { assertAuthorizationResponseIssuer, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readErrorBody, readProviderText, } from "./_shared.js";
13
13
  import { runTokenExchange } from "./_token-exchange.js";
14
14
  import { ExternalAuthError } from "./errors.js";
15
15
  /**
@@ -20,7 +20,11 @@ import { ExternalAuthError } from "./errors.js";
20
20
  * credentials in the body (`client_secret_post`); the profile is read from
21
21
  * `userInfoEndpoint` with the bearer token when set, else from the token
22
22
  * response, and handed to `mapProfile`. Presets like {@link discordProvider}
23
- * are thin wrappers over this.
23
+ * are thin wrappers over this. With `issuer` set, the callback's `iss`
24
+ * (RFC 9207) is checked before the exchange.
25
+ *
26
+ * @throws {ExternalAuthError} `configuration` when
27
+ * `authorizationResponseIssParameterSupported` is set without `issuer`.
24
28
  *
25
29
  * @example
26
30
  * ```ts
@@ -40,6 +44,13 @@ import { ExternalAuthError } from "./errors.js";
40
44
  */
41
45
  export function oauth2Provider(options) {
42
46
  const id = options.id;
47
+ const { issuer } = options;
48
+ const issRequired = options.authorizationResponseIssParameterSupported === true;
49
+ if (issRequired && issuer === undefined) {
50
+ throw new ExternalAuthError(id, "configuration", `authorizationResponseIssParameterSupported needs an issuer to ` +
51
+ `compare the callback's "iss" with — set the provider's issuer ` +
52
+ `identifier as "issuer".`);
53
+ }
43
54
  const fetchImpl = (input, init) => (options.fetch ?? defaultFetch)(input, init);
44
55
  const scopeSeparator = options.scopeSeparator ?? " ";
45
56
  async function exchangeCode(input) {
@@ -120,6 +131,14 @@ export function oauth2Provider(options) {
120
131
  return url.toString();
121
132
  },
122
133
  async fetchProfile(input) {
134
+ if (issuer !== undefined) {
135
+ assertAuthorizationResponseIssuer({
136
+ provider: id,
137
+ iss: input.iss,
138
+ issuer,
139
+ required: issRequired,
140
+ });
141
+ }
123
142
  const tokens = await exchangeCode(input);
124
143
  const profile = options.userInfoEndpoint
125
144
  ? await fetchUserInfo(tokens.accessToken)
@@ -65,6 +65,12 @@ export interface OidcProviderOptions {
65
65
  * id_token `sub` (OIDC Core §5.3.2) is discarded, and a UserInfo failure is
66
66
  * non-fatal — the profile then comes from the id_token claims alone.
67
67
  *
68
+ * **Authorization response issuer:** before the code is redeemed, a callback
69
+ * `iss` (RFC 9207) that is not exactly the discovered issuer is refused, and
70
+ * so is a missing `iss` when the discovery document advertises
71
+ * `authorization_response_iss_parameter_supported: true`. A provider that
72
+ * does not advertise it may leave `iss` out.
73
+ *
68
74
  * **id_token validation:** the `iss`, `aud`, `azp` (when the token has multiple
69
75
  * audiences or an `azp` claim, unless `azp: "ignore"` is configured), `exp`,
70
76
  * and `nonce` claims are checked per OpenID Connect Core §3.1.3.7. The
@@ -9,7 +9,7 @@ import { MemoryAuthRequestStorage } from "../../client/storage.js";
9
9
  import { isOAuth2Error } from "../../errors.js";
10
10
  import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
11
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
12
- import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, stripTrailingSlash, } from "./_shared.js";
12
+ import { assertAuthorizationResponseIssuer, assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, stripTrailingSlash, } from "./_shared.js";
13
13
  import { ExternalAuthError } from "./errors.js";
14
14
  /**
15
15
  * Creates a connector for any spec-compliant OpenID Connect provider.
@@ -23,6 +23,12 @@ import { ExternalAuthError } from "./errors.js";
23
23
  * id_token `sub` (OIDC Core §5.3.2) is discarded, and a UserInfo failure is
24
24
  * non-fatal — the profile then comes from the id_token claims alone.
25
25
  *
26
+ * **Authorization response issuer:** before the code is redeemed, a callback
27
+ * `iss` (RFC 9207) that is not exactly the discovered issuer is refused, and
28
+ * so is a missing `iss` when the discovery document advertises
29
+ * `authorization_response_iss_parameter_supported: true`. A provider that
30
+ * does not advertise it may leave `iss` out.
31
+ *
26
32
  * **id_token validation:** the `iss`, `aud`, `azp` (when the token has multiple
27
33
  * audiences or an `azp` claim, unless `azp: "ignore"` is configured), `exp`,
28
34
  * and `nonce` claims are checked per OpenID Connect Core §3.1.3.7. The
@@ -191,6 +197,12 @@ export function oidcProvider(options) {
191
197
  },
192
198
  async fetchProfile(input) {
193
199
  const meta = await discover();
200
+ assertAuthorizationResponseIssuer({
201
+ provider: id,
202
+ iss: input.iss,
203
+ issuer: meta.issuer,
204
+ required: meta.authorization_response_iss_parameter_supported === true,
205
+ });
194
206
  const response = await exchangeCode(input);
195
207
  const claims = decodeAndValidateIdToken(response, meta, input.nonce);
196
208
  const userInfo = await fetchUserInfoClaims(meta, response.access_token, claims.sub);
@@ -93,6 +93,14 @@ export interface ExternalProfileInput {
93
93
  codeVerifier?: string;
94
94
  /** The expected id_token `nonce` from the transient, when one was sent. */
95
95
  nonce?: string;
96
+ /**
97
+ * The callback's `iss` parameter (RFC 9207), when it carried one. Refuse
98
+ * the callback before redeeming `code` when this is not exactly the
99
+ * provider's issuer, or when it is missing and the provider advertises
100
+ * `authorization_response_iss_parameter_supported`. The flow has already
101
+ * refused a callback with more than one `iss`.
102
+ */
103
+ iss?: string;
96
104
  }
97
105
  /**
98
106
  * What a connector must provide for {@link ExternalAuthFlow} to drive it.
@@ -125,10 +133,12 @@ export interface ExternalProvider {
125
133
  /**
126
134
  * Exchanges the authorization code and returns the verified, normalized
127
135
  * profile. Implementations must validate the id_token `nonce` against
128
- * `input.nonce` when one was sent.
136
+ * `input.nonce` when one was sent, and should check `input.iss` before the
137
+ * exchange as {@link ExternalProfileInput.iss} describes.
129
138
  *
130
139
  * @throws {ExternalAuthError} `provider_error` when the exchange or profile
131
- * fetch fails, `nonce_mismatch` when the id_token nonce does not match.
140
+ * fetch fails, `nonce_mismatch` when the id_token nonce does not match,
141
+ * `invalid_callback` when `input.iss` is refused.
132
142
  */
133
143
  fetchProfile(input: ExternalProfileInput): Promise<ExternalProfile>;
134
144
  }
@@ -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
  *
@@ -74,6 +76,10 @@ export interface AuthorizationServerMetadata {
74
76
  id_token_signing_alg_values_supported?: string[];
75
77
  /** Subject identifier types supported (OIDC Discovery). */
76
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[];
77
83
  }
78
84
  /**
79
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
+ }