@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 +92 -61
- package/dist/adapters/hono/bff/bff.d.ts +13 -5
- package/dist/adapters/hono/bff/bff.js +31 -2
- package/dist/client/_issuer-refusal.d.ts +3 -0
- package/dist/client/_issuer-refusal.js +10 -0
- package/dist/client/direct-client.d.ts +51 -2
- package/dist/client/direct-client.js +101 -17
- package/dist/client/mod.d.ts +1 -1
- package/dist/models/responses.d.ts +5 -0
- package/dist/server/authorization-server.d.ts +8 -1
- package/dist/server/authorization-server.js +26 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# @udibo/oauth2
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/@udibo/oauth2) [](https://jsr.io/@udibo/oauth2) [](https://jsr.io/@udibo/oauth2/score) [](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml?query=branch%3Amain) [](LICENSE) [](https://www.npmjs.com/package/@udibo/oauth2) [](https://www.npmjs.com/package/@udibo/oauth2)
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
59
|
-
|
|
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)
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
`
|
|
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, `
|
|
97
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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`,
|
|
293
|
-
* started — see {@link HonoBffOptions.loginStateTtlMs}
|
|
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
|
-
/**
|
|
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 —
|
|
827
|
-
* returned `state`, then exchanges the
|
|
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 —
|
|
690
|
-
* returned `state`, then exchanges the
|
|
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,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
|
-
|
|
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 #
|
|
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;
|
package/dist/client/mod.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
821
|
+
return authorizationResponseRedirect({ url: redirectUrl, issuer });
|
|
811
822
|
}
|
|
812
|
-
#authorizeErrorResponse(error,
|
|
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
|
|
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.
|
|
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",
|