@udibo/oauth2 0.27.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.
package/README.md CHANGED
@@ -85,7 +85,7 @@ The package implements these specifications, with the limits noted:
85
85
  [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628) device authorization.
86
86
  - [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) `iss` in authorization
87
87
  responses. `DirectClient` and the BFF check it when the client is configured
88
- with an `issuer`.
88
+ with an `issuer`, and so do the external sign-in connectors.
89
89
  - [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) JWT access tokens, opt-in;
90
90
  access tokens are opaque by default.
91
91
  - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html):
@@ -296,7 +296,9 @@ export interface BffCallbackError {
296
296
  * `resolveOrigin` threw, so `iss` could not be checked; the description is
297
297
  * fixed and the failure is in `cause`), or `invalid_grant`
298
298
  * (the code exchange failed — expired/replayed code, unknown `state` — or
299
- * a later step threw: `resolveUser` or the session store).
299
+ * a later step threw: `resolveUser` or the session store), or
300
+ * `login_required` (a pinned `max_age` and an ID token whose `auth_time`
301
+ * does not satisfy it — see {@link HonoBffOptions.extraParams}).
300
302
  */
301
303
  error: string;
302
304
  /** Human-readable description; never contains secrets. */
@@ -514,7 +516,18 @@ export interface HonoBffOptions {
514
516
  * value, name it in {@link forwardedParams} instead.
515
517
  *
516
518
  * `prompt` is an ordinary name here — pin `{ prompt: "login" }` to make
517
- * every sign-in re-authenticate. A pinned `scope` is accepted only when
519
+ * every sign-in re-authenticate. A pinned `max_age` must be a non-negative
520
+ * integer, and `/auth/callback` then checks the ID token's `auth_time`
521
+ * (OpenID Connect Core §3.1.3.7): a sign-in whose `auth_time` is missing,
522
+ * more than 60 seconds in the future, or older than `max_age` plus those
523
+ * 60 seconds (an allowance for clock skew between the BFF and the
524
+ * authorization server) fails with
525
+ * `login_required`. `max_age=0` means `prompt=login`, so it needs
526
+ * `auth_time` but no particular age. A token response without an ID token
527
+ * is not checked, unless {@link HonoBffOptions.forwardedParams} names
528
+ * `scope`: the browser could then leave out `openid` to skip the check, so
529
+ * a missing ID token fails with `login_required` too. A pinned `scope` is
530
+ * accepted only when
518
531
  * {@link scope} is unset; setting both would leave which one applies
519
532
  * ambiguous. Either configuration supplies the scope the login flow records
520
533
  * for its authorization request.
@@ -744,9 +757,10 @@ export declare class HonoBff {
744
757
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
745
758
  * parameter, gives one a name that is empty or not equal to its trimmed
746
759
  * form (`" state"` and `"organization "` are refused rather than trimmed),
747
- * names the same parameter in both, or pins `scope` in
760
+ * names the same parameter in both, pins `scope` in
748
761
  * {@linkcode HonoBffOptions.extraParams} while
749
- * {@linkcode HonoBffOptions.scope} is also set.
762
+ * {@linkcode HonoBffOptions.scope} is also set, or pins a `max_age` that is
763
+ * not a non-negative integer.
750
764
  */
751
765
  constructor(options: HonoBffOptions);
752
766
  /**
@@ -150,6 +150,47 @@ const RESERVED_AUTHORIZE_PARAMS = [
150
150
  "request_uri",
151
151
  ];
152
152
  const RESERVED_AUTHORIZE_PARAM_SET = new Set(RESERVED_AUTHORIZE_PARAMS);
153
+ const AUTH_TIME_LEEWAY_SECONDS = 60;
154
+ function pinnedMaxAge(fixed) {
155
+ let strictest;
156
+ for (const [name, value] of Object.entries(fixed)) {
157
+ if (name.toLowerCase() !== "max_age" || value === "")
158
+ continue;
159
+ const seconds = Number(value);
160
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(seconds)) {
161
+ throw new Error(`extraParams pins ${JSON.stringify(name)} to ${JSON.stringify(value)}, ` +
162
+ "which is not a non-negative integer number of seconds. The " +
163
+ "authorization server refuses such a max_age, and the callback " +
164
+ "could not check the ID token's auth_time against it.");
165
+ }
166
+ strictest =
167
+ strictest === undefined ? seconds : Math.min(strictest, seconds);
168
+ }
169
+ return strictest;
170
+ }
171
+ class CallbackRefusal extends Error {
172
+ refusal;
173
+ constructor(refusal) {
174
+ super(refusal.error_description);
175
+ this.refusal = refusal;
176
+ }
177
+ }
178
+ function authTimeShortfall(claims, maxAge, now) {
179
+ const authTime = typeof claims === "object" && claims !== null
180
+ ? claims.auth_time
181
+ : undefined;
182
+ if (typeof authTime !== "number" || !Number.isFinite(authTime)) {
183
+ return "the ID token has no auth_time, which the max_age this sign-in requested requires";
184
+ }
185
+ const age = Math.floor(now / 1000) - authTime;
186
+ if (age < -AUTH_TIME_LEEWAY_SECONDS) {
187
+ return "the ID token's auth_time is in the future";
188
+ }
189
+ if (maxAge > 0 && age > maxAge + AUTH_TIME_LEEWAY_SECONDS) {
190
+ return "the ID token's auth_time is older than the max_age this sign-in requested";
191
+ }
192
+ return undefined;
193
+ }
153
194
  function assertUsableAuthorizeParamName(name, option) {
154
195
  if (name === "" || name !== name.trim()) {
155
196
  throw new Error(`${option} may not use ${JSON.stringify(name)} as an ` +
@@ -207,7 +248,12 @@ function resolveExtraAuthorizeParams(options) {
207
248
  "alone.");
208
249
  }
209
250
  }
210
- return { fixed, forwarded };
251
+ return {
252
+ fixed,
253
+ forwarded,
254
+ maxAge: pinnedMaxAge(fixed),
255
+ scopeForwarded: forwarded.some((name) => name.toLowerCase() === "scope"),
256
+ };
211
257
  }
212
258
  function authorizeParamsForRequest(resolved, requestParams) {
213
259
  const entries = Object.entries(resolved.fixed);
@@ -365,9 +411,10 @@ export class HonoBff {
365
411
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
366
412
  * parameter, gives one a name that is empty or not equal to its trimmed
367
413
  * form (`" state"` and `"organization "` are refused rather than trimmed),
368
- * names the same parameter in both, or pins `scope` in
414
+ * names the same parameter in both, pins `scope` in
369
415
  * {@linkcode HonoBffOptions.extraParams} while
370
- * {@linkcode HonoBffOptions.scope} is also set.
416
+ * {@linkcode HonoBffOptions.scope} is also set, or pins a `max_age` that is
417
+ * not a non-negative integer.
371
418
  */
372
419
  constructor(options) {
373
420
  assertStoresConfigured(options);
@@ -737,6 +784,23 @@ export class HonoBff {
737
784
  let user = null;
738
785
  let sid;
739
786
  const idToken = raw.id_token;
787
+ const { maxAge, scopeForwarded } = this.#extraAuthorizeParams;
788
+ if (maxAge !== undefined && (idToken || scopeForwarded)) {
789
+ let claims;
790
+ try {
791
+ claims = idToken ? this.#client.decodeIdToken(idToken) : undefined;
792
+ }
793
+ catch {
794
+ claims = undefined;
795
+ }
796
+ const shortfall = authTimeShortfall(claims, maxAge, Date.now());
797
+ if (shortfall) {
798
+ throw new CallbackRefusal({
799
+ error: "login_required",
800
+ error_description: shortfall,
801
+ });
802
+ }
803
+ }
740
804
  if (idToken) {
741
805
  try {
742
806
  const claims = this.#client.decodeIdToken(idToken);
@@ -774,6 +838,8 @@ export class HonoBff {
774
838
  return c.redirect(this.#safeReturnTo(returnTo));
775
839
  }
776
840
  catch (cause) {
841
+ if (cause instanceof CallbackRefusal)
842
+ return await fail(cause.refusal);
777
843
  return await fail({
778
844
  error: "invalid_grant",
779
845
  error_description: "code exchange failed",
@@ -6,11 +6,16 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { type OAuth2Error } from "../errors.ts";
9
+ import { type OAuth2Error, type OAuth2ErrorClass as OAuth2ErrorClassType } from "../errors.ts";
10
10
  /** Response bytes accepted from an endpoint before the call is refused. */
11
11
  export declare const MAX_RESPONSE_BYTES: number;
12
12
  /** Wall-clock budget for one call, covering the connect and the body read. */
13
13
  export declare const REQUEST_TIMEOUT_MS = 10000;
14
+ /**
15
+ * The typed error class for an OAuth2 or OpenID Connect error code, or the
16
+ * generic `OAuth2Error` for a code without one.
17
+ */
18
+ export declare function errorClassForCode(code: string): OAuth2ErrorClassType;
14
19
  /** How a call describes itself in an error message, e.g. `"token endpoint"`. */
15
20
  export type Endpoint = string;
16
21
  /** How {@link sendGuarded} treats a `3xx`. */
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { AccessDeniedError, AuthorizationPendingError, ExpiredTokenError, InsufficientScopeError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, OAuth2Error as OAuth2ErrorClass, ServerError, SlowDownError, TemporarilyUnavailableError, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.js";
9
+ import { AccessDeniedError, AccountSelectionRequiredError, AuthorizationPendingError, ConsentRequiredError, ExpiredTokenError, InsufficientScopeError, InteractionRequiredError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, LoginRequiredError, OAuth2Error as OAuth2ErrorClass, ServerError, SlowDownError, TemporarilyUnavailableError, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.js";
10
10
  import { packageDeadlineSignal } from "../utils/_default-fetch.js";
11
11
  import { sanitizeProviderText } from "../utils/text.js";
12
12
  /** Response bytes accepted from an endpoint before the call is refused. */
@@ -15,14 +15,18 @@ export const MAX_RESPONSE_BYTES = 64 * 1024;
15
15
  export const REQUEST_TIMEOUT_MS = 10_000;
16
16
  const ERROR_CLASS_BY_CODE = {
17
17
  access_denied: AccessDeniedError,
18
+ account_selection_required: AccountSelectionRequiredError,
18
19
  authorization_pending: AuthorizationPendingError,
20
+ consent_required: ConsentRequiredError,
19
21
  expired_token: ExpiredTokenError,
20
22
  insufficient_scope: InsufficientScopeError,
23
+ interaction_required: InteractionRequiredError,
21
24
  invalid_client: InvalidClientError,
22
25
  invalid_grant: InvalidGrantError,
23
26
  invalid_request: InvalidRequestError,
24
27
  invalid_scope: InvalidScopeError,
25
28
  invalid_token: InvalidTokenError,
29
+ login_required: LoginRequiredError,
26
30
  server_error: ServerError,
27
31
  slow_down: SlowDownError,
28
32
  temporarily_unavailable: TemporarilyUnavailableError,
@@ -31,6 +35,15 @@ const ERROR_CLASS_BY_CODE = {
31
35
  unsupported_response_type: UnsupportedResponseTypeError,
32
36
  unsupported_token_type: UnsupportedTokenTypeError,
33
37
  };
38
+ /**
39
+ * The typed error class for an OAuth2 or OpenID Connect error code, or the
40
+ * generic `OAuth2Error` for a code without one.
41
+ */
42
+ export function errorClassForCode(code) {
43
+ return Object.hasOwn(ERROR_CLASS_BY_CODE, code)
44
+ ? ERROR_CLASS_BY_CODE[code]
45
+ : OAuth2ErrorClass;
46
+ }
34
47
  /**
35
48
  * Sends one request with a deadline.
36
49
  *
@@ -228,7 +241,7 @@ function reportedError(value) {
228
241
  };
229
242
  }
230
243
  function buildError(reported, status) {
231
- const ErrorClass = ERROR_CLASS_BY_CODE[reported.error] ?? OAuth2ErrorClass;
244
+ const ErrorClass = errorClassForCode(reported.error);
232
245
  return new ErrorClass(reported.description ?? reported.error, {
233
246
  status: status !== undefined && status >= 400 ? status : undefined,
234
247
  extensions: {
@@ -1,3 +1,10 @@
1
1
  import { type OAuth2Error } from "../errors.ts";
2
2
  export declare function issuerRefusal(message: string): OAuth2Error;
3
3
  export declare function isIssuerRefusal(error: unknown): error is OAuth2Error;
4
+ export declare const DUPLICATE_ISS_PROBLEM = "the authorization response carries more than one `iss` (RFC 9207)";
5
+ /**
6
+ * Why an authorization response's `iss` values (RFC 9207 §2.4) are refused, or
7
+ * `undefined` when they are accepted: more than one value, none when
8
+ * `required`, or one that is not exactly one of `accepted`.
9
+ */
10
+ export declare function authorizationResponseIssuerProblem(received: readonly string[], accepted: readonly string[], required: boolean): string | undefined;
@@ -8,3 +8,25 @@ export function issuerRefusal(message) {
8
8
  export function isIssuerRefusal(error) {
9
9
  return error instanceof Error && refusals.has(error);
10
10
  }
11
+ export const DUPLICATE_ISS_PROBLEM = "the authorization response carries more than one `iss` (RFC 9207)";
12
+ /**
13
+ * Why an authorization response's `iss` values (RFC 9207 §2.4) are refused, or
14
+ * `undefined` when they are accepted: more than one value, none when
15
+ * `required`, or one that is not exactly one of `accepted`.
16
+ */
17
+ export function authorizationResponseIssuerProblem(received, accepted, required) {
18
+ if (received.length > 1)
19
+ return DUPLICATE_ISS_PROBLEM;
20
+ const [iss] = received;
21
+ if (iss === undefined) {
22
+ if (!required)
23
+ return undefined;
24
+ return ("the authorization response is missing `iss`, which the issuer " +
25
+ "advertises with `authorization_response_iss_parameter_supported` " +
26
+ "(RFC 9207 §2.4)");
27
+ }
28
+ if (accepted.includes(iss))
29
+ return undefined;
30
+ return ("the authorization response `iss` does not match the issuer the " +
31
+ "request was sent to (RFC 9207 §2.4)");
32
+ }
@@ -423,7 +423,8 @@ export declare class DirectClient extends OAuth2ClientBase {
423
423
  * reached or its response does not arrive in full before the deadline;
424
424
  * `cause` is the transport failure.
425
425
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
426
- * exchange fails.
426
+ * exchange fails. A code with a typed class, such as `login_required`
427
+ * (`LoginRequiredError`), is thrown as that class.
427
428
  * @throws {InvalidRequestError} when the response's `iss` is refused.
428
429
  * @throws {TypeError} when `code` or `state` is missing, or outside a
429
430
  * browser when the client has no `authRequestStorage` or no
@@ -10,16 +10,16 @@
10
10
  *
11
11
  * @module
12
12
  */
13
- import { AccessDeniedError, InvalidGrantError, isOAuth2Error, OAuth2Error as OAuth2ErrorClass, ServerError, } from "../errors.js";
13
+ import { AccessDeniedError, InvalidGrantError, isOAuth2Error, ServerError, } from "../errors.js";
14
14
  import { challengeMethods, generateCodeVerifier, generateState, } from "../utils/pkce.js";
15
15
  import { encodeBasicAuth } from "../utils/basic-auth.js";
16
16
  import { base64urlDecode } from "../utils/crypto.js";
17
17
  import { delay } from "../utils/_delay.js";
18
18
  import { sanitizeProviderText } from "../utils/text.js";
19
- import { assertField, assertTokenResponse, errorFromResponse, receiveJson, sendGuarded, } from "./_http.js";
19
+ import { assertField, assertTokenResponse, errorClassForCode, 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
+ import { authorizationResponseIssuerProblem, issuerRefusal, } from "./_issuer-refusal.js";
23
23
  import { DEFAULT_DISCOVERY_TTL_MS, } from "./discovery-cache.js";
24
24
  import { MemoryAuthRequestStorage, MemoryRefreshTokenStorage, MemoryTokenStorage, } from "./storage.js";
25
25
  const REFRESH_SKEW_MS = 30_000;
@@ -396,7 +396,8 @@ export class DirectClient extends OAuth2ClientBase {
396
396
  * reached or its response does not arrive in full before the deadline;
397
397
  * `cause` is the transport failure.
398
398
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
399
- * exchange fails.
399
+ * exchange fails. A code with a typed class, such as `login_required`
400
+ * (`LoginRequiredError`), is thrown as that class.
400
401
  * @throws {InvalidRequestError} when the response's `iss` is refused.
401
402
  * @throws {TypeError} when `code` or `state` is missing, or outside a
402
403
  * browser when the client has no `authRequestStorage` or no
@@ -501,26 +502,11 @@ export class DirectClient extends OAuth2ClientBase {
501
502
  if (!this.#issuer)
502
503
  return undefined;
503
504
  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
505
  const accepted = [metadata.issuer];
518
506
  if (origin)
519
507
  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)");
508
+ const problem = authorizationResponseIssuerProblem(params.getAll("iss"), accepted, metadata.authorization_response_iss_parameter_supported === true);
509
+ return problem === undefined ? undefined : issuerRefusal(problem);
524
510
  }
525
511
  /**
526
512
  * **Advanced (BFF / multi-session servers).** SPA, mobile, and CLI consumers
@@ -1322,7 +1308,8 @@ function metadataToEndpoints(meta) {
1322
1308
  }
1323
1309
  function authorizationResponseError(error, params) {
1324
1310
  const description = params.get("error_description") ?? undefined;
1325
- return new OAuth2ErrorClass(description ?? "authorization error", {
1311
+ const ErrorClass = errorClassForCode(error);
1312
+ return new ErrorClass(description ?? "authorization error", {
1326
1313
  extensions: {
1327
1314
  error,
1328
1315
  error_description: description,
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "keywords": [
6
6
  "authorization-server",