@udibo/oauth2 0.25.0 → 0.26.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
@@ -1,13 +1,16 @@
1
1
  # @udibo/oauth2
2
2
 
3
- OAuth2 and OpenID Connect tools for TypeScript applications. Connect your app to
4
- Udibo's identity service, or host an authorization server for your own app. The
5
- package includes clients, authorization and resource servers, Hono middleware,
6
- React bindings, and optional login flows over your own database.
3
+ [![npm version](https://img.shields.io/npm/v/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2) [![JSR version](https://jsr.io/badges/@udibo/oauth2)](https://jsr.io/@udibo/oauth2) [![JSR score](https://jsr.io/badges/@udibo/oauth2/score)](https://jsr.io/@udibo/oauth2/score) [![CI](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml/badge.svg?branch=main)](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml?query=branch%3Amain) [![License: MIT](https://img.shields.io/npm/l/@udibo/oauth2)](LICENSE) [![npm downloads](https://img.shields.io/npm/dw/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2) [![Node.js version](https://img.shields.io/node/v/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2)
7
4
 
8
- Using Udibo's managed identity service requires a Udibo account; see
9
- [udibo.com](https://www.udibo.com/) for how to get one. The self-hosted examples
10
- run locally without a hosted account.
5
+ OAuth2 and OpenID Connect tools for TypeScript applications: clients,
6
+ authorization and resource servers, Hono middleware, React bindings, and
7
+ optional login flows over your own database. It runs standalone in your own app:
8
+ host an authorization server, protect an API, or sign users in through an
9
+ OAuth2 or OpenID Connect issuer.
10
+
11
+ The package also connects your app to Udibo's managed identity service, which
12
+ requires a Udibo account; see [udibo.com](https://www.udibo.com/) for how to get
13
+ one. The self-hosted examples run locally without a hosted account.
11
14
 
12
15
  ## Choose your path
13
16
 
@@ -25,77 +28,100 @@ run locally without a hosted account.
25
28
  ## Install
26
29
 
27
30
  The package supports Node.js 22 and later and ships ESM with type declarations.
28
- Install it from npm:
29
-
30
- ```sh
31
- npm install @udibo/oauth2
32
- # or
33
- pnpm add @udibo/oauth2
34
- ```
35
-
36
- It is also published to [JSR](https://jsr.io/@udibo/oauth2) as TypeScript
37
- source:
31
+ It is published to npm, and to [JSR](https://jsr.io/@udibo/oauth2) as
32
+ TypeScript source:
38
33
 
39
34
  ```sh
40
- npx jsr add @udibo/oauth2
41
- # or, with Deno
42
- deno add jsr:@udibo/oauth2
35
+ npm install @udibo/oauth2 # or: pnpm add @udibo/oauth2
36
+ npx jsr add @udibo/oauth2 # or, with Deno: deno add jsr:@udibo/oauth2
43
37
  ```
44
38
 
45
39
  `hono`, `react`, and `react-dom` are optional peer dependencies. Install the
46
40
  ones for the entrypoints you import: `hono` for the `/hono/*` adapters, `react`
47
41
  and `react-dom` for `/react` and `/react/components`. `vitest` is an optional
48
- peer dependency of the testing helpers.
42
+ peer dependency of the testing helpers. Import the part you use; there is no
43
+ root barrel or default export.
44
+
45
+ ## Protect an API
49
46
 
50
- Import the part you use; there is no root barrel or default export:
47
+ This Hono API accepts JWT access tokens from your issuer and requires the
48
+ `profile` scope. The [API protection guide](docs/guides/protect-an-api.md)
49
+ covers token introspection and scope checks.
51
50
 
52
51
  ```ts
53
- import { BffClient, DirectClient } from "@udibo/oauth2/client";
54
- import { HonoBff } from "@udibo/oauth2/hono/bff";
52
+ import { Hono } from "hono";
55
53
  import { HonoResourceServer } from "@udibo/oauth2/hono/resource-server";
54
+ import { JwksTokenReader } from "@udibo/oauth2/server/resource";
55
+
56
+ const tokenService = new JwksTokenReader({
57
+ issuer: "https://auth.example.com",
58
+ audience: "https://api.example.com",
59
+ getClient: (claims) => ({ id: String(claims.client_id) }),
60
+ getUser: (claims) => (claims.sub ? { id: claims.sub } : undefined),
61
+ });
62
+ const api = new HonoResourceServer({
63
+ resolve: () => ({ services: { tokenService } }),
64
+ });
65
+
66
+ const app = new Hono();
67
+ app.get("/api/me", api.protect("profile"), (c) =>
68
+ c.json(api.getContext(c).user),
69
+ );
56
70
  ```
57
71
 
58
- The package is pre-1.0. Minor releases may contain breaking changes; review the
59
- [stability policy](docs/stability.md) before upgrading.
72
+ ## Standards
73
+
74
+ The package implements these specifications, with the limits noted:
75
+
76
+ - [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) OAuth 2.0: authorization
77
+ code, client credentials, refresh token, and password grants; no implicit grant.
78
+ - [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) bearer tokens in the
79
+ `Authorization` header or a form body, with `WWW-Authenticate` challenges.
80
+ - [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) PKCE, `S256` unless you
81
+ register other methods.
82
+ - [RFC 7009](https://www.rfc-editor.org/rfc/rfc7009) revocation,
83
+ [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) introspection,
84
+ [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) server metadata, and
85
+ [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628) device authorization.
86
+ - [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) `iss` in authorization
87
+ responses. `DirectClient` and the BFF check it when the client is configured
88
+ with an `issuer`.
89
+ - [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) JWT access tokens, opt-in;
90
+ access tokens are opaque by default.
91
+ - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html):
92
+ the authorization code flow, ES256 ID tokens, `nonce`, and UserInfo. The
93
+ server does not act on `prompt` or `max_age`.
94
+ - OpenID Connect [Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
95
+ and [RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
96
+ - [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html),
97
+ as a receiver in the BFF only. You supply the `logout_token` verifier, and the
98
+ server sends no logout tokens.
60
99
 
61
100
  ## How the pieces fit
62
101
 
63
- For a browser app, use a backend-for-frontend (BFF). The browser holds an
64
- HttpOnly session cookie; the BFF exchanges authorization codes and keeps access
65
- and refresh tokens on the server. `BffClient` connects the browser to the BFF,
66
- and the React adapter uses that same client.
67
-
68
- ```mermaid
69
- sequenceDiagram
70
- participant Browser
71
- participant App as Your app / BFF
72
- participant Issuer as Udibo or your app's authorization server
73
- participant API as Your API
74
- Browser->>App: Start sign-in
75
- App-->>Browser: Redirect to authorization endpoint
76
- Browser->>Issuer: Sign in
77
- Issuer-->>Browser: Redirect back with code and state
78
- Browser->>App: Complete callback
79
- App->>Issuer: Exchange code with PKCE and client authentication
80
- Issuer-->>App: Tokens
81
- App-->>Browser: Session cookie
82
- Browser->>App: Request application data
83
- App->>API: Access token
84
- API-->>Browser: Application data, through the BFF
85
- ```
102
+ For a browser app, use a backend-for-frontend (BFF), so tokens stay on the
103
+ server. `BffClient` connects the browser to it, and the React adapter uses that
104
+ same client:
86
105
 
87
- `DirectClient` is for a process that holds tokens: your backend, a CLI, a native
88
- app, or a public browser client. Supply a client secret only on the server. For
89
- a browser app with a backend, start with the BFF guide.
106
+ 1. The browser starts sign-in at your BFF, which redirects it to the issuer's
107
+ authorization endpoint.
108
+ 2. The user signs in, and the issuer redirects back with a code and `state`.
109
+ 3. The BFF exchanges the code, with PKCE and client authentication, for tokens.
110
+ 4. The BFF keeps the tokens and sets an HttpOnly session cookie.
111
+ 5. Later requests carry the cookie; the BFF calls your API with the access
112
+ token and returns the data.
113
+
114
+ The [BFF guide](docs/guides/use-udibo.md#how-sign-in-flows) has this flow as a
115
+ sequence diagram.
90
116
 
91
- `HonoResourceServer` validates access tokens and enforces scopes on API routes.
117
+ `DirectClient` is for a process that holds tokens: your backend, a CLI, a native
118
+ app, or a public browser client. Supply a client secret only on the server.
92
119
  `HonoAuthorizationServer` issues tokens when your app hosts its own
93
120
  authorization server. The framework-independent core accepts standard `Request`
94
121
  and `Response` objects; Hono adapters add routing and middleware.
95
122
 
96
- For app-owned login, `CaptchaProvider` and `verifyCaptcha` let your route check
97
- a client-issued bot-challenge token before calling `IdentityService`. The
98
- package does not include a challenge provider; see the
123
+ For app-owned login, `verifyCaptcha` checks a bot-challenge token from a
124
+ `CaptchaProvider` you supply before your route calls `IdentityService`; see the
99
125
  [CAPTCHA route example](docs/guides/add-login.md#captcha-at-the-route-boundary).
100
126
 
101
127
  ## Examples
@@ -163,11 +189,9 @@ checks a consumer of every entrypoint, and imports each one on Node.
163
189
  | `/testing`, `/testing/contract`, `/hono/bff/testing`, `/react/testing` | Tested; need Vitest | Not supported | Not verified |
164
190
  | `udibo-oauth2` command (`bin`) | Tested | Not supported | Not supported |
165
191
 
166
- The `udibo-oauth2` command is a development tool: `udibo-oauth2 oidc keygen`
167
- prints an OIDC signing key, and `udibo-oauth2 idp dev` runs a local identity
168
- provider. Run it with `npx udibo-oauth2` once the package is installed; the
169
- [local identity provider guide](docs/guides/run-a-local-identity-provider.md)
170
- covers its options. Nothing on the library path imports it.
192
+ The `udibo-oauth2` development command prints OIDC signing keys (`oidc keygen`)
193
+ and runs a [local identity provider](docs/guides/run-a-local-identity-provider.md)
194
+ (`idp dev`). Nothing on the library path imports it.
171
195
 
172
196
  See [stability](docs/stability.md#runtime-support) for the support boundary.
173
197
 
@@ -184,6 +208,13 @@ cookie-session, OIDC, and optional-protection behavior. Identity feature
184
208
  examples apply to apps hosting their own login; Udibo clients use the hosted
185
209
  sign-in flow.
186
210
 
211
+ ## Upgrading
212
+
213
+ The package is pre-1.0, so a minor release may contain breaking changes. Each
214
+ release lists them under **⚠ BREAKING CHANGES** in [CHANGELOG.md](CHANGELOG.md).
215
+ The [stability policy](docs/stability.md) says what counts as public API and
216
+ what a patch release preserves.
217
+
187
218
  ## For coding agents
188
219
 
189
220
  Start with [llms.txt](llms.txt) for the task-to-guide map and integration rules.
@@ -289,15 +289,22 @@ export interface HonoBffBackchannelOptions {
289
289
  export interface BffCallbackError {
290
290
  /**
291
291
  * OAuth2-style error code: the IDP's `error` (e.g. `access_denied`),
292
- * `invalid_request` (missing `code`/`state`, or a `state` this browser never
293
- * started — see {@link HonoBffOptions.loginStateTtlMs}), or `invalid_grant`
292
+ * `invalid_request` (missing `code`/`state`, a `state` this browser never
293
+ * started — see {@link HonoBffOptions.loginStateTtlMs} — or an `iss` the
294
+ * client refused, see `DirectClient.verifyAuthorizationResponseIssuer`),
295
+ * `server_error` or `temporarily_unavailable` (discovery failed or
296
+ * `resolveOrigin` threw, so `iss` could not be checked; the description is
297
+ * fixed and the failure is in `cause`), or `invalid_grant`
294
298
  * (the code exchange failed — expired/replayed code, unknown `state` — or
295
299
  * a later step threw: `resolveUser` or the session store).
296
300
  */
297
301
  error: string;
298
302
  /** Human-readable description; never contains secrets. */
299
303
  error_description: string;
300
- /** The thrown cause for the `invalid_grant` case; otherwise `undefined`. */
304
+ /**
305
+ * The thrown cause for the `invalid_grant` case and for an `iss` that was
306
+ * refused or could not be checked; otherwise `undefined`.
307
+ */
301
308
  cause?: unknown;
302
309
  }
303
310
  /** Options accepted by the {@link HonoBff} constructor. */
@@ -823,8 +830,9 @@ export declare class HonoBff {
823
830
  */
824
831
  loginHandler(): Handler;
825
832
  /**
826
- * Callback handler — requires the login-state cookie to vouch for the
827
- * returned `state`, then exchanges the code and opens a session.
833
+ * Callback handler — checks the response's `iss` (RFC 9207), requires the
834
+ * login-state cookie to vouch for the returned `state`, then exchanges the
835
+ * code and opens a session.
828
836
  */
829
837
  callbackHandler(): Handler;
830
838
  /** Logout handler — revokes the refresh token and clears the session cookie. */
@@ -60,6 +60,7 @@ import { DEFAULT_MAX_RETURN_TO_LENGTH, loginContinuation as computeLoginContinua
60
60
  import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
61
61
  import { DEFAULT_SESSION_MAX_AGE_MS, sessionStoreMaxAgeMs, supportsBackchannelLogout, supportsRefreshLock, } from "./session-store.js";
62
62
  import { RotatedCookies } from "./_rotated-cookies.js";
63
+ import { isIssuerRefusal } from "../../../client/_issuer-refusal.js";
63
64
  import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
64
65
  const DEFAULT_ROTATED_COOKIE_GRACE_MS = 10_000;
65
66
  const DEFAULT_ROTATED_COOKIE_MAX_ENTRIES = 1000;
@@ -686,8 +687,9 @@ export class HonoBff {
686
687
  };
687
688
  }
688
689
  /**
689
- * Callback handler — requires the login-state cookie to vouch for the
690
- * returned `state`, then exchanges the code and opens a session.
690
+ * Callback handler — checks the response's `iss` (RFC 9207), requires the
691
+ * login-state cookie to vouch for the returned `state`, then exchanges the
692
+ * code and opens a session.
691
693
  */
692
694
  callbackHandler() {
693
695
  return async (c) => {
@@ -699,6 +701,9 @@ export class HonoBff {
699
701
  error: error.error,
700
702
  error_description: error.error_description,
701
703
  }, 400);
704
+ const issuerRefusal = await this.#issuerRefusal(c, params);
705
+ if (issuerRefusal)
706
+ return await fail(issuerRefusal);
702
707
  if (params.get("error")) {
703
708
  return await fail({
704
709
  error: params.get("error"),
@@ -777,6 +782,30 @@ export class HonoBff {
777
782
  }
778
783
  };
779
784
  }
785
+ async #issuerRefusal(c, params) {
786
+ try {
787
+ await this.#client.verifyAuthorizationResponseIssuer(params, {
788
+ origin: this.#options.resolveOrigin?.(c),
789
+ });
790
+ return undefined;
791
+ }
792
+ catch (cause) {
793
+ if (isIssuerRefusal(cause)) {
794
+ return {
795
+ error: "invalid_request",
796
+ error_description: cause.message,
797
+ cause,
798
+ };
799
+ }
800
+ const unreachable = isOAuth2Error(cause) &&
801
+ cause.extensions.error === "temporarily_unavailable";
802
+ return {
803
+ error: unreachable ? "temporarily_unavailable" : "server_error",
804
+ error_description: "the authorization response issuer could not be checked",
805
+ cause,
806
+ };
807
+ }
808
+ }
780
809
  /** Logout handler — revokes the refresh token and clears the session cookie. */
781
810
  logoutHandler() {
782
811
  return async (c) => {
@@ -0,0 +1,3 @@
1
+ import { type OAuth2Error } from "../errors.ts";
2
+ export declare function issuerRefusal(message: string): OAuth2Error;
3
+ export declare function isIssuerRefusal(error: unknown): error is OAuth2Error;
@@ -0,0 +1,10 @@
1
+ import { InvalidRequestError } from "../errors.js";
2
+ const refusals = new WeakSet();
3
+ export function issuerRefusal(message) {
4
+ const error = new InvalidRequestError(message);
5
+ refusals.add(error);
6
+ return error;
7
+ }
8
+ export function isIssuerRefusal(error) {
9
+ return error instanceof Error && refusals.has(error);
10
+ }
@@ -161,6 +161,15 @@ export interface AuthorizationRedirect {
161
161
  /** Opaque `state` value the callback will echo back. */
162
162
  state: string;
163
163
  }
164
+ /** Options accepted by {@link DirectClient.verifyAuthorizationResponseIssuer}. */
165
+ export interface VerifyIssuerOptions {
166
+ /**
167
+ * The origin {@link DirectClient.login} retargeted the authorize URL at, if
168
+ * any. The issuer moved to that origin, path kept, is accepted as well as
169
+ * the discovered one.
170
+ */
171
+ origin?: string;
172
+ }
164
173
  /** Result of {@link DirectClient.handleAuthorizationCallback}. */
165
174
  export interface AuthorizationCallbackResult {
166
175
  /** Token bundle saved on the client. */
@@ -402,13 +411,20 @@ export declare class DirectClient extends OAuth2ClientBase {
402
411
  * than redeeming the single-use code twice. A call after it settles redeems
403
412
  * again, and fails.
404
413
  *
414
+ * The response's `iss` is checked first, error responses included, as
415
+ * {@link verifyAuthorizationResponseIssuer} describes. A refused response
416
+ * is never redeemed.
417
+ *
405
418
  * @param input The callback URL, its query string, or the parsed params.
419
+ * @param options The `origin` given to {@link login}, when it retargeted
420
+ * the authorize URL, so the issuer at that origin is accepted as `iss`.
406
421
  * @returns The persisted tokens and the `returnTo` recorded at login.
407
422
  * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
408
423
  * reached or its response does not arrive in full before the deadline;
409
424
  * `cause` is the transport failure.
410
425
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
411
426
  * exchange fails.
427
+ * @throws {InvalidRequestError} when the response's `iss` is refused.
412
428
  * @throws {TypeError} when `code` or `state` is missing, or outside a
413
429
  * browser when the client has no `authRequestStorage` or no
414
430
  * `refreshTokenStorage`; checked before the pending request is claimed.
@@ -425,7 +441,39 @@ export declare class DirectClient extends OAuth2ClientBase {
425
441
  * location.replace(returnTo ?? "/");
426
442
  * ```
427
443
  */
428
- handleAuthorizationCallback(input: URL | URLSearchParams | string): Promise<AuthorizationCallbackResult>;
444
+ handleAuthorizationCallback(input: URL | URLSearchParams | string, options?: VerifyIssuerOptions): Promise<AuthorizationCallbackResult>;
445
+ /**
446
+ * Checks an authorization response's `iss` parameter (RFC 9207), which
447
+ * tells a client that signs in with more than one authorization server
448
+ * which of them answered. {@link handleAuthorizationCallback} calls it; a
449
+ * server that completes the flow with {@link exchangeAuthorizationCode}
450
+ * calls it first, on every response, error responses included.
451
+ *
452
+ * For a client configured with an `issuer`, it refuses an `iss` that is
453
+ * not exactly the discovered issuer, an `iss` given more than once, and a
454
+ * missing `iss` when the metadata advertises
455
+ * `authorization_response_iss_parameter_supported: true`. A missing `iss`
456
+ * is accepted from a server that does not advertise it. A client
457
+ * configured with `endpoints` has no issuer to compare against, so it
458
+ * accepts every response.
459
+ *
460
+ * @param input The callback URL, its query string, or the parsed params.
461
+ * @param options The `origin` the authorize URL was retargeted at, if any.
462
+ * @throws {InvalidRequestError} when the response is refused.
463
+ * @throws {ServerError} when discovery fails.
464
+ * @throws {TemporarilyUnavailableError} when the discovery endpoint cannot
465
+ * be reached.
466
+ *
467
+ * @example
468
+ * ```ts
469
+ * import { DirectClient } from "@udibo/oauth2/client";
470
+ *
471
+ * declare const client: DirectClient;
472
+ * declare const callbackUrl: string;
473
+ * await client.verifyAuthorizationResponseIssuer(callbackUrl);
474
+ * ```
475
+ */
476
+ verifyAuthorizationResponseIssuer(input: URL | URLSearchParams | string, options?: VerifyIssuerOptions): Promise<void>;
429
477
  /**
430
478
  * **Advanced (BFF / multi-session servers).** SPA, mobile, and CLI consumers
431
479
  * should call {@link handleAuthorizationCallback} instead — that variant
@@ -444,7 +492,8 @@ export declare class DirectClient extends OAuth2ClientBase {
444
492
  * two callbacks racing on one `state` reach the token endpoint at most once
445
493
  * (atomically so for a storage that implements
446
494
  * {@link AuthRequestStorage.take}). Emits nothing — the caller owns session
447
- * semantics.
495
+ * semantics. It does not see the response's `iss`: call
496
+ * {@link verifyAuthorizationResponseIssuer} before it.
448
497
  *
449
498
  * @param code The authorization code from the callback.
450
499
  * @param state The `state` the callback echoed back.
@@ -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";
@@ -61,6 +61,11 @@ export interface AuthorizationServerMetadata {
61
61
  scopes_supported?: string[];
62
62
  /** Supported response types. */
63
63
  response_types_supported?: string[];
64
+ /**
65
+ * Whether every authorization response carries the `iss` parameter
66
+ * (RFC 9207 §3). A client that sees `true` refuses a response without one.
67
+ */
68
+ authorization_response_iss_parameter_supported?: boolean;
64
69
  /** Supported grant types. */
65
70
  grant_types_supported?: string[];
66
71
  /** URL of the JWKS document (OIDC Discovery / RFC 8414). */
@@ -610,6 +610,11 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
610
610
  * responses from this endpoint, since there is no verified place to send the
611
611
  * user back to.
612
612
  *
613
+ * Every redirect to the client, the code and the error alike, carries `iss`
614
+ * set to the request's resolved issuer (RFC 9207), replacing any `iss` the
615
+ * registered `redirect_uri` itself carries. A request that resolves no
616
+ * `issuer` sends none.
617
+ *
613
618
  * Requires a grant extending {@linkcode AuthorizationCodeGrant} registered
614
619
  * under `authorization_code`; without one the endpoint answers 500
615
620
  * `server_error` (or rethrows, with `throwOnError`).
@@ -719,7 +724,9 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
719
724
  * authorization code grant's own `challengeMethods`, so a server that
720
725
  * registers an extra method (or drops `S256`) advertises what it will
721
726
  * actually accept. It is omitted entirely when no authorization code grant
722
- * is registered, since PKCE has nothing to apply to.
727
+ * is registered, since PKCE has nothing to apply to. The same condition
728
+ * governs `authorization_response_iss_parameter_supported: true`
729
+ * (RFC 9207 §3): the authorization endpoint's redirects carry `iss`.
723
730
  *
724
731
  * `token_endpoint_auth_methods_supported` always includes `none`, the
725
732
  * RFC 7591 method for a client with no secret. The token endpoint accepts it
@@ -134,6 +134,11 @@ export const ENDPOINT_PATHS = Object.fromEntries(ENDPOINT_ENTRIES.map(([key, { p
134
134
  * ```
135
135
  */
136
136
  export const ENDPOINT_METHODS = Object.fromEntries(ENDPOINT_ENTRIES.map(([key, { methods }]) => [key, methods]));
137
+ function authorizationResponseRedirect(target) {
138
+ if (target.issuer)
139
+ target.url.searchParams.set("iss", target.issuer);
140
+ return Response.redirect(target.url.toString(), 302);
141
+ }
137
142
  function isAuthorizationCodeGrant(grant) {
138
143
  return grant instanceof AuthorizationCodeGrant;
139
144
  }
@@ -742,6 +747,11 @@ export class AuthorizationServer extends ResourceServer {
742
747
  * responses from this endpoint, since there is no verified place to send the
743
748
  * user back to.
744
749
  *
750
+ * Every redirect to the client, the code and the error alike, carries `iss`
751
+ * set to the request's resolved issuer (RFC 9207), replacing any `iss` the
752
+ * registered `redirect_uri` itself carries. A request that resolves no
753
+ * `issuer` sends none.
754
+ *
745
755
  * Requires a grant extending {@linkcode AuthorizationCodeGrant} registered
746
756
  * under `authorization_code`; without one the endpoint answers 500
747
757
  * `server_error` (or rethrows, with `throwOnError`).
@@ -757,16 +767,17 @@ export class AuthorizationServer extends ResourceServer {
757
767
  return await this.#authorize(request, target, authenticateUser, handleConsent);
758
768
  }
759
769
  catch (error) {
760
- return this.#authorizeErrorResponse(error, target.url);
770
+ return this.#authorizeErrorResponse(error, target);
761
771
  }
762
772
  }
763
773
  async #authorize(request, target, authenticateUser, handleConsent) {
764
774
  const params = this.parseAuthorizeParameters(request);
765
775
  const grant = this.#authorizationCodeGrant();
766
- const { services } = await this.authorizationContext(request);
776
+ const { services, issuer } = await this.authorizationContext(request);
767
777
  const client = await this.#resolveAuthorizeClient(grant, params.clientId, services);
768
778
  const redirectUrl = this.#resolveRedirectTarget(client, params.redirectUri);
769
779
  target.url = redirectUrl;
780
+ target.issuer = issuer;
770
781
  if (!params.state) {
771
782
  throw new InvalidRequestError("state required");
772
783
  }
@@ -807,9 +818,10 @@ export class AuthorizationServer extends ResourceServer {
807
818
  authenticationContext,
808
819
  }, request);
809
820
  redirectUrl.searchParams.set("code", authorizationCode.code);
810
- return Response.redirect(redirectUrl.toString(), 302);
821
+ return authorizationResponseRedirect({ url: redirectUrl, issuer });
811
822
  }
812
- #authorizeErrorResponse(error, redirectUrl) {
823
+ #authorizeErrorResponse(error, target) {
824
+ const redirectUrl = target.url;
813
825
  if (!redirectUrl || !(error instanceof Error) || !isOAuth2Error(error)) {
814
826
  return this.#handleEndpointError(error);
815
827
  }
@@ -824,7 +836,10 @@ export class AuthorizationServer extends ResourceServer {
824
836
  if (errorUri) {
825
837
  searchParams.set("error_uri", errorUri);
826
838
  }
827
- return Response.redirect(redirectUrl.toString(), 302);
839
+ return authorizationResponseRedirect({
840
+ url: redirectUrl,
841
+ issuer: target.issuer,
842
+ });
828
843
  }
829
844
  #authorizationCodeGrant() {
830
845
  const grant = this.grants[AUTHORIZATION_CODE_GRANT_TYPE];
@@ -1136,7 +1151,9 @@ export class AuthorizationServer extends ResourceServer {
1136
1151
  * authorization code grant's own `challengeMethods`, so a server that
1137
1152
  * registers an extra method (or drops `S256`) advertises what it will
1138
1153
  * actually accept. It is omitted entirely when no authorization code grant
1139
- * is registered, since PKCE has nothing to apply to.
1154
+ * is registered, since PKCE has nothing to apply to. The same condition
1155
+ * governs `authorization_response_iss_parameter_supported: true`
1156
+ * (RFC 9207 §3): the authorization endpoint's redirects carry `iss`.
1140
1157
  *
1141
1158
  * `token_endpoint_auth_methods_supported` always includes `none`, the
1142
1159
  * RFC 7591 method for a client with no secret. The token endpoint accepts it
@@ -1178,6 +1195,9 @@ export class AuthorizationServer extends ResourceServer {
1178
1195
  metadata.code_challenge_methods_supported = challengeMethods;
1179
1196
  }
1180
1197
  metadata.response_types_supported = hasAuthCodeGrant ? ["code"] : [];
1198
+ if (hasAuthCodeGrant) {
1199
+ metadata.authorization_response_iss_parameter_supported = true;
1200
+ }
1181
1201
  const scopesSupported = context.scopesSupported ?? this.scopesSupported;
1182
1202
  if (scopesSupported) {
1183
1203
  metadata.scopes_supported = scopesSupported;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",