@udibo/oauth2 0.4.1 → 0.5.1
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 +3 -3
- package/SECURITY.md +0 -4
- package/esm/adapters/hono/_common.d.ts +10 -13
- package/esm/adapters/hono/_common.js +11 -20
- package/esm/adapters/hono/authorization-server.d.ts +16 -10
- package/esm/adapters/hono/authorization-server.js +13 -8
- package/esm/adapters/hono/bff/auth-request-store.d.ts +4 -0
- package/esm/adapters/hono/bff/auth-request-store.js +4 -0
- package/esm/adapters/hono/bff/bff.d.ts +29 -18
- package/esm/adapters/hono/bff/bff.js +15 -11
- package/esm/adapters/hono/bff/proxy.js +7 -55
- package/esm/adapters/hono/bff/session-store.d.ts +9 -9
- package/esm/adapters/hono/bff/session-store.js +3 -2
- package/esm/adapters/hono/bff/testing.d.ts +14 -3
- package/esm/adapters/hono/bff/testing.js +14 -3
- package/esm/adapters/hono/identity.d.ts +5 -3
- package/esm/adapters/hono/identity.js +1 -17
- package/esm/adapters/hono/resource-server.d.ts +3 -2
- package/esm/adapters/hono/resource-server.js +3 -2
- package/esm/client/_http.d.ts +4 -7
- package/esm/client/_http.js +4 -37
- package/esm/client/base.d.ts +3 -6
- package/esm/client/bff-client.d.ts +7 -6
- package/esm/client/bff-client.js +7 -6
- package/esm/client/browser-storage.d.ts +33 -34
- package/esm/client/browser-storage.js +47 -32
- package/esm/client/check.d.ts +10 -3
- package/esm/client/check.js +28 -8
- package/esm/client/direct-client.d.ts +49 -29
- package/esm/client/direct-client.js +59 -59
- package/esm/client/discovery-cache.d.ts +12 -5
- package/esm/client/discovery-cache.js +11 -4
- package/esm/client/events.d.ts +11 -3
- package/esm/client/events.js +1 -1
- package/esm/client/mod.d.ts +6 -4
- package/esm/client/mod.js +5 -3
- package/esm/client/storage.d.ts +53 -23
- package/esm/client/storage.js +43 -15
- package/esm/errors.d.ts +15 -12
- package/esm/errors.js +12 -8
- package/esm/identity/delivery.d.ts +7 -7
- package/esm/identity/delivery.js +1 -2
- package/esm/identity/errors.d.ts +10 -4
- package/esm/identity/errors.js +4 -1
- package/esm/identity/events.d.ts +21 -5
- package/esm/identity/events.js +4 -2
- package/esm/identity/external/_shared.d.ts +5 -7
- package/esm/identity/external/_shared.js +5 -7
- package/esm/identity/external/_token-exchange.d.ts +3 -6
- package/esm/identity/external/_token-exchange.js +2 -5
- package/esm/identity/external/apple-client-secret.d.ts +13 -8
- package/esm/identity/external/apple-client-secret.js +9 -6
- package/esm/identity/external/apple.d.ts +17 -10
- package/esm/identity/external/apple.js +12 -9
- package/esm/identity/external/discord.d.ts +5 -1
- package/esm/identity/external/errors.d.ts +6 -2
- package/esm/identity/external/errors.js +6 -2
- package/esm/identity/external/flow.d.ts +9 -6
- package/esm/identity/external/flow.js +6 -5
- package/esm/identity/external/github.d.ts +7 -3
- package/esm/identity/external/github.js +2 -2
- package/esm/identity/external/google.d.ts +5 -1
- package/esm/identity/external/oauth2.d.ts +13 -2
- package/esm/identity/external/oauth2.js +3 -3
- package/esm/identity/external/oidc.d.ts +21 -10
- package/esm/identity/external/oidc.js +11 -6
- package/esm/identity/external/provider.d.ts +4 -2
- package/esm/identity/identifier.d.ts +6 -5
- package/esm/identity/identifier.js +6 -5
- package/esm/identity/lockout.d.ts +4 -3
- package/esm/identity/lockout.js +4 -3
- package/esm/identity/mfa/mod.d.ts +2 -1
- package/esm/identity/mfa/mod.js +2 -1
- package/esm/identity/mfa/recovery-codes.d.ts +2 -1
- package/esm/identity/mfa/service.d.ts +29 -11
- package/esm/identity/mfa/service.js +35 -11
- package/esm/identity/mfa/totp.d.ts +4 -2
- package/esm/identity/mfa/totp.js +4 -2
- package/esm/identity/migration.d.ts +8 -7
- package/esm/identity/migration.js +9 -12
- package/esm/identity/mod.d.ts +2 -2
- package/esm/identity/mod.js +2 -2
- package/esm/identity/otp.d.ts +23 -16
- package/esm/identity/otp.js +13 -12
- package/esm/identity/password-policy.d.ts +3 -3
- package/esm/identity/password-policy.js +3 -3
- package/esm/identity/password.d.ts +23 -20
- package/esm/identity/password.js +15 -16
- package/esm/identity/rate-limit.d.ts +1 -7
- package/esm/identity/rate-limit.js +1 -2
- package/esm/identity/service.d.ts +76 -44
- package/esm/identity/service.js +86 -60
- package/esm/identity/session.d.ts +5 -4
- package/esm/identity/session.js +5 -4
- package/esm/identity/token-flow.d.ts +8 -8
- package/esm/identity/token-flow.js +5 -6
- package/esm/models/client.d.ts +7 -4
- package/esm/models/token.d.ts +2 -3
- package/esm/react/callback.d.ts +3 -3
- package/esm/react/callback.js +3 -3
- package/esm/react/components/class-names.d.ts +3 -6
- package/esm/react/components/class-names.js +3 -6
- package/esm/react/components/internal.d.ts +7 -17
- package/esm/react/components/internal.js +7 -17
- package/esm/react/components/mod.d.ts +7 -5
- package/esm/react/components/mod.js +7 -5
- package/esm/react/components/types.d.ts +8 -6
- package/esm/react/context.d.ts +3 -3
- package/esm/react/provider.d.ts +3 -2
- package/esm/react/provider.js +0 -13
- package/esm/react/require-auth.d.ts +0 -3
- package/esm/react/require-auth.js +0 -3
- package/esm/react/testing.d.ts +3 -2
- package/esm/react/testing.js +3 -3
- package/esm/react/use-oauth2.d.ts +1 -1
- package/esm/server/authorization-server.d.ts +81 -54
- package/esm/server/authorization-server.js +76 -100
- package/esm/server/grants/authorization-code.d.ts +7 -3
- package/esm/server/grants/authorization-code.js +6 -2
- package/esm/server/grants/device-authorization.d.ts +1 -1
- package/esm/server/grants/grant.d.ts +15 -8
- package/esm/server/grants/grant.js +26 -18
- package/esm/server/grants/password.d.ts +1 -1
- package/esm/server/grants/refresh-token.d.ts +6 -3
- package/esm/server/grants/refresh-token.js +4 -3
- package/esm/server/introspection-token-reader.d.ts +16 -7
- package/esm/server/introspection-token-reader.js +9 -3
- package/esm/server/jwks-token-reader.d.ts +3 -3
- package/esm/server/jwks-token-reader.js +3 -3
- package/esm/server/redirect-uri.d.ts +6 -9
- package/esm/server/redirect-uri.js +7 -34
- package/esm/server/resource-server.d.ts +5 -3
- package/esm/server/resource-server.js +5 -3
- package/esm/server/services/client.d.ts +5 -1
- package/esm/server/services/device-authorization.d.ts +6 -6
- package/esm/server/services/token.d.ts +15 -8
- package/esm/server/services/token.js +1 -1
- package/esm/server/signing-keys.d.ts +24 -9
- package/esm/server/signing-keys.js +20 -9
- package/esm/testing/contract/mod.d.ts +3 -3
- package/esm/testing/contract/mod.js +3 -3
- package/esm/testing/contract/tenant.d.ts +1 -1
- package/esm/testing/contract/tenant.js +2 -2
- package/esm/testing/mod.d.ts +6 -4
- package/esm/testing/mod.js +6 -4
- package/esm/testing/services.d.ts +1 -1
- package/esm/testing/services.js +1 -1
- package/esm/testing/tenant.d.ts +14 -5
- package/esm/utils/_buffer.d.ts +3 -2
- package/esm/utils/_buffer.js +3 -2
- package/esm/utils/_timing-safe.d.ts +8 -6
- package/esm/utils/_timing-safe.js +8 -6
- package/esm/utils/basic-auth.d.ts +5 -12
- package/esm/utils/basic-auth.js +5 -12
- package/esm/utils/crypto.d.ts +2 -1
- package/esm/utils/crypto.js +2 -1
- package/esm/utils/text.d.ts +2 -9
- package/esm/utils/text.js +2 -9
- package/esm/utils/url.d.ts +2 -2
- package/esm/utils/url.js +0 -2
- package/package.json +1 -1
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*
|
|
18
18
|
* A raw "token in cookie" store is deliberately **not** shipped: a leaked
|
|
19
19
|
* log line containing the cookie would yield a working bearer token. The
|
|
20
|
-
* AES-GCM encryption in the stateless store
|
|
21
|
-
*
|
|
20
|
+
* AES-GCM encryption in the stateless store keeps the tokens unreadable, but
|
|
21
|
+
* a leaked encrypted cookie still works as a session cookie against the BFF
|
|
22
|
+
* until it expires.
|
|
22
23
|
*
|
|
23
24
|
* @module
|
|
24
25
|
*/
|
|
@@ -60,7 +60,10 @@ export interface CreateTestSessionOptions {
|
|
|
60
60
|
* const cookie = await createTestSession(bff, {
|
|
61
61
|
* user: { sub: "u1", name: "Alice" },
|
|
62
62
|
* });
|
|
63
|
-
*
|
|
63
|
+
* // With CSRF on (the default), a request carrying the cookie needs the header.
|
|
64
|
+
* const res = await app.request("/auth/session", {
|
|
65
|
+
* headers: { cookie, "x-csrf": "1" },
|
|
66
|
+
* });
|
|
64
67
|
* ```
|
|
65
68
|
*/
|
|
66
69
|
export declare function createTestSession(bff: HonoBff, options?: CreateTestSessionOptions): Promise<string>;
|
|
@@ -105,7 +108,9 @@ export interface CreateAuthenticatedTestSessionOptions<Client extends ClientInte
|
|
|
105
108
|
* scope: new BasicScope("read write"),
|
|
106
109
|
* claims: { sub: "u1", name: "Alice" },
|
|
107
110
|
* });
|
|
108
|
-
* const res = await app.request("/api/me", {
|
|
111
|
+
* const res = await app.request("/api/me", {
|
|
112
|
+
* headers: { cookie, "x-csrf": "1" },
|
|
113
|
+
* });
|
|
109
114
|
* ```
|
|
110
115
|
*/
|
|
111
116
|
export declare function createAuthenticatedTestSession<Client extends ClientInterface, User, Scope extends AbstractScope>(bff: HonoBff, options: CreateAuthenticatedTestSessionOptions<Client, User, Scope>): Promise<string>;
|
|
@@ -161,5 +166,11 @@ export interface SessionStoreContractOptions {
|
|
|
161
166
|
*/
|
|
162
167
|
makeBoundedStore?(maxAgeMs: number): Promise<SessionStore> | SessionStore;
|
|
163
168
|
}
|
|
164
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Contract test suite for {@link SessionStore} implementations. Registers
|
|
171
|
+
* `describe`/`it` blocks from `@std/testing/bdd`, so call it at a test
|
|
172
|
+
* module's top level. It does not check that a stateful store's `update`
|
|
173
|
+
* rejects a missing or destroyed session, which {@link SessionStore.update}
|
|
174
|
+
* requires — test that yourself.
|
|
175
|
+
*/
|
|
165
176
|
export declare function runSessionStoreContractTests(options: SessionStoreContractOptions): void;
|
|
@@ -42,7 +42,10 @@ import { sessionStoreMaxAgeMs, } from "./session-store.js";
|
|
|
42
42
|
* const cookie = await createTestSession(bff, {
|
|
43
43
|
* user: { sub: "u1", name: "Alice" },
|
|
44
44
|
* });
|
|
45
|
-
*
|
|
45
|
+
* // With CSRF on (the default), a request carrying the cookie needs the header.
|
|
46
|
+
* const res = await app.request("/auth/session", {
|
|
47
|
+
* headers: { cookie, "x-csrf": "1" },
|
|
48
|
+
* });
|
|
46
49
|
* ```
|
|
47
50
|
*/
|
|
48
51
|
export async function createTestSession(bff, options = {}) {
|
|
@@ -86,7 +89,9 @@ export async function createTestSession(bff, options = {}) {
|
|
|
86
89
|
* scope: new BasicScope("read write"),
|
|
87
90
|
* claims: { sub: "u1", name: "Alice" },
|
|
88
91
|
* });
|
|
89
|
-
* const res = await app.request("/api/me", {
|
|
92
|
+
* const res = await app.request("/api/me", {
|
|
93
|
+
* headers: { cookie, "x-csrf": "1" },
|
|
94
|
+
* });
|
|
90
95
|
* ```
|
|
91
96
|
*/
|
|
92
97
|
export async function createAuthenticatedTestSession(bff, options) {
|
|
@@ -163,7 +168,13 @@ function parseCookieHeader(header, name) {
|
|
|
163
168
|
throw new Error(`parseCookieHeader: cookie "${name}" not found in header "${header}"`);
|
|
164
169
|
}
|
|
165
170
|
const BOUND_MS = 1000;
|
|
166
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Contract test suite for {@link SessionStore} implementations. Registers
|
|
173
|
+
* `describe`/`it` blocks from `@std/testing/bdd`, so call it at a test
|
|
174
|
+
* module's top level. It does not check that a stateful store's `update`
|
|
175
|
+
* rejects a missing or destroyed session, which {@link SessionStore.update}
|
|
176
|
+
* requires — test that yourself.
|
|
177
|
+
*/
|
|
167
178
|
export function runSessionStoreContractTests(options) {
|
|
168
179
|
const destroyClearsRead = options.destroyClearsRead ?? true;
|
|
169
180
|
const makeBoundedStore = options.makeBoundedStore;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* (the `IdentityService` stays usable directly from your own
|
|
11
11
|
* routes/loaders/actions).
|
|
12
12
|
*
|
|
13
|
-
* **Scope — password path only, by design.** The
|
|
13
|
+
* **Scope — password path only, by design.** The other flows are intentionally
|
|
14
14
|
* *not* mounted here, because their safe wiring carries app-owned policy the
|
|
15
15
|
* factory can't guess. Drive them by calling the service methods from your own
|
|
16
16
|
* handlers, as each guide shows:
|
|
@@ -76,8 +76,10 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
|
|
|
76
76
|
* Cross-site request protection, **on by default**. These routes carry no
|
|
77
77
|
* token of their own, so the guard reads what the browser states about the
|
|
78
78
|
* request: a `Sec-Fetch-Site` of `same-origin`/`none` passes, any other value
|
|
79
|
-
* is refused
|
|
80
|
-
*
|
|
79
|
+
* is refused unless `Origin` is in
|
|
80
|
+
* {@link HonoIdentityCsrfOptions.allowedOrigins}, and browsers too old to
|
|
81
|
+
* send it fall back to matching `Origin` against that list or the request's
|
|
82
|
+
* own host. A request with neither header
|
|
81
83
|
* — curl, a server-side call, a native app — passes, since cross-site request
|
|
82
84
|
* forgery needs a browser.
|
|
83
85
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* (the `IdentityService` stays usable directly from your own
|
|
11
11
|
* routes/loaders/actions).
|
|
12
12
|
*
|
|
13
|
-
* **Scope — password path only, by design.** The
|
|
13
|
+
* **Scope — password path only, by design.** The other flows are intentionally
|
|
14
14
|
* *not* mounted here, because their safe wiring carries app-owned policy the
|
|
15
15
|
* factory can't guess. Drive them by calling the service methods from your own
|
|
16
16
|
* handlers, as each guide shows:
|
|
@@ -42,12 +42,6 @@
|
|
|
42
42
|
import "../../_dnt.polyfills.js";
|
|
43
43
|
import { Hono } from "hono";
|
|
44
44
|
import { identityErrorStatus, isIdentityError, } from "../../identity/errors.js";
|
|
45
|
-
/**
|
|
46
|
-
* Wrap a handler so a thrown {@link IdentityError} (e.g. `identifier_taken` from
|
|
47
|
-
* your user store, or `weak_password` from a policy check) becomes a consistent
|
|
48
|
-
* `{ error: code }` response with the conventional status; other errors
|
|
49
|
-
* propagate untouched.
|
|
50
|
-
*/
|
|
51
45
|
function handle(fn) {
|
|
52
46
|
return async (c) => {
|
|
53
47
|
try {
|
|
@@ -64,12 +58,6 @@ function handle(fn) {
|
|
|
64
58
|
};
|
|
65
59
|
}
|
|
66
60
|
const STRING_FIELDS = new Set(["password", "identifier", "email", "token"]);
|
|
67
|
-
/**
|
|
68
|
-
* Reads a JSON or form-encoded request body. A JSON body keeps each value's
|
|
69
|
-
* type except for {@link STRING_FIELDS}; a body that is not a JSON object
|
|
70
|
-
* (array, string, `null`) reads as empty. A form-encoded body is all strings by
|
|
71
|
-
* construction.
|
|
72
|
-
*/
|
|
73
61
|
async function readBody(c) {
|
|
74
62
|
const contentType = c.req.header("content-type") ?? "";
|
|
75
63
|
if (contentType.includes("application/json")) {
|
|
@@ -124,10 +112,6 @@ function isTrustedOrigin(c, allowedOrigins) {
|
|
|
124
112
|
return allowedOrigins.has(origin) ||
|
|
125
113
|
hostOf(origin) === new URL(c.req.url).host;
|
|
126
114
|
}
|
|
127
|
-
/**
|
|
128
|
-
* Refuses an unsafe-method request a browser reports as coming from another
|
|
129
|
-
* site, per {@link HonoIdentityOptions.csrf}.
|
|
130
|
-
*/
|
|
131
115
|
function sameOriginGuard(options) {
|
|
132
116
|
const allowedOrigins = new Set(options.allowedOrigins ?? []);
|
|
133
117
|
return async (c, next) => {
|
|
@@ -45,8 +45,9 @@ export declare class HonoResourceServer<Client extends ClientInterface, User, S
|
|
|
45
45
|
*
|
|
46
46
|
* On success, sets the authenticated context at `c.get("@udibo/oauth2")`
|
|
47
47
|
* (typed via {@link HonoResourceServerVariables}) and calls `next()`. On
|
|
48
|
-
* failure, returns an
|
|
49
|
-
*
|
|
48
|
+
* failure, returns an error response, with a `WWW-Authenticate` challenge
|
|
49
|
+
* (RFC 6750 Section 3) on a 401 or 403, or rethrows when `throwOnError` is
|
|
50
|
+
* set.
|
|
50
51
|
*/
|
|
51
52
|
protect(requiredScope?: S | string): MiddlewareHandler;
|
|
52
53
|
/**
|
|
@@ -42,8 +42,9 @@ export class HonoResourceServer extends ResourceServer {
|
|
|
42
42
|
*
|
|
43
43
|
* On success, sets the authenticated context at `c.get("@udibo/oauth2")`
|
|
44
44
|
* (typed via {@link HonoResourceServerVariables}) and calls `next()`. On
|
|
45
|
-
* failure, returns an
|
|
46
|
-
*
|
|
45
|
+
* failure, returns an error response, with a `WWW-Authenticate` challenge
|
|
46
|
+
* (RFC 6750 Section 3) on a 401 or 403, or rethrows when `throwOnError` is
|
|
47
|
+
* set.
|
|
47
48
|
*/
|
|
48
49
|
protect(requiredScope) {
|
|
49
50
|
return createProtectMiddleware(this, requiredScope);
|
package/esm/client/_http.d.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Not part of the public API. The authorization server's bytes become either a
|
|
7
|
-
* typed {@link OAuth2Error} or a validated JSON object here and nowhere else,
|
|
8
|
-
* so the guards sit next to the tests that pin them.
|
|
2
|
+
* Not part of the public API. The guarded request/response path the clients'
|
|
3
|
+
* calls to the authorization server (and `BffClient`'s session probe) share:
|
|
4
|
+
* deadline, redirect refusal, bounded read, JSON validation, and RFC 6749 §5.2
|
|
5
|
+
* error mapping to a typed {@link OAuth2Error}.
|
|
9
6
|
*
|
|
10
7
|
* @module
|
|
11
8
|
*/
|
package/esm/client/_http.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Not part of the public API. The authorization server's bytes become either a
|
|
7
|
-
* typed {@link OAuth2Error} or a validated JSON object here and nowhere else,
|
|
8
|
-
* so the guards sit next to the tests that pin them.
|
|
2
|
+
* Not part of the public API. The guarded request/response path the clients'
|
|
3
|
+
* calls to the authorization server (and `BffClient`'s session probe) share:
|
|
4
|
+
* deadline, redirect refusal, bounded read, JSON validation, and RFC 6749 §5.2
|
|
5
|
+
* error mapping to a typed {@link OAuth2Error}.
|
|
9
6
|
*
|
|
10
7
|
* @module
|
|
11
8
|
*/
|
|
@@ -15,11 +12,6 @@ import { sanitizeProviderText } from "../utils/text.js";
|
|
|
15
12
|
export const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
16
13
|
/** Wall-clock budget for one call, covering the connect and the body read. */
|
|
17
14
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
18
|
-
/**
|
|
19
|
-
* Maps an OAuth2 error code to the specific error class so callers can
|
|
20
|
-
* `catch (e) { if (e instanceof InvalidGrantError) … }` instead of
|
|
21
|
-
* inspecting `e.extensions.error` at every call site.
|
|
22
|
-
*/
|
|
23
15
|
const ERROR_CLASS_BY_CODE = {
|
|
24
16
|
access_denied: AccessDeniedError,
|
|
25
17
|
authorization_pending: AuthorizationPendingError,
|
|
@@ -167,16 +159,6 @@ export function assertField(body, field, what) {
|
|
|
167
159
|
throw new ServerError(`the ${what} returned a success response with no "${field}"`);
|
|
168
160
|
}
|
|
169
161
|
}
|
|
170
|
-
/**
|
|
171
|
-
* Reads up to {@link MAX_RESPONSE_BYTES} and reports whether more was coming.
|
|
172
|
-
*
|
|
173
|
-
* Never throws for being too large: on a **failed** response the bytes are the
|
|
174
|
-
* only place the OAuth2 error code lives, and refusing to look would turn a
|
|
175
|
-
* `invalid_grant` that happens to arrive oversized into a generic transport
|
|
176
|
-
* error — which reads as "retry later" instead of "this grant is dead", so the
|
|
177
|
-
* client would keep presenting a revoked refresh token forever. Truncation is
|
|
178
|
-
* reported instead, and the success path refuses it.
|
|
179
|
-
*/
|
|
180
162
|
async function readBounded(res) {
|
|
181
163
|
if (!res.body)
|
|
182
164
|
return { text: "", truncated: false };
|
|
@@ -235,11 +217,6 @@ function reportedError(value) {
|
|
|
235
217
|
: undefined,
|
|
236
218
|
};
|
|
237
219
|
}
|
|
238
|
-
/**
|
|
239
|
-
* `status` is passed through only when it is a real error status: a server
|
|
240
|
-
* that reports `error` inside a `200` still needs a throwable, and `HttpError`
|
|
241
|
-
* refuses to carry a 2xx.
|
|
242
|
-
*/
|
|
243
220
|
function buildError(reported, status) {
|
|
244
221
|
const ErrorClass = ERROR_CLASS_BY_CODE[reported.error] ?? OAuth2ErrorClass;
|
|
245
222
|
return new ErrorClass(reported.description ?? reported.error, {
|
|
@@ -251,16 +228,6 @@ function buildError(reported, status) {
|
|
|
251
228
|
},
|
|
252
229
|
});
|
|
253
230
|
}
|
|
254
|
-
/**
|
|
255
|
-
* Recovers the `error` code from a body that did not parse.
|
|
256
|
-
*
|
|
257
|
-
* The realistic reason a failed token response is unparseable here is that the
|
|
258
|
-
* cap cut it mid-string — a server padding `error_description`, or a proxy
|
|
259
|
-
* appending a page of HTML. The code itself is short and near the front, and
|
|
260
|
-
* losing it costs the caller the difference between "this grant is dead" and
|
|
261
|
-
* "something went wrong", so it is worth one bounded scan. Restricted to the
|
|
262
|
-
* lowercase-underscore shape RFC 6749 §5.2 defines, so nothing else matches.
|
|
263
|
-
*/
|
|
264
231
|
function scanErrorCode(text) {
|
|
265
232
|
const match = /"error"\s*:\s*"([a-z_]{1,64})"/.exec(text);
|
|
266
233
|
return match ? { error: match[1] } : null;
|
package/esm/client/base.d.ts
CHANGED
|
@@ -42,12 +42,9 @@ export interface BaseOptions {
|
|
|
42
42
|
fetch?: typeof fetch;
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
* The `login` options every client understands.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `DirectClient` shape it has always had — so that a consumer importing
|
|
49
|
-
* `LoginOptions` and passing `scope` still compiles. Reach for this one only
|
|
50
|
-
* when writing against {@link OAuth2ClientBase}.
|
|
45
|
+
* The `login` options every client understands. Use it when writing against
|
|
46
|
+
* {@link OAuth2ClientBase}; `DirectClient.login` accepts the richer
|
|
47
|
+
* {@link LoginOptions}.
|
|
51
48
|
*/
|
|
52
49
|
export interface BaseLoginOptions {
|
|
53
50
|
/** Path or URL to return to after a successful login. */
|
|
@@ -57,10 +57,11 @@ export interface BffClientOptions extends BaseOptions {
|
|
|
57
57
|
* OAuth2 client for a browser app whose sessions live in a
|
|
58
58
|
* Backend-for-Frontend.
|
|
59
59
|
*
|
|
60
|
-
*
|
|
61
|
-
* "include"
|
|
62
|
-
*
|
|
63
|
-
*
|
|
60
|
+
* Nothing here calls the authorization server: the session probe and
|
|
61
|
+
* {@link BffClient.fetch} send `credentials: "include"` requests, and the login
|
|
62
|
+
* and logout methods only build BFF URLs. There is no token storage, no PKCE,
|
|
63
|
+
* and no access token to read — if you need one, the app wants a
|
|
64
|
+
* {@link DirectClient} instead.
|
|
64
65
|
*
|
|
65
66
|
* @example Same-origin BFF
|
|
66
67
|
* ```ts
|
|
@@ -146,8 +147,8 @@ export declare class BffClient extends OAuth2ClientBase {
|
|
|
146
147
|
* (an HTML error page from a misrouted path is the common one) all read as
|
|
147
148
|
* signed out. A failure that was not simply "nobody is signed in" — anything
|
|
148
149
|
* other than a `401` — also emits an `error` event so a UI can tell the two
|
|
149
|
-
* apart. The
|
|
150
|
-
*
|
|
150
|
+
* apart. The probe is refused if the BFF answers with a redirect, and is
|
|
151
|
+
* bounded in size and time.
|
|
151
152
|
*
|
|
152
153
|
* Only `isAuthenticated: true` carries `user` / `sessionExpiresIn` /
|
|
153
154
|
* `logoutUrl` through; an anonymous payload always reports them as `null`,
|
package/esm/client/bff-client.js
CHANGED
|
@@ -15,10 +15,11 @@ import { mergedHeaders, OAuth2ClientBase, SIGNED_OUT, } from "./base.js";
|
|
|
15
15
|
* OAuth2 client for a browser app whose sessions live in a
|
|
16
16
|
* Backend-for-Frontend.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* "include"
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Nothing here calls the authorization server: the session probe and
|
|
19
|
+
* {@link BffClient.fetch} send `credentials: "include"` requests, and the login
|
|
20
|
+
* and logout methods only build BFF URLs. There is no token storage, no PKCE,
|
|
21
|
+
* and no access token to read — if you need one, the app wants a
|
|
22
|
+
* {@link DirectClient} instead.
|
|
22
23
|
*
|
|
23
24
|
* @example Same-origin BFF
|
|
24
25
|
* ```ts
|
|
@@ -136,8 +137,8 @@ export class BffClient extends OAuth2ClientBase {
|
|
|
136
137
|
* (an HTML error page from a misrouted path is the common one) all read as
|
|
137
138
|
* signed out. A failure that was not simply "nobody is signed in" — anything
|
|
138
139
|
* other than a `401` — also emits an `error` event so a UI can tell the two
|
|
139
|
-
* apart. The
|
|
140
|
-
*
|
|
140
|
+
* apart. The probe is refused if the BFF answers with a redirect, and is
|
|
141
|
+
* bounded in size and time.
|
|
141
142
|
*
|
|
142
143
|
* Only `isAuthenticated: true` carries `user` / `sessionExpiresIn` /
|
|
143
144
|
* `logoutUrl` through; an anonymous payload always reports them as `null`,
|
|
@@ -2,14 +2,10 @@
|
|
|
2
2
|
* Browser-storage implementations of {@link RefreshTokenStorage} and
|
|
3
3
|
* {@link AuthRequestStorage}.
|
|
4
4
|
*
|
|
5
|
-
* Both
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Both implementations are **SSR-safe**: if `window` / `indexedDB` /
|
|
10
|
-
* `sessionStorage` is undefined at construction, the storage degrades
|
|
11
|
-
* to a no-op (`get` returns `null`, `set`/`clear` do nothing). The
|
|
12
|
-
* client constructs the real backing on first use in the browser.
|
|
5
|
+
* Both are **SSR-safe**: construction touches no browser API, and each method
|
|
6
|
+
* checks for `indexedDB` / `sessionStorage` when it runs. Where the API is
|
|
7
|
+
* missing the call is a no-op (`get` returns `null`, `set`/`clear` do
|
|
8
|
+
* nothing), so the same instance works once the code runs in a browser.
|
|
13
9
|
*
|
|
14
10
|
* @module
|
|
15
11
|
*/
|
|
@@ -32,17 +28,12 @@ export interface IndexedDBRefreshTokenStorageOptions {
|
|
|
32
28
|
/**
|
|
33
29
|
* Refresh token storage backed by IndexedDB.
|
|
34
30
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* default for a browser `DirectClient`.
|
|
42
|
-
*
|
|
43
|
-
* No `localStorage`-backed variant is shipped: it is synchronously
|
|
44
|
-
* readable by any in-document script (including injected XSS), which
|
|
45
|
-
* defeats the protection above.
|
|
31
|
+
* Keeps the refresh token across reloads without putting it in a cookie
|
|
32
|
+
* (sent with every request) or `localStorage`. It is stored in plain text and
|
|
33
|
+
* **is not a defense against XSS**: any script running on the origin can open
|
|
34
|
+
* the same database and read it. Pair it with refresh-token rotation and
|
|
35
|
+
* reuse detection on the authorization server, which limit how long a stolen
|
|
36
|
+
* token stays useful.
|
|
46
37
|
*
|
|
47
38
|
* Every instance needs a
|
|
48
39
|
* {@link IndexedDBRefreshTokenStorageOptions.clientId} — the token is stored
|
|
@@ -77,25 +68,28 @@ export declare class IndexedDBRefreshTokenStorage implements RefreshTokenStorage
|
|
|
77
68
|
*/
|
|
78
69
|
constructor(options: IndexedDBRefreshTokenStorageOptions);
|
|
79
70
|
/**
|
|
80
|
-
* Returns the stored refresh token, or `null` when none is stored
|
|
81
|
-
* `indexedDB` is unavailable (SSR / non-browser)
|
|
82
|
-
* blocking the store's creation.
|
|
71
|
+
* Returns the stored refresh token, or `null` when none is stored or
|
|
72
|
+
* `indexedDB` is unavailable (SSR / non-browser).
|
|
83
73
|
*
|
|
84
|
-
* @throws {DOMException} when IndexedDB rejects the read
|
|
74
|
+
* @throws {DOMException} when IndexedDB rejects the read, or when another
|
|
75
|
+
* open connection (another tab) blocks the store's creation.
|
|
85
76
|
*/
|
|
86
77
|
get(): Promise<string | null>;
|
|
87
78
|
/**
|
|
88
|
-
* Persists {@linkcode value} as the refresh token. A no-op when
|
|
89
|
-
* is unavailable (SSR / non-browser).
|
|
79
|
+
* Persists {@linkcode value} as the refresh token. A silent no-op when
|
|
80
|
+
* `indexedDB` is unavailable (SSR / non-browser).
|
|
90
81
|
*
|
|
91
|
-
* @throws {DOMException} when IndexedDB rejects the write
|
|
82
|
+
* @throws {DOMException} when IndexedDB rejects the write, or when another
|
|
83
|
+
* open connection (another tab) blocks the store's creation — the token was
|
|
84
|
+
* not persisted.
|
|
92
85
|
*/
|
|
93
86
|
set(value: string): Promise<void>;
|
|
94
87
|
/**
|
|
95
|
-
* Discards the stored refresh token. A no-op when `indexedDB` is
|
|
96
|
-
* (SSR / non-browser).
|
|
88
|
+
* Discards the stored refresh token. A silent no-op when `indexedDB` is
|
|
89
|
+
* unavailable (SSR / non-browser).
|
|
97
90
|
*
|
|
98
|
-
* @throws {DOMException} when IndexedDB rejects the delete
|
|
91
|
+
* @throws {DOMException} when IndexedDB rejects the delete, or when another
|
|
92
|
+
* open connection (another tab) blocks the store's creation.
|
|
99
93
|
*/
|
|
100
94
|
clear(): Promise<void>;
|
|
101
95
|
}
|
|
@@ -104,11 +98,10 @@ export interface SessionStorageAuthRequestStorageOptions {
|
|
|
104
98
|
/** sessionStorage key prefix. Defaults to `"oauth2:auth-req:"`. */
|
|
105
99
|
keyPrefix?: string;
|
|
106
100
|
/**
|
|
107
|
-
* Maximum age of an auth request before it's treated as expired
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* previous abandoned login doesn't accumulate.
|
|
101
|
+
* Maximum age of an auth request before it's treated as expired, in ms.
|
|
102
|
+
* Reading an older entry returns `null` and removes it; an entry that is
|
|
103
|
+
* never read again stays until {@link SessionStorageAuthRequestStorage.clear}
|
|
104
|
+
* or the tab closes. Defaults to 10 minutes.
|
|
112
105
|
*/
|
|
113
106
|
ttlMs?: number;
|
|
114
107
|
}
|
|
@@ -133,6 +126,12 @@ export declare class SessionStorageAuthRequestStorage implements AuthRequestStor
|
|
|
133
126
|
* unavailable (SSR / non-browser). Expired or unparseable entries are pruned.
|
|
134
127
|
*/
|
|
135
128
|
get(state: string): AuthRequestRecord | null;
|
|
129
|
+
/**
|
|
130
|
+
* Returns the record for {@linkcode state} and removes it, under the same
|
|
131
|
+
* rules as {@link get}. `sessionStorage` is synchronous and confined to one
|
|
132
|
+
* tab, so the read and the removal cannot interleave with another caller.
|
|
133
|
+
*/
|
|
134
|
+
take(state: string): AuthRequestRecord | null;
|
|
136
135
|
/**
|
|
137
136
|
* Stores {@linkcode value} under {@linkcode state}. A no-op when
|
|
138
137
|
* `sessionStorage` is unavailable (SSR / non-browser).
|
|
@@ -2,31 +2,22 @@
|
|
|
2
2
|
* Browser-storage implementations of {@link RefreshTokenStorage} and
|
|
3
3
|
* {@link AuthRequestStorage}.
|
|
4
4
|
*
|
|
5
|
-
* Both
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Both implementations are **SSR-safe**: if `window` / `indexedDB` /
|
|
10
|
-
* `sessionStorage` is undefined at construction, the storage degrades
|
|
11
|
-
* to a no-op (`get` returns `null`, `set`/`clear` do nothing). The
|
|
12
|
-
* client constructs the real backing on first use in the browser.
|
|
5
|
+
* Both are **SSR-safe**: construction touches no browser API, and each method
|
|
6
|
+
* checks for `indexedDB` / `sessionStorage` when it runs. Where the API is
|
|
7
|
+
* missing the call is a no-op (`get` returns `null`, `set`/`clear` do
|
|
8
|
+
* nothing), so the same instance works once the code runs in a browser.
|
|
13
9
|
*
|
|
14
10
|
* @module
|
|
15
11
|
*/
|
|
16
12
|
/**
|
|
17
13
|
* Refresh token storage backed by IndexedDB.
|
|
18
14
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* default for a browser `DirectClient`.
|
|
26
|
-
*
|
|
27
|
-
* No `localStorage`-backed variant is shipped: it is synchronously
|
|
28
|
-
* readable by any in-document script (including injected XSS), which
|
|
29
|
-
* defeats the protection above.
|
|
15
|
+
* Keeps the refresh token across reloads without putting it in a cookie
|
|
16
|
+
* (sent with every request) or `localStorage`. It is stored in plain text and
|
|
17
|
+
* **is not a defense against XSS**: any script running on the origin can open
|
|
18
|
+
* the same database and read it. Pair it with refresh-token rotation and
|
|
19
|
+
* reuse detection on the authorization server, which limit how long a stolen
|
|
20
|
+
* token stays useful.
|
|
30
21
|
*
|
|
31
22
|
* Every instance needs a
|
|
32
23
|
* {@link IndexedDBRefreshTokenStorageOptions.clientId} — the token is stored
|
|
@@ -71,11 +62,11 @@ export class IndexedDBRefreshTokenStorage {
|
|
|
71
62
|
this.#key = options.clientId;
|
|
72
63
|
}
|
|
73
64
|
/**
|
|
74
|
-
* Returns the stored refresh token, or `null` when none is stored
|
|
75
|
-
* `indexedDB` is unavailable (SSR / non-browser)
|
|
76
|
-
* blocking the store's creation.
|
|
65
|
+
* Returns the stored refresh token, or `null` when none is stored or
|
|
66
|
+
* `indexedDB` is unavailable (SSR / non-browser).
|
|
77
67
|
*
|
|
78
|
-
* @throws {DOMException} when IndexedDB rejects the read
|
|
68
|
+
* @throws {DOMException} when IndexedDB rejects the read, or when another
|
|
69
|
+
* open connection (another tab) blocks the store's creation.
|
|
79
70
|
*/
|
|
80
71
|
async get() {
|
|
81
72
|
const db = await this.#openDb();
|
|
@@ -90,10 +81,12 @@ export class IndexedDBRefreshTokenStorage {
|
|
|
90
81
|
});
|
|
91
82
|
}
|
|
92
83
|
/**
|
|
93
|
-
* Persists {@linkcode value} as the refresh token. A no-op when
|
|
94
|
-
* is unavailable (SSR / non-browser).
|
|
84
|
+
* Persists {@linkcode value} as the refresh token. A silent no-op when
|
|
85
|
+
* `indexedDB` is unavailable (SSR / non-browser).
|
|
95
86
|
*
|
|
96
|
-
* @throws {DOMException} when IndexedDB rejects the write
|
|
87
|
+
* @throws {DOMException} when IndexedDB rejects the write, or when another
|
|
88
|
+
* open connection (another tab) blocks the store's creation — the token was
|
|
89
|
+
* not persisted.
|
|
97
90
|
*/
|
|
98
91
|
async set(value) {
|
|
99
92
|
const db = await this.#openDb();
|
|
@@ -110,10 +103,11 @@ export class IndexedDBRefreshTokenStorage {
|
|
|
110
103
|
});
|
|
111
104
|
}
|
|
112
105
|
/**
|
|
113
|
-
* Discards the stored refresh token. A no-op when `indexedDB` is
|
|
114
|
-
* (SSR / non-browser).
|
|
106
|
+
* Discards the stored refresh token. A silent no-op when `indexedDB` is
|
|
107
|
+
* unavailable (SSR / non-browser).
|
|
115
108
|
*
|
|
116
|
-
* @throws {DOMException} when IndexedDB rejects the delete
|
|
109
|
+
* @throws {DOMException} when IndexedDB rejects the delete, or when another
|
|
110
|
+
* open connection (another tab) blocks the store's creation.
|
|
117
111
|
*/
|
|
118
112
|
async clear() {
|
|
119
113
|
const db = await this.#openDb();
|
|
@@ -133,7 +127,7 @@ export class IndexedDBRefreshTokenStorage {
|
|
|
133
127
|
if (typeof indexedDB === "undefined")
|
|
134
128
|
return null;
|
|
135
129
|
const db = await this.#open();
|
|
136
|
-
if (
|
|
130
|
+
if (db.objectStoreNames.contains(this.#storeName))
|
|
137
131
|
return db;
|
|
138
132
|
const nextVersion = db.version + 1;
|
|
139
133
|
db.close();
|
|
@@ -150,9 +144,20 @@ export class IndexedDBRefreshTokenStorage {
|
|
|
150
144
|
db.createObjectStore(this.#storeName);
|
|
151
145
|
}
|
|
152
146
|
};
|
|
153
|
-
|
|
147
|
+
let blocked = false;
|
|
148
|
+
req.onsuccess = () => {
|
|
149
|
+
if (blocked)
|
|
150
|
+
req.result.close();
|
|
151
|
+
else
|
|
152
|
+
resolve(req.result);
|
|
153
|
+
};
|
|
154
154
|
req.onerror = () => reject(req.error);
|
|
155
|
-
req.onblocked = () =>
|
|
155
|
+
req.onblocked = () => {
|
|
156
|
+
blocked = true;
|
|
157
|
+
reject(new DOMException(`opening IndexedDB "${this.#databaseName}" to create its ` +
|
|
158
|
+
`"${this.#storeName}" store was blocked by another open ` +
|
|
159
|
+
`connection, such as another tab`, "InvalidStateError"));
|
|
160
|
+
};
|
|
156
161
|
});
|
|
157
162
|
}
|
|
158
163
|
}
|
|
@@ -200,6 +205,16 @@ export class SessionStorageAuthRequestStorage {
|
|
|
200
205
|
return null;
|
|
201
206
|
}
|
|
202
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Returns the record for {@linkcode state} and removes it, under the same
|
|
210
|
+
* rules as {@link get}. `sessionStorage` is synchronous and confined to one
|
|
211
|
+
* tab, so the read and the removal cannot interleave with another caller.
|
|
212
|
+
*/
|
|
213
|
+
take(state) {
|
|
214
|
+
const record = this.get(state);
|
|
215
|
+
this.delete(state);
|
|
216
|
+
return record;
|
|
217
|
+
}
|
|
203
218
|
/**
|
|
204
219
|
* Stores {@linkcode value} under {@linkcode state}. A no-op when
|
|
205
220
|
* `sessionStorage` is unavailable (SSR / non-browser).
|
package/esm/client/check.d.ts
CHANGED
|
@@ -58,8 +58,11 @@ export interface CheckPermissionsOptions {
|
|
|
58
58
|
*/
|
|
59
59
|
resource?: CheckResource;
|
|
60
60
|
/**
|
|
61
|
-
* `fetch` implementation used to call the endpoint.
|
|
62
|
-
*
|
|
61
|
+
* `fetch` implementation used to call the endpoint, e.g. to stub the network
|
|
62
|
+
* in tests. Defaults to the global `fetch`. Whichever is used, the call runs
|
|
63
|
+
* under this package's deadline and response-size cap, and a redirect is
|
|
64
|
+
* refused rather than followed so the bearer token never reaches the host
|
|
65
|
+
* it names.
|
|
63
66
|
*/
|
|
64
67
|
fetch?: typeof fetch;
|
|
65
68
|
}
|
|
@@ -91,7 +94,11 @@ export interface CheckPermissionsResult {
|
|
|
91
94
|
* tenant no longer resolves is a 404 and lands there too. Echoing an `org_id`
|
|
92
95
|
* off a signed claim is how you meet it, because the organization can be
|
|
93
96
|
* deleted after the signature, so catch it on that path rather than reading a
|
|
94
|
-
* throw as a bug in your own setup.
|
|
97
|
+
* throw as a bug in your own setup. A redirect, or a `2xx` whose body is not a
|
|
98
|
+
* JSON object or exceeds the response-size cap, also throws
|
|
99
|
+
* {@linkcode ServerError}, and a request whose response headers do not
|
|
100
|
+
* arrive before the deadline throws {@linkcode TemporarilyUnavailableError}.
|
|
101
|
+
* The body's shape is not validated beyond being a JSON object.
|
|
95
102
|
*
|
|
96
103
|
* @example
|
|
97
104
|
* ```ts
|