@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 +94 -61
- package/dist/adapters/hono/authorization-server.d.ts +21 -8
- package/dist/adapters/hono/authorization-server.js +2 -2
- package/dist/adapters/hono/bff/bff.d.ts +13 -5
- package/dist/adapters/hono/bff/bff.js +31 -2
- package/dist/cli/idp/server.js +35 -14
- 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/errors.d.ts +34 -0
- package/dist/errors.js +58 -0
- package/dist/models/authentication.d.ts +28 -0
- package/dist/models/authorization-code.d.ts +3 -3
- package/dist/models/responses.d.ts +13 -2
- package/dist/server/authentication-request.d.ts +60 -0
- package/dist/server/authentication-request.js +102 -0
- package/dist/server/authorization-server.d.ts +63 -8
- package/dist/server/authorization-server.js +80 -17
- package/dist/server/mod.d.ts +3 -2
- package/dist/server/mod.js +2 -1
- 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,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
|
-
|
|
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`, 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)
|
|
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
|
-
```
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
`
|
|
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, `
|
|
97
|
-
|
|
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
|
|
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.
|
|
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? }` —
|
|
51
|
-
* the authorize flow continues. Supply only verified
|
|
52
|
-
* `authenticationContext`; the code and its tokens retain
|
|
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`,
|
|
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) => {
|
package/dist/cli/idp/server.js
CHANGED
|
@@ -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
|
-
|
|
193
|
+
function currentSession(state, request) {
|
|
185
194
|
const sessionId = readCookie(request, SESSION_COOKIE);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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,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.
|