@zudojs/auth-oauth 1.1.0 → 1.1.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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { OAuthConfigurationError } from "../oauthErrors/index.js";
7
7
  import { assertValidCodeVerifier, deriveCodeChallenge, generateCodeVerifier, } from "../oauthSecurity/index.js";
8
- import { assertRedirectUriAllowed, resolveAuthorizeUrl, resolveConfig, } from "./oauthConfig.resolve.js";
8
+ import { assertRedirectUriAllowed, assertScopes, resolveAuthorizeUrl, resolveConfig, } from "./oauthConfig.resolve.js";
9
9
  /** Parameters the caller may not override through `additionalParams`. */
10
10
  const RESERVED_PARAMS = new Set([
11
11
  "response_type",
@@ -56,9 +56,11 @@ export function createAuthorizationUrl(config, options) {
56
56
  const codeVerifier = options.codeVerifier ?? generateCodeVerifier();
57
57
  assertValidCodeVerifier(codeVerifier);
58
58
  const codeChallenge = deriveCodeChallenge(codeVerifier);
59
- const scopes = options.scopes !== undefined && options.scopes.length > 0
60
- ? options.scopes
61
- : resolved.scopes;
59
+ let scopes = resolved.scopes;
60
+ if (options.scopes !== undefined && options.scopes.length > 0) {
61
+ assertScopes(options.scopes);
62
+ scopes = options.scopes;
63
+ }
62
64
  const params = new URLSearchParams(url.search);
63
65
  for (const [key, value] of Object.entries(resolved.preset.authorizeParams ?? {})) {
64
66
  params.set(key, value);
@@ -32,6 +32,16 @@ export interface ResolvedOAuthConfig {
32
32
  readonly allowedRedirectUris: readonly string[];
33
33
  readonly source: OAuthConfig;
34
34
  }
35
+ /**
36
+ * Reject anything that is not a list of RFC 6749 §3.3 scope-tokens.
37
+ *
38
+ * Applied to `config.scopes` and to the per-request `scopes` override on
39
+ * {@link createAuthorizationUrl}, which previously skipped it — so a blank
40
+ * or space-containing entry reached the `scope` parameter untouched.
41
+ *
42
+ * @throws {OAuthConfigurationError} On a non-string or malformed entry.
43
+ */
44
+ export declare function assertScopes(scopes: readonly unknown[]): void;
35
45
  /**
36
46
  * Validate an `OAuthConfig` and merge it with its provider preset.
37
47
  *
@@ -36,6 +36,25 @@ function boundedInt(value, fallback, min, max, field) {
36
36
  }
37
37
  return value;
38
38
  }
39
+ /**
40
+ * Reject anything that is not a list of RFC 6749 §3.3 scope-tokens.
41
+ *
42
+ * Applied to `config.scopes` and to the per-request `scopes` override on
43
+ * {@link createAuthorizationUrl}, which previously skipped it — so a blank
44
+ * or space-containing entry reached the `scope` parameter untouched.
45
+ *
46
+ * @throws {OAuthConfigurationError} On a non-string or malformed entry.
47
+ */
48
+ export function assertScopes(scopes) {
49
+ if (!Array.isArray(scopes)) {
50
+ throw new OAuthConfigurationError("scopes must be an array of scope-tokens.");
51
+ }
52
+ for (const scope of scopes) {
53
+ if (typeof scope !== "string" || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) {
54
+ throw new OAuthConfigurationError("Each scope must be a non-empty RFC 6749 scope-token.");
55
+ }
56
+ }
57
+ }
39
58
  /**
40
59
  * Strip a URL's fragment and normalise scheme/host casing for comparison.
41
60
  */
@@ -66,11 +85,7 @@ export function resolveConfig(config) {
66
85
  const scopes = config.scopes !== undefined && config.scopes.length > 0
67
86
  ? config.scopes
68
87
  : preset.defaultScopes;
69
- for (const scope of scopes) {
70
- if (typeof scope !== "string" || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) {
71
- throw new OAuthConfigurationError("Each scope must be a non-empty RFC 6749 scope-token.");
72
- }
73
- }
88
+ assertScopes(scopes);
74
89
  const fetchImpl = config.fetch ?? globalThis.fetch;
75
90
  if (typeof fetchImpl !== "function") {
76
91
  throw new OAuthConfigurationError("No fetch implementation available; supply config.fetch.");
@@ -13,7 +13,7 @@
13
13
  * - **Reach** — `redirect: "manual"`, so a 3xx cannot walk the request to a
14
14
  * host that never passed the SSRF guard.
15
15
  */
16
- import { OAuthNetworkError, OAuthProviderError, OAuthResponseError, OAuthResponseTooLargeError, } from "../oauthErrors/index.js";
16
+ import { OAuthError, OAuthNetworkError, OAuthProviderError, OAuthResponseError, OAuthResponseTooLargeError, } from "../oauthErrors/index.js";
17
17
  import { parseJsonObject, parseJsonValue } from "../oauthSecurity/index.js";
18
18
  /** Provider `error` codes are echoed only if they look like OAuth error codes. */
19
19
  const SAFE_ERROR_CODE = /^[A-Za-z0-9_.:-]{1,64}$/;
@@ -94,16 +94,23 @@ export async function requestProviderValue(resolved, request) {
94
94
  });
95
95
  }
96
96
  catch (cause) {
97
- const name = cause instanceof Error ? cause.name : "";
98
- const timedOut = name === "TimeoutError" || name === "AbortError";
99
- throw new OAuthNetworkError(timedOut
100
- ? `The ${request.label} request timed out after ${resolved.timeoutMs}ms.`
101
- : `The ${request.label} request could not be completed.`, { cause });
97
+ throw toNetworkError(cause, request.label, resolved.timeoutMs);
102
98
  }
103
99
  if (response.status >= 300 && response.status < 400) {
104
100
  throw new OAuthProviderError(`The ${request.label} endpoint returned an unexpected redirect.`, { providerStatus: response.status });
105
101
  }
106
- const text = await readCappedText(response, resolved.maxResponseBytes);
102
+ let text;
103
+ try {
104
+ text = await readCappedText(response, resolved.maxResponseBytes);
105
+ }
106
+ catch (cause) {
107
+ // The timeout signal also aborts the body stream, and a transport can
108
+ // fail mid-body. Both surfaced here as a raw `DOMException` /
109
+ // transport error rather than the documented `OAuthNetworkError`.
110
+ if (cause instanceof OAuthError)
111
+ throw cause;
112
+ throw toNetworkError(cause, request.label, resolved.timeoutMs);
113
+ }
107
114
  if (!response.ok) {
108
115
  let code;
109
116
  try {
@@ -128,6 +135,14 @@ export async function requestProviderValue(resolved, request) {
128
135
  }
129
136
  return payload;
130
137
  }
138
+ /** Wrap a transport or timeout failure in the documented error type. */
139
+ function toNetworkError(cause, label, timeoutMs) {
140
+ const name = cause instanceof Error ? cause.name : "";
141
+ const timedOut = name === "TimeoutError" || name === "AbortError";
142
+ return new OAuthNetworkError(timedOut
143
+ ? `The ${label} request timed out after ${timeoutMs}ms.`
144
+ : `The ${label} request could not be completed.`, { cause });
145
+ }
131
146
  /** Build the `Authorization: Basic` header for client authentication. */
132
147
  export function basicAuthHeader(clientId, clientSecret) {
133
148
  // RFC 6749 §2.3.1: both halves are form-urlencoded before base64.
@@ -132,9 +132,15 @@ function isNonPublicIpv6(raw) {
132
132
  */
133
133
  export function isBlockedFetchHost(hostname) {
134
134
  const host = hostname.toLowerCase();
135
- const bare = host.startsWith("[") && host.endsWith("]")
135
+ const unbracketed = host.startsWith("[") && host.endsWith("]")
136
136
  ? host.slice(1, -1)
137
137
  : host;
138
+ // A trailing dot marks a fully-qualified name (`localhost.`,
139
+ // `metadata.google.internal.`). DNS resolves it to the same address as
140
+ // the undotted form, but the WHATWG parser keeps the dot on domain
141
+ // hosts, so without stripping it every name-based rule below was one
142
+ // character away from being bypassed.
143
+ const bare = unbracketed.replace(/\.+$/, "");
138
144
  if (BLOCKED_HOST_NAMES.has(bare))
139
145
  return true;
140
146
  for (const suffix of BLOCKED_HOST_SUFFIXES) {
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/auth-oauth",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "OAuth2 authorization-code client for the Zudojs framework — PKCE S256, mandatory state, SSRF-guarded endpoints, and provider presets for Google, GitHub, Microsoft, Apple and Discord.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",