@zudojs/auth-oauth 1.1.0 → 1.2.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 CHANGED
@@ -5,7 +5,15 @@ default, mandatory anti-CSRF `state` with a timing-safe check, an SSRF guard on
5
5
  every endpoint URL, a redirect-URI allowlist, and size- and time-bounded
6
6
  requests to the provider.
7
7
 
8
- Depends on nothing but Node built-ins (`node:crypto` and the global `fetch`).
8
+ <!-- zudo-docs:start -->
9
+
10
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-auth-oauth](https://zudojs.oyinlola.site/docs/packages-auth-oauth) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-auth-oauth.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
11
+
12
+ <!-- zudo-docs:end -->
13
+
14
+ Built on Node built-ins (`node:crypto` and the global `fetch`), plus
15
+ `@zudojs/errors` (the error base classes) and `@zudojs/security` (the IPv6
16
+ helpers behind the SSRF guard).
9
17
 
10
18
  ```bash
11
19
  pnpm add @zudojs/auth-oauth
@@ -169,7 +177,11 @@ Every one of these is covered by a test in `tests/`.
169
177
  endpoints (token, user-info) additionally may not point at a loopback,
170
178
  private, CGNAT, link-local, unique-local, multicast or reserved address, at
171
179
  `169.254.169.254` and friends, or at a `localhost` / `*.local` / `*.internal`
172
- / `metadata.google.internal` name. Redirects are not followed
180
+ / `metadata.google.internal` name. IPv6 literals that embed an IPv4
181
+ address (`[::127.0.0.1]`, `[::ffff:0:a9fe:a9fe]`, NAT64
182
+ `[64:ff9b::169.254.169.254]`, 6to4 `2002::/16`) are judged as that IPv4
183
+ address, and the local-use NAT64 prefix `64:ff9b:1::/48` is refused.
184
+ Redirects are not followed
173
185
  (`redirect: "manual"`), so a 3xx cannot walk the request somewhere that never
174
186
  passed the guard.
175
187
  **Limit:** the check is on the literal host; DNS is not resolved, so DNS
@@ -198,6 +210,9 @@ Every one of these is covered by a test in `tests/`.
198
210
 
199
211
  Every failure is an `OAuthError` with a machine-readable `code`, a suggested
200
212
  `statusCode`, and `expose` saying whether the message is safe to show a user.
213
+ `OAuthError` extends the shared `OAuthError` from `@zudojs/errors`, so every
214
+ class below is also a `BaseError`, and its codes equal the shared
215
+ `ErrorCode.OAUTH_*` members.
201
216
 
202
217
  | Class | Code | Status | Exposed |
203
218
  | --- | --- | --- | --- |
@@ -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.
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * @module oauthErrors/oauthError
5
5
  *
6
- * These are defined locally rather than extending `@zudojs/errors` so the
7
- * package has no `@zudojs/*` dependency at all.
6
+ * `OAuthError` builds on the shared `OAuthError` in `@zudojs/errors`, so
7
+ * every class here is a `BaseError` (structured `toJSON`, metadata
8
+ * redaction, `category`/`severity`). `OAuthErrorCode` values equal the
9
+ * shared `ErrorCode.OAUTH_*` members.
8
10
  *
9
11
  * **Secret hygiene.** No constructor here ever interpolates a client secret,
10
12
  * an access token, a refresh token or a code verifier into `message`. The
@@ -13,6 +15,7 @@
13
15
  * so a provider cannot echo a secret back into your logs. `message` is the
14
16
  * first line of `stack`, so keeping it clean keeps the stack clean.
15
17
  */
18
+ import { OAuthError as SharedOAuthError } from "@zudojs/errors";
16
19
  /** Stable, machine-readable error codes. */
17
20
  export declare const OAuthErrorCode: {
18
21
  /** The config is unusable: missing field, bad URL, unsupported operation. */
@@ -48,16 +51,12 @@ export interface OAuthErrorOptions {
48
51
  *
49
52
  * `expose` says whether the message is safe to hand to an end user; it is
50
53
  * `true` for request-caused failures and `false` for configuration ones
51
- * (which describe your deployment, not the request).
54
+ * (which describe your deployment, not the request). Defaults: 400,
55
+ * exposed, code `OAUTH_PROVIDER_REJECTED`.
52
56
  */
53
- export declare class OAuthError extends Error {
54
- readonly name: string;
57
+ export declare class OAuthError extends SharedOAuthError {
55
58
  /** Machine-readable code. */
56
59
  readonly code: OAuthErrorCode;
57
- /** Suggested HTTP status for a handler that surfaces this. */
58
- readonly statusCode: number;
59
- /** Whether `message` is safe to return to a client verbatim. */
60
- readonly expose: boolean;
61
60
  constructor(message: string, options?: OAuthErrorOptions);
62
61
  }
63
62
  /** The configuration is missing something or is structurally unusable. */
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * @module oauthErrors/oauthError
5
5
  *
6
- * These are defined locally rather than extending `@zudojs/errors` so the
7
- * package has no `@zudojs/*` dependency at all.
6
+ * `OAuthError` builds on the shared `OAuthError` in `@zudojs/errors`, so
7
+ * every class here is a `BaseError` (structured `toJSON`, metadata
8
+ * redaction, `category`/`severity`). `OAuthErrorCode` values equal the
9
+ * shared `ErrorCode.OAUTH_*` members.
8
10
  *
9
11
  * **Secret hygiene.** No constructor here ever interpolates a client secret,
10
12
  * an access token, a refresh token or a code verifier into `message`. The
@@ -13,6 +15,7 @@
13
15
  * so a provider cannot echo a secret back into your logs. `message` is the
14
16
  * first line of `stack`, so keeping it clean keeps the stack clean.
15
17
  */
18
+ import { OAuthError as SharedOAuthError } from "@zudojs/errors";
16
19
  /** Stable, machine-readable error codes. */
17
20
  export const OAuthErrorCode = {
18
21
  /** The config is unusable: missing field, bad URL, unsupported operation. */
@@ -39,21 +42,12 @@ export const OAuthErrorCode = {
39
42
  *
40
43
  * `expose` says whether the message is safe to hand to an end user; it is
41
44
  * `true` for request-caused failures and `false` for configuration ones
42
- * (which describe your deployment, not the request).
45
+ * (which describe your deployment, not the request). Defaults: 400,
46
+ * exposed, code `OAUTH_PROVIDER_REJECTED`.
43
47
  */
44
- export class OAuthError extends Error {
45
- name = "OAuthError";
46
- /** Machine-readable code. */
47
- code;
48
- /** Suggested HTTP status for a handler that surfaces this. */
49
- statusCode;
50
- /** Whether `message` is safe to return to a client verbatim. */
51
- expose;
48
+ export class OAuthError extends SharedOAuthError {
52
49
  constructor(message, options) {
53
- super(message, options?.cause !== undefined ? { cause: options.cause } : {});
54
- this.code = options?.code ?? OAuthErrorCode.PROVIDER_REJECTED;
55
- this.statusCode = options?.statusCode ?? 400;
56
- this.expose = options?.expose ?? true;
50
+ super(message, options);
57
51
  }
58
52
  }
59
53
  /** The configuration is missing something or is structurally unusable. */
@@ -26,6 +26,7 @@
26
26
  * rebinding) is not caught here. Pair this with network egress controls if
27
27
  * you accept endpoint URLs from untrusted operators.
28
28
  */
29
+ import { embeddedIpv4, expandIpv6, isNonPublicIpv6Range, } from "@zudojs/security";
29
30
  import { OAuthEndpointNotAllowedError } from "../oauthErrors/index.js";
30
31
  /** Hostnames that are always refused for a server-fetched endpoint. */
31
32
  const BLOCKED_HOST_NAMES = new Set([
@@ -90,40 +91,22 @@ function isNonPublicIpv4(octets) {
90
91
  return true; // multicast + reserved + broadcast
91
92
  return false;
92
93
  }
93
- /** Whether an IPv6 literal (already stripped of brackets) is non-public. */
94
+ /**
95
+ * Whether an IPv6 literal (already stripped of brackets) is non-public.
96
+ *
97
+ * Any form embedding an IPv4 address (compatible `::/96`, mapped, translated,
98
+ * NAT64 `64:ff9b::/96`, 6to4) is judged as that IPv4 address. The WHATWG
99
+ * parser serialises `[::127.0.0.1]` as `[::7f00:1]`, which the old
100
+ * dotted-form regex never matched. An unparseable literal fails closed.
101
+ */
94
102
  function isNonPublicIpv6(raw) {
95
- const host = raw.toLowerCase();
96
- if (host === "::" || host === "::1")
103
+ const groups = expandIpv6(raw);
104
+ if (!groups)
97
105
  return true;
98
- // IPv4-mapped / -compatible: judge the embedded IPv4 address.
99
- const mapped = /^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/.exec(host);
100
- const embedded = mapped?.[1];
101
- if (embedded !== undefined) {
102
- const octets = parseIpv4(embedded);
103
- return octets === undefined ? true : isNonPublicIpv4(octets);
104
- }
105
- // The WHATWG URL parser rewrites `::ffff:127.0.0.1` as `::ffff:7f00:1`,
106
- // so the hex form has to be decoded back to its embedded IPv4 address.
107
- const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
108
- const high = hexMapped?.[1];
109
- const low = hexMapped?.[2];
110
- if (high !== undefined && low !== undefined) {
111
- const word1 = Number.parseInt(high, 16);
112
- const word2 = Number.parseInt(low, 16);
113
- return isNonPublicIpv4([
114
- (word1 >> 8) & 0xff,
115
- word1 & 0xff,
116
- (word2 >> 8) & 0xff,
117
- word2 & 0xff,
118
- ]);
119
- }
120
- if (/^f[cd][0-9a-f]{2}:/.test(host))
121
- return true; // fc00::/7 unique local
122
- if (/^fe[89ab][0-9a-f]:/.test(host))
123
- return true; // fe80::/10 link-local
124
- if (/^ff[0-9a-f]{2}:/.test(host))
125
- return true; // ff00::/8 multicast
126
- return false;
106
+ const embedded = embeddedIpv4(groups);
107
+ if (embedded)
108
+ return isNonPublicIpv4(embedded);
109
+ return isNonPublicIpv6Range(groups);
127
110
  }
128
111
  /**
129
112
  * Whether a host literal is one this package refuses to fetch from.
@@ -132,9 +115,15 @@ function isNonPublicIpv6(raw) {
132
115
  */
133
116
  export function isBlockedFetchHost(hostname) {
134
117
  const host = hostname.toLowerCase();
135
- const bare = host.startsWith("[") && host.endsWith("]")
118
+ const unbracketed = host.startsWith("[") && host.endsWith("]")
136
119
  ? host.slice(1, -1)
137
120
  : host;
121
+ // A trailing dot marks a fully-qualified name (`localhost.`,
122
+ // `metadata.google.internal.`). DNS resolves it to the same address as
123
+ // the undotted form, but the WHATWG parser keeps the dot on domain
124
+ // hosts, so without stripping it every name-based rule below was one
125
+ // character away from being bypassed.
126
+ const bare = unbracketed.replace(/\.+$/, "");
138
127
  if (BLOCKED_HOST_NAMES.has(bare))
139
128
  return true;
140
129
  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.2.0",
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",
@@ -48,6 +52,10 @@
48
52
  "url": "https://github.com/oyinlola-tech/zudo",
49
53
  "directory": "packages/auth-oauth"
50
54
  },
55
+ "dependencies": {
56
+ "@zudojs/errors": "1.1.0",
57
+ "@zudojs/security": "1.1.0"
58
+ },
51
59
  "scripts": {
52
60
  "build": "tsc -p tsconfig.json",
53
61
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",