@udibo/oauth2 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,102 @@ 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`, UserInfo, and the
93
+ `prompt`, `max_age` and `acr_values` controls, with `auth_time` and `acr` in
94
+ the ID token. Your login callback performs any re-authentication they need,
95
+ and there is no `claims` request parameter.
96
+ - OpenID Connect [Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
97
+ and [RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
98
+ - [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html),
99
+ as a receiver in the BFF only. You supply the `logout_token` verifier, and the
100
+ server sends no logout tokens.
60
101
 
61
102
  ## How the pieces fit
62
103
 
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
- ```
104
+ For a browser app, use a backend-for-frontend (BFF), so tokens stay on the
105
+ server. `BffClient` connects the browser to it, and the React adapter uses that
106
+ same client:
86
107
 
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.
108
+ 1. The browser starts sign-in at your BFF, which redirects it to the issuer's
109
+ authorization endpoint.
110
+ 2. The user signs in, and the issuer redirects back with a code and `state`.
111
+ 3. The BFF exchanges the code, with PKCE and client authentication, for tokens.
112
+ 4. The BFF keeps the tokens and sets an HttpOnly session cookie.
113
+ 5. Later requests carry the cookie; the BFF calls your API with the access
114
+ token and returns the data.
115
+
116
+ The [BFF guide](docs/guides/use-udibo.md#how-sign-in-flows) has this flow as a
117
+ sequence diagram.
90
118
 
91
- `HonoResourceServer` validates access tokens and enforces scopes on API routes.
119
+ `DirectClient` is for a process that holds tokens: your backend, a CLI, a native
120
+ app, or a public browser client. Supply a client secret only on the server.
92
121
  `HonoAuthorizationServer` issues tokens when your app hosts its own
93
122
  authorization server. The framework-independent core accepts standard `Request`
94
123
  and `Response` objects; Hono adapters add routing and middleware.
95
124
 
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
125
+ For app-owned login, `verifyCaptcha` checks a bot-challenge token from a
126
+ `CaptchaProvider` you supply before your route calls `IdentityService`; see the
99
127
  [CAPTCHA route example](docs/guides/add-login.md#captcha-at-the-route-boundary).
100
128
 
101
129
  ## Examples
@@ -163,11 +191,9 @@ checks a consumer of every entrypoint, and imports each one on Node.
163
191
  | `/testing`, `/testing/contract`, `/hono/bff/testing`, `/react/testing` | Tested; need Vitest | Not supported | Not verified |
164
192
  | `udibo-oauth2` command (`bin`) | Tested | Not supported | Not supported |
165
193
 
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.
194
+ The `udibo-oauth2` development command prints OIDC signing keys (`oidc keygen`)
195
+ and runs a [local identity provider](docs/guides/run-a-local-identity-provider.md)
196
+ (`idp dev`). Nothing on the library path imports it.
171
197
 
172
198
  See [stability](docs/stability.md#runtime-support) for the support boundary.
173
199
 
@@ -184,6 +210,13 @@ cookie-session, OIDC, and optional-protection behavior. Identity feature
184
210
  examples apply to apps hosting their own login; Udibo clients use the hosted
185
211
  sign-in flow.
186
212
 
213
+ ## Upgrading
214
+
215
+ The package is pre-1.0, so a minor release may contain breaking changes. Each
216
+ release lists them under **⚠ BREAKING CHANGES** in [CHANGELOG.md](CHANGELOG.md).
217
+ The [stability policy](docs/stability.md) says what counts as public API and
218
+ what a patch release preserves.
219
+
187
220
  ## For coding agents
188
221
 
189
222
  Start with [llms.txt](llms.txt) for the task-to-guide map and integration rules.
@@ -35,7 +35,7 @@
35
35
  import { Hono } from "hono";
36
36
  import type { Context, Handler, MiddlewareHandler } from "hono";
37
37
  import type { RequireConditions } from "../../models/authorization.ts";
38
- import type { AuthenticationContext } from "../../models/authentication.ts";
38
+ import type { AuthenticationContext, AuthenticationRequest } from "../../models/authentication.ts";
39
39
  import type { ClientInterface } from "../../models/client.ts";
40
40
  import type { AbstractScope, BasicScope } from "../../models/scope.ts";
41
41
  import { AuthorizationServer, type AuthorizationServerOptions } from "../../server/authorization-server.ts";
@@ -47,20 +47,29 @@ import type { AuthenticatedContext } from "../../server/resource-server.ts";
47
47
  * or session variables without reconstructing a {@link Request}. Return one
48
48
  * of:
49
49
  *
50
- * - `{ user, authorizedScope?, authenticationContext? }` — user is authenticated;
51
- * the authorize flow continues. Supply only verified event claims in
52
- * `authenticationContext`; the code and its tokens retain that snapshot.
50
+ * - `{ user, authorizedScope?, authenticationContext?, reauthenticated? }` —
51
+ * user is authenticated; the authorize flow continues. Supply only verified
52
+ * event claims in `authenticationContext`; the code and its tokens retain
53
+ * that snapshot. Set `reauthenticated: true` only when the user actively
54
+ * authenticated in response to this authorization request.
53
55
  * - `null` — explicit denial; the framework redirects to `redirect_uri` with
54
56
  * `error=access_denied`.
55
57
  * - A `Response` (typically `c.redirect(...)` to a login page) —
56
58
  * short-circuits the authorize flow and is returned to the browser as-is.
57
59
  * This is how login redirects, interstitials, etc. are wired without
58
- * wrapping the framework handler.
60
+ * wrapping the framework handler. Under `prompt=none` the server answers
61
+ * `login_required` instead.
62
+ *
63
+ * The second argument, always passed, carries the request's validated
64
+ * `prompt`, `max_age` and `acr_values`. The server answers `login_required`
65
+ * when the returned user fails `satisfiesAuthenticationRequest`, so send the
66
+ * user to sign in again whenever the session cannot meet it.
59
67
  */
60
- export type HonoAuthenticateUserFn<User> = (c: Context) => Promise<{
68
+ export type HonoAuthenticateUserFn<User> = (c: Context, authentication?: AuthenticationRequest) => Promise<{
61
69
  user: User;
62
70
  authorizedScope?: AbstractScope;
63
71
  authenticationContext?: AuthenticationContext;
72
+ reauthenticated?: boolean;
64
73
  } | Response | null>;
65
74
  /**
66
75
  * Collects consent from the resource owner for the requested scope.
@@ -76,9 +85,13 @@ export type HonoAuthenticateUserFn<User> = (c: Context) => Promise<{
76
85
  * - A `Response` (typically `c.html(...)` rendering a consent page) —
77
86
  * short-circuits the authorize flow and is returned to the browser as-is.
78
87
  * The consent UI should resubmit to the authorize endpoint with a
79
- * decision parameter the callback can read on the next request.
88
+ * decision parameter the callback can read on the next request. Under
89
+ * `prompt=none` the server answers `consent_required` instead.
90
+ *
91
+ * With `prompt=consent` the server calls it even when the scope is already
92
+ * authorized, and the fifth argument, always passed, says so.
80
93
  */
81
- export type HonoHandleConsentFn<Client extends ClientInterface, S extends AbstractScope> = (c: Context, client: Client, requestedScope: S | undefined, user: unknown) => Promise<{
94
+ export type HonoHandleConsentFn<Client extends ClientInterface, S extends AbstractScope> = (c: Context, client: Client, requestedScope: S | undefined, user: unknown, authentication?: AuthenticationRequest) => Promise<{
82
95
  approved: boolean;
83
96
  scope?: S;
84
97
  } | Response>;
@@ -104,9 +104,9 @@ export class HonoAuthorizationServer extends AuthorizationServer {
104
104
  authorizeHandler(options) {
105
105
  const { authenticateUser, handleConsent } = options;
106
106
  return (c) => {
107
- const auth = () => authenticateUser(c);
107
+ const auth = (_request, authentication) => authenticateUser(c, authentication);
108
108
  const consent = handleConsent
109
- ? (client, requestedScope, user) => handleConsent(c, client, requestedScope, user)
109
+ ? (client, requestedScope, user, authentication) => handleConsent(c, client, requestedScope, user, authentication)
110
110
  : undefined;
111
111
  return this.handleAuthorizeRequest(c.req.raw, auth, consent);
112
112
  };
@@ -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) => {
@@ -21,6 +21,7 @@ import { AuthorizationCodeGrant } from "../../server/grants/authorization-code.j
21
21
  import { ClientCredentialsGrant } from "../../server/grants/client-credentials.js";
22
22
  import { PasswordGrant } from "../../server/grants/password.js";
23
23
  import { RefreshTokenGrant } from "../../server/grants/refresh-token.js";
24
+ import { satisfiesAuthenticationRequest } from "../../server/authentication-request.js";
24
25
  import { StaticSigningKeyProvider, } from "../../server/signing-keys.js";
25
26
  import { generateCodeChallenge, generateCodeVerifier, generateState, } from "../../utils/pkce.js";
26
27
  import { DEFAULT_MAX_BODY_BYTES, readBoundedBody } from "../../utils/_body.js";
@@ -43,6 +44,14 @@ const WILDCARD_HOSTNAMES = new Set(["0.0.0.0", "::", "[::]"]);
43
44
  export function isLoopbackHostname(hostname) {
44
45
  return LOOPBACK_HOSTNAMES.has(hostname) || hostname.startsWith("127.");
45
46
  }
47
+ const REAUTHENTICATION_WINDOW_SECONDS = 600;
48
+ function newSession(userId, signedInFor) {
49
+ return {
50
+ userId,
51
+ authTime: Math.floor(Date.now() / 1000),
52
+ ...(signedInFor === undefined ? {} : { signedInFor }),
53
+ };
54
+ }
46
55
  async function createState(config, signingKey, issuer) {
47
56
  const userService = new MemoryUserService();
48
57
  const clientService = new MemoryClientService(userService);
@@ -181,21 +190,33 @@ function formValue(form, name) {
181
190
  const value = form.get(name);
182
191
  return typeof value === "string" ? value : undefined;
183
192
  }
184
- async function currentUser(state, request) {
193
+ function currentSession(state, request) {
185
194
  const sessionId = readCookie(request, SESSION_COOKIE);
186
- if (!sessionId)
187
- return undefined;
188
- const userId = state.sessions.get(sessionId);
189
- if (!userId)
190
- return undefined;
191
- return await state.userService.get(userId);
195
+ return sessionId ? state.sessions.get(sessionId) : undefined;
196
+ }
197
+ async function currentUser(state, request) {
198
+ const session = currentSession(state, request);
199
+ return session ? await state.userService.get(session.userId) : undefined;
192
200
  }
193
201
  function authenticateUserFor(state, config, request) {
194
- return async () => {
195
- const user = await currentUser(state, request);
196
- if (user)
197
- return { user };
198
- return htmlResponse(loginPage({ returnTo: pathAndQuery(request), users: config.users }));
202
+ return async (_request, authentication) => {
203
+ const returnTo = pathAndQuery(request);
204
+ const session = currentSession(state, request);
205
+ const user = session && (await state.userService.get(session.userId));
206
+ if (session && user) {
207
+ const result = {
208
+ user,
209
+ authenticationContext: { auth_time: session.authTime },
210
+ reauthenticated: session.signedInFor === returnTo &&
211
+ Math.floor(Date.now() / 1000) - session.authTime <=
212
+ REAUTHENTICATION_WINDOW_SECONDS,
213
+ };
214
+ if (!authentication ||
215
+ satisfiesAuthenticationRequest(authentication, result)) {
216
+ return result;
217
+ }
218
+ }
219
+ return htmlResponse(loginPage({ returnTo, users: config.users }));
199
220
  };
200
221
  }
201
222
  function handleConsentFor(state, request) {
@@ -258,7 +279,7 @@ async function handleLoginSubmission(state, config, request) {
258
279
  return htmlResponse(loginPage({ returnTo, users: config.users, error }), 401);
259
280
  }
260
281
  const sessionId = crypto.randomUUID();
261
- state.sessions.set(sessionId, user.id);
282
+ state.sessions.set(sessionId, newSession(user.id, returnTo));
262
283
  return redirect(returnTo, {
263
284
  "set-cookie": cookie(SESSION_COOKIE, sessionId),
264
285
  });
@@ -334,7 +355,7 @@ async function handleAdminSession(state, request) {
334
355
  return jsonError(400, 'no seeded user matched "userId" or "username"');
335
356
  }
336
357
  const sessionId = crypto.randomUUID();
337
- state.sessions.set(sessionId, user.id);
358
+ state.sessions.set(sessionId, newSession(user.id));
338
359
  return new Response(JSON.stringify({
339
360
  sessionId,
340
361
  cookie: SESSION_COOKIE,
@@ -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.