@udibo/oauth2 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,9 +5,9 @@ Udibo's identity service, or host an authorization server for your own app. The
5
5
  package includes clients, authorization and resource servers, Hono middleware,
6
6
  React bindings, and optional login flows over your own database.
7
7
 
8
- **Udibo's managed identity service is in private beta.**
9
- [Join the waitlist](https://udibo.com). The self-hosted examples run locally
10
- without a hosted account.
8
+ **Udibo's managed identity service is in private beta.** See
9
+ [udibo.com](https://www.udibo.com/) for access. The self-hosted examples run
10
+ locally without a hosted account.
11
11
 
12
12
  ## Choose your path
13
13
 
@@ -51,6 +51,7 @@ import { Hono } from "hono";
51
51
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
52
52
  import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../../../utils/_body.js";
53
53
  import { InvalidRequestError, InvalidTokenError, isOAuth2Error, TemporarilyUnavailableError, } from "../../../errors.js";
54
+ import { defaultFetch } from "../../../utils/_default-fetch.js";
54
55
  import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
55
56
  import { sha256Hash } from "../../../server/utils/hash.js";
56
57
  import { OAUTH2_CONTEXT_KEY } from "../_common.js";
@@ -1197,8 +1198,7 @@ export class HonoBff {
1197
1198
  proxy(target, options = {}) {
1198
1199
  const base = new URL(target);
1199
1200
  const allowedHeaders = resolveForwardHeaders(options.forwardHeaders, this.#csrf?.headerName);
1200
- const fetchImpl = options.fetch ??
1201
- ((input, init) => fetch(input, init));
1201
+ const fetchImpl = options.fetch ?? defaultFetch;
1202
1202
  const retryOn401 = options.retryOn401 ?? true;
1203
1203
  const mountPrefix = (options.stripPrefix ?? "").endsWith("/")
1204
1204
  ? options.stripPrefix.slice(0, -1)
@@ -7,6 +7,7 @@
7
7
  * @module
8
8
  */
9
9
  import { AccessDeniedError, AuthorizationPendingError, ExpiredTokenError, InsufficientScopeError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, OAuth2Error as OAuth2ErrorClass, ServerError, SlowDownError, TemporarilyUnavailableError, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.js";
10
+ import { packageDeadlineSignal } from "../utils/_default-fetch.js";
10
11
  import { sanitizeProviderText } from "../utils/text.js";
11
12
  /** Response bytes accepted from an endpoint before the call is refused. */
12
13
  export const MAX_RESPONSE_BYTES = 64 * 1024;
@@ -61,7 +62,7 @@ export async function sendGuarded(fetchImpl, url, init, what, options = {}) {
61
62
  res = await fetchImpl(url, {
62
63
  ...init,
63
64
  redirect: redirect === "refuse" ? "manual" : "follow",
64
- signal: init.signal ? AbortSignal.any([init.signal, deadline]) : deadline,
65
+ signal: packageDeadlineSignal(deadline, init.signal),
65
66
  });
66
67
  }
67
68
  catch (error) {
@@ -38,6 +38,12 @@ export interface BaseOptions {
38
38
  * an in-memory server. Preferred over stubbing `globalThis.fetch`,
39
39
  * which would also intercept unrelated outbound requests the app may
40
40
  * make.
41
+ *
42
+ * On Deno the default stops reusing a pooled HTTP/2 connection to a host
43
+ * once one of the client's own calls to it (discovery, token, revocation,
44
+ * userinfo, the session probe) misses its deadline; what the client's
45
+ * `fetch` method returns is the global `fetch`'s response, unaltered. An
46
+ * injected `fetch` manages its own connections.
41
47
  */
42
48
  fetch?: typeof fetch;
43
49
  }
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * @module
13
13
  */
14
+ import { defaultFetch } from "../utils/_default-fetch.js";
14
15
  import { EventBus, } from "./events.js";
15
16
  /**
16
17
  * The headers a wrapped `fetch` should send for `(input, init)`: the ones
@@ -88,6 +89,6 @@ export class OAuth2ClientBase {
88
89
  * polyfilled global if the runtime installs one after construction.
89
90
  */
90
91
  fetchImpl(input, init) {
91
- return (this.#customFetch ?? globalThis.fetch)(input, init);
92
+ return (this.#customFetch ?? defaultFetch)(input, init);
92
93
  }
93
94
  }
@@ -62,7 +62,9 @@ export interface CheckPermissionsOptions {
62
62
  * in tests. Defaults to the global `fetch`. Whichever is used, the call runs
63
63
  * under this package's deadline and response-size cap, and a redirect is
64
64
  * refused rather than followed so the bearer token never reaches the host
65
- * it names.
65
+ * it names. On Deno the default stops reusing a pooled HTTP/2 connection to
66
+ * a host once a check request to it misses its deadline; an injected `fetch`
67
+ * manages its own connections.
66
68
  */
67
69
  fetch?: typeof fetch;
68
70
  }
@@ -27,6 +27,7 @@
27
27
  * @module
28
28
  */
29
29
  import { ServerError, TemporarilyUnavailableError } from "../errors.js";
30
+ import { defaultFetch } from "../utils/_default-fetch.js";
30
31
  import { receiveJson, sendGuarded } from "./_http.js";
31
32
  const CHECK_ENDPOINT = "check endpoint";
32
33
  /**
@@ -74,7 +75,7 @@ const CHECK_ENDPOINT = "check endpoint";
74
75
  * ```
75
76
  */
76
77
  export async function checkPermissions(options) {
77
- const response = await sendGuarded(options.fetch ?? fetch, options.endpoint, {
78
+ const response = await sendGuarded(options.fetch ?? defaultFetch, options.endpoint, {
78
79
  method: "POST",
79
80
  headers: {
80
81
  authorization: `Bearer ${options.accessToken}`,
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * @module
10
10
  */
11
+ import { packageDeadlineSignal } from "../../utils/_default-fetch.js";
11
12
  import { describeError, sanitizeProviderText } from "./_shared.js";
12
13
  import { ExternalAuthError } from "./errors.js";
13
14
  /** Response bytes accepted from a token endpoint before the exchange is refused. */
@@ -33,7 +34,7 @@ export async function runTokenExchange(input) {
33
34
  },
34
35
  body: input.body,
35
36
  redirect: "manual",
36
- signal: AbortSignal.timeout(input.timeoutMs ?? TOKEN_EXCHANGE_TIMEOUT_MS),
37
+ signal: packageDeadlineSignal(AbortSignal.timeout(input.timeoutMs ?? TOKEN_EXCHANGE_TIMEOUT_MS)),
37
38
  });
38
39
  }
39
40
  catch (error) {
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { toArrayBuffer } from "../../utils/_buffer.js";
19
19
  import { base64urlDecode } from "../../utils/crypto.js";
20
+ import { defaultFetch } from "../../utils/_default-fetch.js";
20
21
  import { assertIdTokenClaims, describeError } from "./_shared.js";
21
22
  import { runTokenExchange } from "./_token-exchange.js";
22
23
  import { createAppleClientSecretFactory, } from "./apple-client-secret.js";
@@ -60,7 +61,7 @@ const SIGNATURE_NOT_VERIFIED = `Apple id_token signature did not verify against
60
61
  */
61
62
  export function appleProvider(options) {
62
63
  const id = "apple";
63
- const fetchImpl = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
64
+ const fetchImpl = (input, init) => (options.fetch ?? defaultFetch)(input, init);
64
65
  const clientSecretFactory = options.clientSecret ??
65
66
  createAppleClientSecretFactory({
66
67
  providerId: id,
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * @module
6
6
  */
7
+ import { defaultFetch } from "../../utils/_default-fetch.js";
7
8
  import { describeError } from "./_shared.js";
8
9
  import { runTokenExchange } from "./_token-exchange.js";
9
10
  import { ExternalAuthError } from "./errors.js";
@@ -36,7 +37,7 @@ const GITHUB_EMAILS_URL = "https://api.github.com/user/emails";
36
37
  */
37
38
  export function githubProvider(options) {
38
39
  const id = "github";
39
- const fetchImpl = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
40
+ const fetchImpl = (input, init) => (options.fetch ?? defaultFetch)(input, init);
40
41
  async function exchangeCode(input) {
41
42
  const { value } = await runTokenExchange({
42
43
  provider: id,
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * @module
9
9
  */
10
+ import { defaultFetch } from "../../utils/_default-fetch.js";
10
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
11
12
  import { describeError, readErrorBody } from "./_shared.js";
12
13
  import { runTokenExchange } from "./_token-exchange.js";
@@ -39,7 +40,7 @@ import { ExternalAuthError } from "./errors.js";
39
40
  */
40
41
  export function oauth2Provider(options) {
41
42
  const id = options.id;
42
- const fetchImpl = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
43
+ const fetchImpl = (input, init) => (options.fetch ?? defaultFetch)(input, init);
43
44
  const scopeSeparator = options.scopeSeparator ?? " ";
44
45
  async function exchangeCode(input) {
45
46
  const body = new URLSearchParams({
@@ -7,6 +7,7 @@
7
7
  import { DirectClient } from "../../client/direct-client.js";
8
8
  import { MemoryAuthRequestStorage } from "../../client/storage.js";
9
9
  import { isOAuth2Error } from "../../errors.js";
10
+ import { defaultFetch } from "../../utils/_default-fetch.js";
10
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
11
12
  import { assertIdTokenClaims, describeError, stripTrailingSlash, } from "./_shared.js";
12
13
  import { ExternalAuthError } from "./errors.js";
@@ -53,7 +54,7 @@ export function oidcProvider(options) {
53
54
  const defaultScopes = options.scopes ?? ["openid", "email", "profile"];
54
55
  const fetchImpl = (input, init) => {
55
56
  assertSecureIssuer(id, input instanceof Request ? input.url : String(input));
56
- return (options.fetch ?? globalThis.fetch)(input, {
57
+ return (options.fetch ?? defaultFetch)(input, {
57
58
  ...init,
58
59
  redirect: "error",
59
60
  });
@@ -18,6 +18,7 @@
18
18
  * @module
19
19
  */
20
20
  import { encodeHex } from "../deps/jsr.io/@std/encoding/1.0.11/hex.js";
21
+ import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
21
22
  import { dispatchIdentityEvent } from "./events.js";
22
23
  const DEFAULT_BASE_URL = "https://api.pwnedpasswords.com/range/";
23
24
  const VALIDATOR_NAME = "breached_password";
@@ -44,7 +45,7 @@ export function breachedPasswordValidator(options = {}) {
44
45
  const threshold = options.threshold ?? 1;
45
46
  const failOpen = options.failOpen ?? true;
46
47
  const timeoutMs = options.timeoutMs ?? 3000;
47
- const fetchImpl = options.fetch ?? fetch;
48
+ const fetchImpl = options.fetch ?? defaultFetch;
48
49
  const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
49
50
  const onEvent = options.onEvent;
50
51
  return async (password) => {
@@ -54,7 +55,7 @@ export function breachedPasswordValidator(options = {}) {
54
55
  const suffix = hash.slice(5);
55
56
  const response = await fetchImpl(`${baseUrl}${prefix}`, {
56
57
  headers: { "Add-Padding": "true" },
57
- signal: AbortSignal.timeout(timeoutMs),
58
+ signal: packageDeadlineSignal(AbortSignal.timeout(timeoutMs)),
58
59
  });
59
60
  if (!response.ok) {
60
61
  await response.body?.cancel();
@@ -126,6 +126,11 @@ export interface AuthorizationServerContext<Client extends ClientInterface, User
126
126
  services: AuthorizationServerServices<Client, User, S>;
127
127
  /** The issuer identifier URL for this request. */
128
128
  issuer?: string;
129
+ /**
130
+ * Scopes advertised for this request in discovery metadata. Overrides the
131
+ * server-wide default, including when set to an empty array.
132
+ */
133
+ scopesSupported?: string[];
129
134
  /** URL of the authorization endpoint. Defaults to `${issuer}/authorize`. */
130
135
  authorizationEndpoint?: string;
131
136
  /** URL of the token endpoint. Defaults to `${issuer}/token`. */
@@ -164,7 +169,10 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
164
169
  resolve: (request: Request) => AuthorizationServerContext<Client, User, S> | Promise<AuthorizationServerContext<Client, User, S>>;
165
170
  /** Grants the server supports, keyed by `grant_type` (each key must equal `grant.grantType`). */
166
171
  grants: AuthorizationServerGrants<Client, User, S>;
167
- /** Scopes supported by this server (advertised in RFC 8414 metadata). */
172
+ /**
173
+ * Default scopes advertised in RFC 8414 metadata when the request context
174
+ * does not supply scopesSupported.
175
+ */
168
176
  scopesSupported?: string[];
169
177
  /**
170
178
  * OIDC issuance keys. Configuring this turns the OIDC provider surface on:
@@ -1099,8 +1099,9 @@ export class AuthorizationServer extends ResourceServer {
1099
1099
  metadata.code_challenge_methods_supported = challengeMethods;
1100
1100
  }
1101
1101
  metadata.response_types_supported = hasAuthCodeGrant ? ["code"] : [];
1102
- if (this.scopesSupported) {
1103
- metadata.scopes_supported = this.scopesSupported;
1102
+ const scopesSupported = context.scopesSupported ?? this.scopesSupported;
1103
+ if (scopesSupported) {
1104
+ metadata.scopes_supported = scopesSupported;
1104
1105
  }
1105
1106
  if (this.signingKeys) {
1106
1107
  this.#advertiseEndpoints(metadata, endpoints, true);
@@ -50,10 +50,13 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
50
50
  * authentication, never a replacement for it, so a confidential client's
51
51
  * secret is still required on this grant.
52
52
  *
53
- * Defaults to `true`: confidential clients authenticate alongside PKCE.
54
- * Public clients, which have no secret, are unaffected. Setting `false`
55
- * explicitly enables legacy behavior where PKCE replaces client credentials;
56
- * use that opt-out only for an integration that requires it.
53
+ * Defaults to `true`: confidential clients authenticate alongside PKCE, and
54
+ * a public client authenticates by `client_id` alone — one that attaches a
55
+ * `client_secret` it was never issued is refused, as on every other grant.
56
+ * Setting `false` explicitly enables legacy behavior where PKCE replaces
57
+ * client credentials: a request carrying a `code_verifier` is looked up by
58
+ * id, so a presented secret is not checked at all. Use that opt-out only for
59
+ * an integration that requires it.
57
60
  *
58
61
  * @default true
59
62
  * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#section-4.1.3
@@ -70,7 +70,10 @@ export interface IntrospectionTokenReaderOptions<Client extends ClientInterface,
70
70
  /**
71
71
  * `fetch` implementation used to call the introspection endpoint. Defaults
72
72
  * to the global `fetch`. Inject it to route through a custom client (mTLS,
73
- * timeouts, retries) or to stub the network in tests.
73
+ * timeouts, retries) or to stub the network in tests. On Deno the default
74
+ * stops reusing a pooled HTTP/2 connection to a host once a request to it
75
+ * misses {@linkcode fetchTimeoutMs}; an injected `fetch` manages its own
76
+ * connections.
74
77
  */
75
78
  fetch?: typeof fetch;
76
79
  }
@@ -12,6 +12,7 @@
12
12
  import { ServerError, TemporarilyUnavailableError } from "../errors.js";
13
13
  import { BasicScope } from "../models/scope.js";
14
14
  import { encodeBasicAuth } from "../utils/basic-auth.js";
15
+ import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
15
16
  /**
16
17
  * Validates tokens by calling an OAuth2 introspection endpoint (RFC 7662).
17
18
  *
@@ -127,9 +128,9 @@ export class IntrospectionTokenReader {
127
128
  };
128
129
  }
129
130
  async #introspect(accessToken) {
130
- const fetchImpl = this.customFetch ?? globalThis.fetch;
131
+ const fetchImpl = this.customFetch ?? defaultFetch;
131
132
  const controller = new AbortController();
132
- const deadline = setTimeout(() => controller.abort(), this.fetchTimeoutMs);
133
+ const deadline = setTimeout(() => controller.abort(new DOMException("token introspection timed out", "TimeoutError")), this.fetchTimeoutMs);
133
134
  try {
134
135
  let response;
135
136
  try {
@@ -140,7 +141,7 @@ export class IntrospectionTokenReader {
140
141
  "Authorization": encodeBasicAuth(this.clientId, this.clientSecret),
141
142
  },
142
143
  body: new URLSearchParams({ token: accessToken }),
143
- signal: controller.signal,
144
+ signal: packageDeadlineSignal(controller.signal),
144
145
  });
145
146
  }
146
147
  catch (cause) {
@@ -167,7 +167,10 @@ export interface JwksTokenReaderOptions<Client extends ClientInterface, User, S
167
167
  /**
168
168
  * `fetch` implementation used for JWKS and discovery requests. Defaults to
169
169
  * the global `fetch`. Inject it to route through a custom client (mTLS,
170
- * timeouts, retries) or to stub the network in tests.
170
+ * timeouts, retries) or to stub the network in tests. On Deno the default
171
+ * stops reusing a pooled HTTP/2 connection to a host once a request to it
172
+ * misses {@linkcode fetchTimeoutMs}; an injected `fetch` manages its own
173
+ * connections.
171
174
  */
172
175
  fetch?: typeof fetch;
173
176
  }
@@ -16,6 +16,7 @@ import { ServerError, TemporarilyUnavailableError } from "../errors.js";
16
16
  import { BasicScope } from "../models/scope.js";
17
17
  import { toArrayBuffer } from "../utils/_buffer.js";
18
18
  import { base64urlDecode } from "../utils/crypto.js";
19
+ import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
19
20
  const ALGORITHMS = {
20
21
  ES256: {
21
22
  kty: "EC",
@@ -472,15 +473,15 @@ export class JwksTokenReader {
472
473
  throw transientError ?? lastError ?? new ServerError("discovery failed");
473
474
  }
474
475
  async #fetchJson(url, label) {
475
- const fetchImpl = this.#customFetch ?? globalThis.fetch;
476
+ const fetchImpl = this.#customFetch ?? defaultFetch;
476
477
  const controller = new AbortController();
477
- const deadline = setTimeout(() => controller.abort(), this.#fetchTimeoutMs);
478
+ const deadline = setTimeout(() => controller.abort(new DOMException(`${label} request timed out`, "TimeoutError")), this.#fetchTimeoutMs);
478
479
  try {
479
480
  let response;
480
481
  try {
481
482
  response = await fetchImpl(url, {
482
483
  headers: { "Accept": "application/json" },
483
- signal: controller.signal,
484
+ signal: packageDeadlineSignal(controller.signal),
484
485
  });
485
486
  }
486
487
  catch (cause) {
@@ -17,13 +17,17 @@ export interface ClientServiceInterface<Client extends ClientInterface, User> {
17
17
  * always advertises `none` in `token_endpoint_auth_methods_supported`, so
18
18
  * an implementation that demands a secret from every client makes that
19
19
  * advertisement false.
20
+ * - A **public** client must resolve `undefined` when a non-empty `secret` is
21
+ * presented. It was issued no secret, so a request carrying one is
22
+ * misconfigured or confused about the client's type, and the server answers
23
+ * `invalid_client` rather than silently treating it as public. An empty
24
+ * `secret` carries no credential — a Basic header of `client_id:` decodes
25
+ * to one — and counts as presenting none, so the client resolves.
20
26
  * - A **confidential** client resolves only with its correct `secret`. With
21
27
  * the secret missing or wrong, resolve `undefined`.
22
28
  *
23
29
  * `runClientServiceContractTests` from `@udibo/oauth2/testing/contract` pins
24
- * both rules; run it against your implementation. It does not pin whether a
25
- * public client that presents a `secret` resolves, so do not rely on either
26
- * answer.
30
+ * all three rules; run it against your implementation.
27
31
  *
28
32
  * @param id The presented `client_id` — unauthenticated input.
29
33
  * @param secret The presented `client_secret`, omitted when the request
@@ -82,6 +82,17 @@ export function runClientServiceContractTests(options) {
82
82
  const auth = await clientService.getAuthenticated(client.id);
83
83
  assertEquals(auth?.id, client.id);
84
84
  });
85
+ it("returns undefined for a public client that presents a secret", async () => {
86
+ const client = makeClient(9);
87
+ await options.addClient(clientService, client);
88
+ assertStrictEquals(await clientService.getAuthenticated(client.id, "unissued"), undefined);
89
+ });
90
+ it("returns the public client when the presented secret is empty", async () => {
91
+ const client = makeClient(10);
92
+ await options.addClient(clientService, client);
93
+ const auth = await clientService.getAuthenticated(client.id, "");
94
+ assertEquals(auth?.id, client.id);
95
+ });
85
96
  it("returns undefined for a confidential client when no secret is supplied", async () => {
86
97
  const client = makeClient(3);
87
98
  await options.addClient(clientService, client, "secret");
@@ -99,7 +99,7 @@ export declare class MemoryUserService<U extends MemoryUserShape> implements Use
99
99
  * A client is treated as confidential when {@link add} is called with a
100
100
  * secret, and public when it is not — there is no separate flag to
101
101
  * remember. `getAuthenticated` requires the secret for confidential
102
- * clients and accepts a missing secret for public clients.
102
+ * clients, and requires public clients to present none.
103
103
  */
104
104
  export declare class MemoryClientService<C extends ClientInterface, U> implements ClientServiceInterface<C, U> {
105
105
  #private;
@@ -122,7 +122,9 @@ export declare class MemoryClientService<C extends ClientInterface, U> implement
122
122
  /**
123
123
  * Resolves the client when authentication succeeds, otherwise `undefined`.
124
124
  * Confidential clients (registered with a secret) require a matching
125
- * `secret`; public clients (registered without one) ignore it.
125
+ * `secret`; public clients (registered without one) must present none, so a
126
+ * secret they were never issued fails authentication rather than being
127
+ * ignored. An empty `secret` counts as none.
126
128
  */
127
129
  getAuthenticated(id: string, secret?: string): Promise<C | undefined>;
128
130
  /**
@@ -109,7 +109,7 @@ export class MemoryUserService {
109
109
  * A client is treated as confidential when {@link add} is called with a
110
110
  * secret, and public when it is not — there is no separate flag to
111
111
  * remember. `getAuthenticated` requires the secret for confidential
112
- * clients and accepts a missing secret for public clients.
112
+ * clients, and requires public clients to present none.
113
113
  */
114
114
  export class MemoryClientService {
115
115
  #clientsById = new Map();
@@ -150,19 +150,21 @@ export class MemoryClientService {
150
150
  /**
151
151
  * Resolves the client when authentication succeeds, otherwise `undefined`.
152
152
  * Confidential clients (registered with a secret) require a matching
153
- * `secret`; public clients (registered without one) ignore it.
153
+ * `secret`; public clients (registered without one) must present none, so a
154
+ * secret they were never issued fails authentication rather than being
155
+ * ignored. An empty `secret` counts as none.
154
156
  */
155
157
  async getAuthenticated(id, secret) {
156
158
  const client = this.#clientsById.get(id);
157
159
  if (!client)
158
160
  return undefined;
159
161
  const storedHash = this.#secretHashByClientId.get(id);
160
- if (storedHash) {
161
- if (!secret)
162
- return undefined;
163
- if (await sha256Hash(secret) !== storedHash)
164
- return undefined;
165
- }
162
+ if (!storedHash)
163
+ return secret ? undefined : client;
164
+ if (!secret)
165
+ return undefined;
166
+ if (await sha256Hash(secret) !== storedHash)
167
+ return undefined;
166
168
  return client;
167
169
  }
168
170
  /**
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Not part of the public API. The `fetch` the package sends with when its
3
+ * caller injects none: the global `fetch`, resolved at call time, except that
4
+ * on Deno, once a request to an `https` origin misses a deadline the package
5
+ * set (see {@link packageDeadlineSignal}) before its response has fully
6
+ * arrived, later requests to that origin each use a connection of their own
7
+ * instead of the pooled HTTP/2 connection, which may be stalled.
8
+ *
9
+ * @module
10
+ */
11
+ /**
12
+ * The signal to send a request with when the package bounds it by its own
13
+ * `deadline`; it also aborts when the caller's `signal` does. Only a request
14
+ * whose signal came from here can move its origin off the pooled connection,
15
+ * and only when `deadline` is what aborted it.
16
+ */
17
+ export declare function packageDeadlineSignal(deadline: AbortSignal, signal?: AbortSignal | null): AbortSignal;
18
+ /** The package's default `fetch`; see the module documentation. */
19
+ export declare const defaultFetch: typeof fetch;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Not part of the public API. The `fetch` the package sends with when its
3
+ * caller injects none: the global `fetch`, resolved at call time, except that
4
+ * on Deno, once a request to an `https` origin misses a deadline the package
5
+ * set (see {@link packageDeadlineSignal}) before its response has fully
6
+ * arrived, later requests to that origin each use a connection of their own
7
+ * instead of the pooled HTTP/2 connection, which may be stalled.
8
+ *
9
+ * @module
10
+ */
11
+ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
12
+ const abandonedOrigins = new Set();
13
+ const packageDeadlines = new WeakMap();
14
+ /**
15
+ * The signal to send a request with when the package bounds it by its own
16
+ * `deadline`; it also aborts when the caller's `signal` does. Only a request
17
+ * whose signal came from here can move its origin off the pooled connection,
18
+ * and only when `deadline` is what aborted it.
19
+ */
20
+ export function packageDeadlineSignal(deadline, signal) {
21
+ const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
22
+ packageDeadlines.set(combined, deadline);
23
+ return combined;
24
+ }
25
+ /** The package's default `fetch`; see the module documentation. */
26
+ export const defaultFetch = async (input, init) => {
27
+ const createHttpClient = denoCreateHttpClient();
28
+ const origin = createHttpClient ? httpsOrigin(input) : undefined;
29
+ if (!createHttpClient || origin === undefined) {
30
+ return await globalThis.fetch(input, init);
31
+ }
32
+ if (abandonedOrigins.has(origin)) {
33
+ return await fetchOnOwnConnection(createHttpClient, input, init);
34
+ }
35
+ const signal = init?.signal;
36
+ const deadline = signal ? packageDeadlines.get(signal) : undefined;
37
+ if (!signal || !deadline)
38
+ return await globalThis.fetch(input, init);
39
+ const abandonIfDeadlineMissed = () => {
40
+ if (deadline.aborted && signal.reason === deadline.reason) {
41
+ abandonedOrigins.add(origin);
42
+ }
43
+ };
44
+ let response;
45
+ try {
46
+ response = await globalThis.fetch(input, init);
47
+ }
48
+ catch (error) {
49
+ abandonIfDeadlineMissed();
50
+ throw error;
51
+ }
52
+ return watchBody(response, abandonIfDeadlineMissed);
53
+ };
54
+ function denoCreateHttpClient() {
55
+ const deno = globalThis.Deno;
56
+ const create = deno?.createHttpClient;
57
+ return typeof create === "function"
58
+ ? (options) => create.call(deno, options)
59
+ : undefined;
60
+ }
61
+ function httpsOrigin(input) {
62
+ try {
63
+ const url = new URL(input instanceof Request ? input.url : input);
64
+ return url.protocol === "https:" ? url.origin : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ async function fetchOnOwnConnection(createHttpClient, input, init) {
71
+ let client;
72
+ try {
73
+ client = createHttpClient({});
74
+ }
75
+ catch {
76
+ return await globalThis.fetch(input, init);
77
+ }
78
+ try {
79
+ return await globalThis.fetch(input, { ...init, client });
80
+ }
81
+ finally {
82
+ client.close();
83
+ }
84
+ }
85
+ function watchBody(response, onError) {
86
+ const { body: source, status } = response;
87
+ if (!source || status < 200 || status > 599 || NULL_BODY_STATUSES.has(status)) {
88
+ return response;
89
+ }
90
+ const reader = source.getReader();
91
+ const body = new ReadableStream({
92
+ async pull(controller) {
93
+ let chunk;
94
+ try {
95
+ chunk = await reader.read();
96
+ }
97
+ catch (error) {
98
+ onError();
99
+ controller.error(error);
100
+ return;
101
+ }
102
+ if (chunk.done)
103
+ controller.close();
104
+ else
105
+ controller.enqueue(chunk.value);
106
+ },
107
+ cancel: (reason) => reader.cancel(reason),
108
+ });
109
+ return new Response(body, {
110
+ status,
111
+ statusText: response.statusText,
112
+ headers: response.headers,
113
+ });
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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
  "repository": {
6
6
  "type": "git",