@zudojs/auth-oauth 1.1.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/dist/index.d.ts +18 -0
  4. package/dist/index.js +18 -0
  5. package/dist/oauthClient/index.d.ts +10 -0
  6. package/dist/oauthClient/index.js +10 -0
  7. package/dist/oauthClient/oauthAuthorize.core.d.ts +34 -0
  8. package/dist/oauthClient/oauthAuthorize.core.js +93 -0
  9. package/dist/oauthClient/oauthConfig.resolve.d.ts +60 -0
  10. package/dist/oauthClient/oauthConfig.resolve.js +140 -0
  11. package/dist/oauthClient/oauthHttp.core.d.ts +43 -0
  12. package/dist/oauthClient/oauthHttp.core.js +137 -0
  13. package/dist/oauthClient/oauthToken.core.d.ts +50 -0
  14. package/dist/oauthClient/oauthToken.core.js +171 -0
  15. package/dist/oauthClient/oauthUserInfo.core.d.ts +27 -0
  16. package/dist/oauthClient/oauthUserInfo.core.js +104 -0
  17. package/dist/oauthErrors/index.d.ts +7 -0
  18. package/dist/oauthErrors/index.js +7 -0
  19. package/dist/oauthErrors/oauthError.base.d.ts +113 -0
  20. package/dist/oauthErrors/oauthError.base.js +168 -0
  21. package/dist/oauthProviders/index.d.ts +7 -0
  22. package/dist/oauthProviders/index.js +7 -0
  23. package/dist/oauthProviders/oauthProvider.presets.d.ts +53 -0
  24. package/dist/oauthProviders/oauthProvider.presets.js +190 -0
  25. package/dist/oauthSecurity/index.d.ts +10 -0
  26. package/dist/oauthSecurity/index.js +10 -0
  27. package/dist/oauthSecurity/oauthJson.sanitize.d.ts +38 -0
  28. package/dist/oauthSecurity/oauthJson.sanitize.js +85 -0
  29. package/dist/oauthSecurity/oauthPkce.core.d.ts +34 -0
  30. package/dist/oauthSecurity/oauthPkce.core.js +47 -0
  31. package/dist/oauthSecurity/oauthState.core.d.ts +31 -0
  32. package/dist/oauthSecurity/oauthState.core.js +44 -0
  33. package/dist/oauthSecurity/oauthUrl.guard.d.ts +47 -0
  34. package/dist/oauthSecurity/oauthUrl.guard.js +190 -0
  35. package/dist/oauthTypes/index.d.ts +7 -0
  36. package/dist/oauthTypes/index.js +7 -0
  37. package/dist/oauthTypes/oauth.type.d.ts +165 -0
  38. package/dist/oauthTypes/oauth.type.js +7 -0
  39. package/package.json +58 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zudojs Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # @zudojs/auth-oauth
2
+
3
+ OAuth2 **authorization-code** client for the Zudojs framework: PKCE `S256` by
4
+ default, mandatory anti-CSRF `state` with a timing-safe check, an SSRF guard on
5
+ every endpoint URL, a redirect-URI allowlist, and size- and time-bounded
6
+ requests to the provider.
7
+
8
+ Depends on nothing but Node built-ins (`node:crypto` and the global `fetch`).
9
+
10
+ ```bash
11
+ pnpm add @zudojs/auth-oauth
12
+ ```
13
+
14
+ Requires Node >= 24.
15
+
16
+ ## Why this package exists
17
+
18
+ `@zudojs/auth` handles passwords, JWTs, sessions and RBAC. It has no OAuth2 and
19
+ never did. This package is the real implementation: it builds the authorization
20
+ request, exchanges the code, refreshes tokens and normalises the provider's
21
+ profile — with the parts people usually skip (PKCE, state, SSRF, response
22
+ bounds) built in rather than bolted on.
23
+
24
+ It does **not** create users, issue your own sessions, or verify an OIDC
25
+ `id_token` signature. It gets you a verified provider profile; what you do with
26
+ it is your application's business.
27
+
28
+ ## The whole flow
29
+
30
+ ```ts
31
+ import {
32
+ createAuthorizationUrl,
33
+ exchangeCodeForToken,
34
+ fetchUserInfo,
35
+ generateState,
36
+ verifyState,
37
+ type OAuthConfig,
38
+ } from "@zudojs/auth-oauth";
39
+
40
+ const config: OAuthConfig = {
41
+ provider: "google",
42
+ clientId: process.env["GOOGLE_CLIENT_ID"] ?? "",
43
+ clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "",
44
+ // Every redirect URI this client may use, listed exactly.
45
+ allowedRedirectUris: ["https://app.example.com/auth/callback"],
46
+ };
47
+
48
+ const REDIRECT_URI = "https://app.example.com/auth/callback";
49
+
50
+ // 1. Start the flow.
51
+ app.get("/auth/google", (req, res) => {
52
+ const state = generateState();
53
+ const { url, codeVerifier } = createAuthorizationUrl(config, {
54
+ state,
55
+ redirectUri: REDIRECT_URI,
56
+ });
57
+
58
+ // Both values are secrets. Keep them server-side, scoped to this session,
59
+ // and short-lived (a few minutes is plenty).
60
+ req.session.oauth = { state, codeVerifier };
61
+ res.redirect(url);
62
+ });
63
+
64
+ // 2. Handle the callback.
65
+ app.get("/auth/callback", async (req, res) => {
66
+ const pending = req.session.oauth;
67
+ delete req.session.oauth; // single use, whatever happens next
68
+
69
+ if (pending === undefined) return res.status(400).send("No pending login.");
70
+
71
+ // 3. Verify state before anything else touches the provider.
72
+ if (!verifyState(pending.state, String(req.query["state"] ?? ""))) {
73
+ return res.status(400).send("Invalid state.");
74
+ }
75
+
76
+ // 4. Exchange the code, proving possession of the PKCE verifier.
77
+ const tokens = await exchangeCodeForToken(config, {
78
+ code: String(req.query["code"] ?? ""),
79
+ codeVerifier: pending.codeVerifier,
80
+ redirectUri: REDIRECT_URI,
81
+ });
82
+
83
+ // 5. Fetch the profile.
84
+ const profile = await fetchUserInfo(config, tokens.accessToken);
85
+
86
+ if (profile.email === undefined) {
87
+ return res.status(400).send("This provider did not release an email address.");
88
+ }
89
+
90
+ // Now it is your application's turn: find or create the local account keyed
91
+ // by (provider, profile.providerId) — not by email alone.
92
+ const user = await users.upsertFromOAuth("google", profile);
93
+ req.session.userId = user.id;
94
+ res.redirect("/");
95
+ });
96
+ ```
97
+
98
+ Refreshing later:
99
+
100
+ ```ts
101
+ import { refreshAccessToken } from "@zudojs/auth-oauth";
102
+
103
+ const fresh = await refreshAccessToken(config, storedRefreshToken);
104
+ // Most providers do not rotate the refresh token; keep the old one unless
105
+ // `fresh.refreshToken` is set.
106
+ ```
107
+
108
+ ## API
109
+
110
+ | Export | What it does |
111
+ | --- | --- |
112
+ | `createAuthorizationUrl(config, options)` | Builds the authorize URL. Returns `{ url, state, codeVerifier, codeChallenge }`. |
113
+ | `exchangeCodeForToken(config, options)` | Authorization-code grant. Returns a validated `OAuthTokenSet`. |
114
+ | `refreshAccessToken(config, refreshToken)` | Refresh-token grant, where the provider supports one. |
115
+ | `fetchUserInfo(config, accessToken)` | Bearer GET of the user-info endpoint, normalised to `OAuthUserInfo`. |
116
+ | `generateState()` / `verifyState(expected, received)` | 256-bit state, timing-safe comparison. |
117
+ | `generateCodeVerifier()` / `deriveCodeChallenge(verifier)` | PKCE primitives (`S256`). |
118
+ | `parseTokenResponse(payload)` | Validate a token payload you obtained elsewhere. |
119
+ | `normalizeUserInfo(provider, payload)` | Normalise a profile payload you obtained elsewhere. |
120
+ | `assertSafeUrl(url, label, use)` / `isBlockedFetchHost(host)` | The URL and SSRF guards, exposed for your own checks. |
121
+ | `PROVIDER_PRESETS` | Endpoint defaults per provider. |
122
+
123
+ ## Providers
124
+
125
+ | Provider | Endpoints | Client auth | Refresh | Notes |
126
+ | --- | --- | --- | --- | --- |
127
+ | `google` | preset | body | yes | `access_type=offline` + `prompt=consent` are sent so a refresh token is actually issued. |
128
+ | `github` | preset | body | **no** | Classic OAuth App tokens do not expire and no refresh token is issued; `refreshAccessToken` throws rather than making a pointless request. |
129
+ | `microsoft` | preset (`common` tenant) | body | yes | Override `authorizeUrl`/`tokenUrl` for a single-tenant app. |
130
+ | `apple` | preset | body | yes | `clientSecret` is the ES256 JWT you mint from your private key — this package does not generate it. Apple has **no user-info endpoint**; the profile is in the `id_token`, so `fetchUserInfo` throws for `apple`. |
131
+ | `discord` | preset | **basic** | yes | |
132
+ | `custom` | you supply all three URLs | body | yes | |
133
+
134
+ Any preset URL can be overridden on the config; every override goes through the
135
+ same validation.
136
+
137
+ ### About email
138
+
139
+ `OAuthUserInfo.email` is **optional**, and deliberately so.
140
+
141
+ - **GitHub** omits `email` from `/user` whenever the address is private, which
142
+ is the default. When the `user:email` scope was granted, this package asks
143
+ `/user/emails` and uses the primary *verified* address. Without the scope, or
144
+ without a verified primary, the profile simply comes back with no email.
145
+ - **Discord** returns no email unless the `email` scope was granted.
146
+
147
+ No address is ever synthesised from a login name. Key your local accounts by
148
+ `(provider, providerId)`, not by email — emails change, and an unverified email
149
+ from a provider is not proof of anything.
150
+
151
+ ## Security
152
+
153
+ Every one of these is covered by a test in `tests/`.
154
+
155
+ - **`state` is mandatory.** `createAuthorizationUrl` refuses to build a URL
156
+ without one, and refuses one under 16 characters. `verifyState` compares with
157
+ `crypto.timingSafeEqual` after a length check, and returns `false` for empty
158
+ or non-string input, so a missing `state` can never pass.
159
+ - **PKCE `S256`, always.** The verifier is 384 bits from `randomBytes`, encoded
160
+ base64url so it lands in the unreserved alphabet with no modulo bias. There is
161
+ no switch to turn PKCE off and `plain` is not implemented.
162
+ - **Redirect-URI allowlist.** `allowedRedirectUris` is required and non-empty;
163
+ the requested URI is parsed, canonicalised (scheme and host case-insensitive,
164
+ fragment forbidden, path and query byte-exact) and must match an entry. An
165
+ arbitrary redirect target is never reflected.
166
+ - **URL validation and SSRF guard.** Every URL must be `https` — `http` is
167
+ tolerated only for `localhost` / `127.0.0.1` / `[::1]` and only on
168
+ browser-facing URLs — and must not embed credentials. The *server-fetched*
169
+ endpoints (token, user-info) additionally may not point at a loopback,
170
+ private, CGNAT, link-local, unique-local, multicast or reserved address, at
171
+ `169.254.169.254` and friends, or at a `localhost` / `*.local` / `*.internal`
172
+ / `metadata.google.internal` name. Redirects are not followed
173
+ (`redirect: "manual"`), so a 3xx cannot walk the request somewhere that never
174
+ passed the guard.
175
+ **Limit:** the check is on the literal host; DNS is not resolved, so DNS
176
+ rebinding is out of scope. Pair this with network egress controls if endpoint
177
+ URLs come from untrusted operators.
178
+ - **Bounded responses.** Every provider request carries
179
+ `AbortSignal.timeout(timeoutMs)` (default 10s) and the body is streamed and
180
+ abandoned the moment it passes `maxResponseBytes` (default 256 KiB); an
181
+ oversized `Content-Length` is refused before a byte is read.
182
+ - **Defensive parsing.** A token response must be a JSON *object* with a
183
+ non-blank string `access_token`; `expires_in` must be a non-negative integer
184
+ (or its decimal string); `refresh_token`, `id_token`, `scope` and `token_type`
185
+ must be strings when present. `__proto__`, `constructor` and `prototype` are
186
+ stripped from every object reconstructed from provider JSON, at every depth.
187
+ - **No secret in any error.** No message, and therefore no stack, ever contains
188
+ the client secret, an access or refresh token, or a PKCE verifier. The only
189
+ provider-supplied text that reaches a message is the OAuth `error` code, and
190
+ only after passing `[A-Za-z0-9_.:-]{1,64}` — an `error_description` is never
191
+ interpolated, so a provider cannot echo material into your logs.
192
+ The one thing this cannot police is `cause`: when you supply your own
193
+ `config.fetch`, its rejection is attached untouched.
194
+ - **Secrets travel in the body or the `Authorization` header**, never in a URL
195
+ where a proxy or access log would capture them.
196
+
197
+ ## Errors
198
+
199
+ Every failure is an `OAuthError` with a machine-readable `code`, a suggested
200
+ `statusCode`, and `expose` saying whether the message is safe to show a user.
201
+
202
+ | Class | Code | Status | Exposed |
203
+ | --- | --- | --- | --- |
204
+ | `OAuthConfigurationError` | `OAUTH_CONFIGURATION_INVALID` | 500 | no |
205
+ | `OAuthEndpointNotAllowedError` | `OAUTH_ENDPOINT_NOT_ALLOWED` | 500 | no |
206
+ | `OAuthRedirectUriError` | `OAUTH_REDIRECT_URI_NOT_ALLOWED` | 400 | yes |
207
+ | `OAuthStateMismatchError` | `OAUTH_STATE_MISMATCH` | 400 | yes |
208
+ | `OAuthProviderError` | `OAUTH_PROVIDER_REJECTED` | 502 | yes |
209
+ | `OAuthResponseError` | `OAUTH_PROVIDER_RESPONSE_INVALID` | 502 | yes |
210
+ | `OAuthResponseTooLargeError` | `OAUTH_RESPONSE_TOO_LARGE` | 502 | yes |
211
+ | `OAuthNetworkError` | `OAUTH_NETWORK` | 504 | yes |
212
+
213
+ ## Configuration reference
214
+
215
+ | Field | Required | Default |
216
+ | --- | --- | --- |
217
+ | `provider` | yes | — |
218
+ | `clientId`, `clientSecret` | yes | — |
219
+ | `allowedRedirectUris` | yes, non-empty | — |
220
+ | `authorizeUrl`, `tokenUrl`, `userInfoUrl` | only for `custom` | the preset's |
221
+ | `scopes` | no | the preset's |
222
+ | `clientAuthMethod` | no | the preset's |
223
+ | `timeoutMs` | no | `10000` (1 - 120000) |
224
+ | `maxResponseBytes` | no | `262144` (1024 - 5242880) |
225
+ | `fetch` | no | global `fetch` |
226
+
227
+ Out-of-range values are rejected, not clamped.
228
+
229
+ ## What this package does not do
230
+
231
+ - **No `id_token` verification.** `idToken` is passed through unparsed and
232
+ unverified. If you rely on its claims, verify the signature against the
233
+ provider's JWKS yourself.
234
+ - **No implicit, password or client-credentials grant.** Authorization code
235
+ only, which is the only flow current guidance recommends for user login.
236
+ - **No state or verifier storage.** Where you keep them between the two requests
237
+ is your decision; the package hands them to you and asks for them back.
238
+ - **No account linking, user creation or session issuing.** Pair it with
239
+ `@zudojs/auth` for sessions and tokens.
240
+
241
+ ## Development
242
+
243
+ ```bash
244
+ pnpm --filter @zudojs/auth-oauth typecheck # both tsconfigs, full strictness
245
+ pnpm --filter @zudojs/auth-oauth test
246
+ pnpm --filter @zudojs/auth-oauth build
247
+ ```
248
+
249
+ ## License
250
+
251
+ MIT
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @zudojs/auth-oauth
3
+ *
4
+ * A small, strict OAuth2 authorization-code client: PKCE `S256` by default,
5
+ * mandatory anti-CSRF `state` with a timing-safe check, an SSRF guard on
6
+ * every endpoint URL, a redirect-URI allowlist, size- and time-bounded
7
+ * provider requests, and defensive parsing of everything a provider returns.
8
+ *
9
+ * Depends on nothing but Node built-ins.
10
+ *
11
+ * @module @zudojs/auth-oauth
12
+ */
13
+ export * from "./oauthTypes/index.js";
14
+ export * from "./oauthErrors/index.js";
15
+ export * from "./oauthSecurity/index.js";
16
+ export * from "./oauthProviders/index.js";
17
+ export * from "./oauthClient/index.js";
18
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @zudojs/auth-oauth
3
+ *
4
+ * A small, strict OAuth2 authorization-code client: PKCE `S256` by default,
5
+ * mandatory anti-CSRF `state` with a timing-safe check, an SSRF guard on
6
+ * every endpoint URL, a redirect-URI allowlist, size- and time-bounded
7
+ * provider requests, and defensive parsing of everything a provider returns.
8
+ *
9
+ * Depends on nothing but Node built-ins.
10
+ *
11
+ * @module @zudojs/auth-oauth
12
+ */
13
+ export * from "./oauthTypes/index.js";
14
+ export * from "./oauthErrors/index.js";
15
+ export * from "./oauthSecurity/index.js";
16
+ export * from "./oauthProviders/index.js";
17
+ export * from "./oauthClient/index.js";
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The OAuth2 authorization-code client.
3
+ *
4
+ * @module oauthClient
5
+ */
6
+ export { DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, DEFAULT_MAX_RESPONSE_BYTES, MIN_MAX_RESPONSE_BYTES, MAX_MAX_RESPONSE_BYTES, type ResolvedOAuthConfig, resolveConfig, resolveAuthorizeUrl, resolveTokenUrl, resolveUserInfoUrl, assertRedirectUriAllowed, } from "./oauthConfig.resolve.js";
7
+ export { createAuthorizationUrl } from "./oauthAuthorize.core.js";
8
+ export { parseTokenResponse, exchangeCodeForToken, refreshAccessToken, } from "./oauthToken.core.js";
9
+ export { fetchUserInfo } from "./oauthUserInfo.core.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The OAuth2 authorization-code client.
3
+ *
4
+ * @module oauthClient
5
+ */
6
+ export { DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, DEFAULT_MAX_RESPONSE_BYTES, MIN_MAX_RESPONSE_BYTES, MAX_MAX_RESPONSE_BYTES, resolveConfig, resolveAuthorizeUrl, resolveTokenUrl, resolveUserInfoUrl, assertRedirectUriAllowed, } from "./oauthConfig.resolve.js";
7
+ export { createAuthorizationUrl } from "./oauthAuthorize.core.js";
8
+ export { parseTokenResponse, exchangeCodeForToken, refreshAccessToken, } from "./oauthToken.core.js";
9
+ export { fetchUserInfo } from "./oauthUserInfo.core.js";
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Authorization-request construction.
3
+ *
4
+ * @module oauthClient/oauthAuthorize
5
+ */
6
+ import type { AuthorizationUrlOptions, AuthorizationUrlResult, OAuthConfig } from "../oauthTypes/index.js";
7
+ /**
8
+ * Build the authorization-request URL for the authorization-code flow.
9
+ *
10
+ * Always emits `response_type=code`, a mandatory `state`, and PKCE with
11
+ * `code_challenge_method=S256`. There is no way to turn PKCE off and no
12
+ * `plain` fallback.
13
+ *
14
+ * The returned `codeVerifier` and `state` must be stored server-side against
15
+ * the user's session — the verifier is a secret and must never be sent to the
16
+ * browser in a readable form.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const state = generateState();
21
+ * const { url, codeVerifier } = createAuthorizationUrl(config, {
22
+ * state,
23
+ * redirectUri: "https://app.example.com/auth/callback",
24
+ * });
25
+ * req.session.oauth = { state, codeVerifier };
26
+ * res.redirect(url);
27
+ * ```
28
+ *
29
+ * @throws {OAuthConfigurationError} If `state` is missing or the config is bad.
30
+ * @throws {OAuthRedirectUriError} If `redirectUri` is not allowlisted.
31
+ * @throws {OAuthEndpointNotAllowedError} If `authorizeUrl` fails the URL guard.
32
+ */
33
+ export declare function createAuthorizationUrl(config: OAuthConfig, options: AuthorizationUrlOptions): AuthorizationUrlResult;
34
+ //# sourceMappingURL=oauthAuthorize.core.d.ts.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Authorization-request construction.
3
+ *
4
+ * @module oauthClient/oauthAuthorize
5
+ */
6
+ import { OAuthConfigurationError } from "../oauthErrors/index.js";
7
+ import { assertValidCodeVerifier, deriveCodeChallenge, generateCodeVerifier, } from "../oauthSecurity/index.js";
8
+ import { assertRedirectUriAllowed, resolveAuthorizeUrl, resolveConfig, } from "./oauthConfig.resolve.js";
9
+ /** Parameters the caller may not override through `additionalParams`. */
10
+ const RESERVED_PARAMS = new Set([
11
+ "response_type",
12
+ "client_id",
13
+ "client_secret",
14
+ "redirect_uri",
15
+ "scope",
16
+ "state",
17
+ "code_challenge",
18
+ "code_challenge_method",
19
+ ]);
20
+ /**
21
+ * Build the authorization-request URL for the authorization-code flow.
22
+ *
23
+ * Always emits `response_type=code`, a mandatory `state`, and PKCE with
24
+ * `code_challenge_method=S256`. There is no way to turn PKCE off and no
25
+ * `plain` fallback.
26
+ *
27
+ * The returned `codeVerifier` and `state` must be stored server-side against
28
+ * the user's session — the verifier is a secret and must never be sent to the
29
+ * browser in a readable form.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const state = generateState();
34
+ * const { url, codeVerifier } = createAuthorizationUrl(config, {
35
+ * state,
36
+ * redirectUri: "https://app.example.com/auth/callback",
37
+ * });
38
+ * req.session.oauth = { state, codeVerifier };
39
+ * res.redirect(url);
40
+ * ```
41
+ *
42
+ * @throws {OAuthConfigurationError} If `state` is missing or the config is bad.
43
+ * @throws {OAuthRedirectUriError} If `redirectUri` is not allowlisted.
44
+ * @throws {OAuthEndpointNotAllowedError} If `authorizeUrl` fails the URL guard.
45
+ */
46
+ export function createAuthorizationUrl(config, options) {
47
+ const resolved = resolveConfig(config);
48
+ if (typeof options.state !== "string" || options.state.trim().length === 0) {
49
+ throw new OAuthConfigurationError("state is required: an authorization request without CSRF state is not supported.");
50
+ }
51
+ if (options.state.length < 16) {
52
+ throw new OAuthConfigurationError("state must be at least 16 characters of unguessable randomness.");
53
+ }
54
+ const redirectUri = assertRedirectUriAllowed(resolved, options.redirectUri);
55
+ const url = resolveAuthorizeUrl(resolved);
56
+ const codeVerifier = options.codeVerifier ?? generateCodeVerifier();
57
+ assertValidCodeVerifier(codeVerifier);
58
+ const codeChallenge = deriveCodeChallenge(codeVerifier);
59
+ const scopes = options.scopes !== undefined && options.scopes.length > 0
60
+ ? options.scopes
61
+ : resolved.scopes;
62
+ const params = new URLSearchParams(url.search);
63
+ for (const [key, value] of Object.entries(resolved.preset.authorizeParams ?? {})) {
64
+ params.set(key, value);
65
+ }
66
+ for (const [key, value] of Object.entries(options.additionalParams ?? {})) {
67
+ if (RESERVED_PARAMS.has(key)) {
68
+ throw new OAuthConfigurationError(`additionalParams may not override the reserved parameter "${key}".`);
69
+ }
70
+ if (typeof value !== "string") {
71
+ throw new OAuthConfigurationError("additionalParams values must be strings.");
72
+ }
73
+ params.set(key, value);
74
+ }
75
+ params.set("response_type", "code");
76
+ params.set("client_id", resolved.clientId);
77
+ params.set("redirect_uri", redirectUri);
78
+ if (scopes.length > 0)
79
+ params.set("scope", scopes.join(" "));
80
+ params.set("state", options.state);
81
+ params.set("code_challenge", codeChallenge);
82
+ params.set("code_challenge_method", "S256");
83
+ if (options.nonce !== undefined)
84
+ params.set("nonce", options.nonce);
85
+ url.search = params.toString();
86
+ return {
87
+ url: url.toString(),
88
+ state: options.state,
89
+ codeVerifier,
90
+ codeChallenge,
91
+ };
92
+ }
93
+ //# sourceMappingURL=oauthAuthorize.core.js.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Configuration resolution: preset merge, URL validation, redirect allowlist.
3
+ *
4
+ * @module oauthClient/oauthConfig
5
+ *
6
+ * Every public entry point resolves the config first, so a bad endpoint or a
7
+ * disallowed redirect URI is rejected before a single byte leaves the process.
8
+ */
9
+ import { type OAuthProviderPreset } from "../oauthProviders/index.js";
10
+ import type { ClientAuthMethod, FetchLike, OAuthConfig, OAuthProvider } from "../oauthTypes/index.js";
11
+ /** Default per-request timeout. */
12
+ export declare const DEFAULT_TIMEOUT_MS = 10000;
13
+ /** Upper bound accepted for `timeoutMs`. */
14
+ export declare const MAX_TIMEOUT_MS = 120000;
15
+ /** Default response-body cap: 256 KiB. */
16
+ export declare const DEFAULT_MAX_RESPONSE_BYTES = 262144;
17
+ /** Lower bound accepted for `maxResponseBytes`. */
18
+ export declare const MIN_MAX_RESPONSE_BYTES = 1024;
19
+ /** Upper bound accepted for `maxResponseBytes`: 5 MiB. */
20
+ export declare const MAX_MAX_RESPONSE_BYTES = 5242880;
21
+ /** A validated configuration. Endpoint URLs are resolved on demand. */
22
+ export interface ResolvedOAuthConfig {
23
+ readonly provider: OAuthProvider;
24
+ readonly preset: OAuthProviderPreset;
25
+ readonly clientId: string;
26
+ readonly clientSecret: string;
27
+ readonly scopes: readonly string[];
28
+ readonly clientAuth: ClientAuthMethod;
29
+ readonly timeoutMs: number;
30
+ readonly maxResponseBytes: number;
31
+ readonly fetchImpl: FetchLike;
32
+ readonly allowedRedirectUris: readonly string[];
33
+ readonly source: OAuthConfig;
34
+ }
35
+ /**
36
+ * Validate an `OAuthConfig` and merge it with its provider preset.
37
+ *
38
+ * @throws {OAuthConfigurationError} For a missing or out-of-range field.
39
+ * @throws {OAuthEndpointNotAllowedError} For an unacceptable redirect URI.
40
+ */
41
+ export declare function resolveConfig(config: OAuthConfig): ResolvedOAuthConfig;
42
+ /** The authorization endpoint, validated for browser use. */
43
+ export declare function resolveAuthorizeUrl(resolved: ResolvedOAuthConfig): URL;
44
+ /** The token endpoint, validated for server-side fetching. */
45
+ export declare function resolveTokenUrl(resolved: ResolvedOAuthConfig): URL;
46
+ /** The user-info endpoint, validated for server-side fetching. */
47
+ export declare function resolveUserInfoUrl(resolved: ResolvedOAuthConfig): URL;
48
+ /**
49
+ * Check a redirect URI against the caller's allowlist.
50
+ *
51
+ * The URI is parsed and canonicalised (scheme and host case-insensitive,
52
+ * fragment forbidden, path and query byte-exact) and must match an allowlist
53
+ * entry. An arbitrary redirect target is never reflected into the
54
+ * authorization request.
55
+ *
56
+ * @returns The canonical form to send as `redirect_uri`.
57
+ * @throws {OAuthRedirectUriError} If it is not allowlisted.
58
+ */
59
+ export declare function assertRedirectUriAllowed(resolved: ResolvedOAuthConfig, redirectUri: string): string;
60
+ //# sourceMappingURL=oauthConfig.resolve.d.ts.map
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Configuration resolution: preset merge, URL validation, redirect allowlist.
3
+ *
4
+ * @module oauthClient/oauthConfig
5
+ *
6
+ * Every public entry point resolves the config first, so a bad endpoint or a
7
+ * disallowed redirect URI is rejected before a single byte leaves the process.
8
+ */
9
+ import { OAuthConfigurationError, OAuthRedirectUriError, } from "../oauthErrors/index.js";
10
+ import { PROVIDER_PRESETS, } from "../oauthProviders/index.js";
11
+ import { assertSafeUrl } from "../oauthSecurity/index.js";
12
+ /** Default per-request timeout. */
13
+ export const DEFAULT_TIMEOUT_MS = 10_000;
14
+ /** Upper bound accepted for `timeoutMs`. */
15
+ export const MAX_TIMEOUT_MS = 120_000;
16
+ /** Default response-body cap: 256 KiB. */
17
+ export const DEFAULT_MAX_RESPONSE_BYTES = 262_144;
18
+ /** Lower bound accepted for `maxResponseBytes`. */
19
+ export const MIN_MAX_RESPONSE_BYTES = 1_024;
20
+ /** Upper bound accepted for `maxResponseBytes`: 5 MiB. */
21
+ export const MAX_MAX_RESPONSE_BYTES = 5_242_880;
22
+ function requireString(value, field) {
23
+ if (typeof value !== "string" || value.trim().length === 0) {
24
+ throw new OAuthConfigurationError(`${field} is required and must be a non-empty string.`);
25
+ }
26
+ return value;
27
+ }
28
+ function boundedInt(value, fallback, min, max, field) {
29
+ if (value === undefined)
30
+ return fallback;
31
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
32
+ throw new OAuthConfigurationError(`${field} must be an integer.`);
33
+ }
34
+ if (value < min || value > max) {
35
+ throw new OAuthConfigurationError(`${field} must be between ${min} and ${max}.`);
36
+ }
37
+ return value;
38
+ }
39
+ /**
40
+ * Strip a URL's fragment and normalise scheme/host casing for comparison.
41
+ */
42
+ function canonicalRedirect(url) {
43
+ return `${url.protocol}//${url.host}${url.pathname}${url.search}`;
44
+ }
45
+ /**
46
+ * Validate an `OAuthConfig` and merge it with its provider preset.
47
+ *
48
+ * @throws {OAuthConfigurationError} For a missing or out-of-range field.
49
+ * @throws {OAuthEndpointNotAllowedError} For an unacceptable redirect URI.
50
+ */
51
+ export function resolveConfig(config) {
52
+ if (config === null || typeof config !== "object") {
53
+ throw new OAuthConfigurationError("An OAuth configuration object is required.");
54
+ }
55
+ const preset = PROVIDER_PRESETS[config.provider];
56
+ if (preset === undefined) {
57
+ throw new OAuthConfigurationError("Unknown OAuth provider.");
58
+ }
59
+ const clientId = requireString(config.clientId, "clientId");
60
+ const clientSecret = requireString(config.clientSecret, "clientSecret");
61
+ const allowlist = config.allowedRedirectUris;
62
+ if (!Array.isArray(allowlist) || allowlist.length === 0) {
63
+ throw new OAuthConfigurationError("allowedRedirectUris must list at least one exact redirect URI.");
64
+ }
65
+ const allowedRedirectUris = allowlist.map((entry, index) => canonicalRedirect(assertSafeUrl(entry, `allowedRedirectUris[${index}]`, "browser")));
66
+ const scopes = config.scopes !== undefined && config.scopes.length > 0
67
+ ? config.scopes
68
+ : 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
+ }
74
+ const fetchImpl = config.fetch ?? globalThis.fetch;
75
+ if (typeof fetchImpl !== "function") {
76
+ throw new OAuthConfigurationError("No fetch implementation available; supply config.fetch.");
77
+ }
78
+ return {
79
+ provider: config.provider,
80
+ preset,
81
+ clientId,
82
+ clientSecret,
83
+ scopes,
84
+ clientAuth: config.clientAuthMethod ?? preset.clientAuth,
85
+ timeoutMs: boundedInt(config.timeoutMs, DEFAULT_TIMEOUT_MS, 1, MAX_TIMEOUT_MS, "timeoutMs"),
86
+ maxResponseBytes: boundedInt(config.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES, MIN_MAX_RESPONSE_BYTES, MAX_MAX_RESPONSE_BYTES, "maxResponseBytes"),
87
+ fetchImpl: fetchImpl,
88
+ allowedRedirectUris,
89
+ source: config,
90
+ };
91
+ }
92
+ /** The authorization endpoint, validated for browser use. */
93
+ export function resolveAuthorizeUrl(resolved) {
94
+ const raw = resolved.source.authorizeUrl ?? resolved.preset.authorizeUrl;
95
+ if (raw === undefined) {
96
+ throw new OAuthConfigurationError("authorizeUrl is required for this provider.");
97
+ }
98
+ return assertSafeUrl(raw, "authorizeUrl", "browser");
99
+ }
100
+ /** The token endpoint, validated for server-side fetching. */
101
+ export function resolveTokenUrl(resolved) {
102
+ const raw = resolved.source.tokenUrl ?? resolved.preset.tokenUrl;
103
+ if (raw === undefined) {
104
+ throw new OAuthConfigurationError("tokenUrl is required for this provider.");
105
+ }
106
+ return assertSafeUrl(raw, "tokenUrl", "fetch");
107
+ }
108
+ /** The user-info endpoint, validated for server-side fetching. */
109
+ export function resolveUserInfoUrl(resolved) {
110
+ const raw = resolved.source.userInfoUrl ?? resolved.preset.userInfoUrl;
111
+ if (raw === undefined) {
112
+ throw new OAuthConfigurationError(resolved.provider === "apple"
113
+ ? "Apple has no user-info endpoint; read the profile from the id_token returned by the token exchange."
114
+ : "userInfoUrl is required for this provider.");
115
+ }
116
+ return assertSafeUrl(raw, "userInfoUrl", "fetch");
117
+ }
118
+ /**
119
+ * Check a redirect URI against the caller's allowlist.
120
+ *
121
+ * The URI is parsed and canonicalised (scheme and host case-insensitive,
122
+ * fragment forbidden, path and query byte-exact) and must match an allowlist
123
+ * entry. An arbitrary redirect target is never reflected into the
124
+ * authorization request.
125
+ *
126
+ * @returns The canonical form to send as `redirect_uri`.
127
+ * @throws {OAuthRedirectUriError} If it is not allowlisted.
128
+ */
129
+ export function assertRedirectUriAllowed(resolved, redirectUri) {
130
+ const url = assertSafeUrl(redirectUri, "redirectUri", "browser");
131
+ if (url.hash !== "") {
132
+ throw new OAuthRedirectUriError("redirectUri must not contain a fragment.");
133
+ }
134
+ const canonical = canonicalRedirect(url);
135
+ if (!resolved.allowedRedirectUris.includes(canonical)) {
136
+ throw new OAuthRedirectUriError("redirectUri is not in the configured allowlist.");
137
+ }
138
+ return canonical;
139
+ }
140
+ //# sourceMappingURL=oauthConfig.resolve.js.map