@zudojs/auth-oauth 1.2.0 → 1.2.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 +47 -45
- package/dist/oauthClient/oauthConfig.resolve.js +5 -2
- package/dist/oauthClient/oauthHttp.core.js +3 -1
- package/dist/oauthClient/oauthUserInfo.core.js +1 -1
- package/dist/oauthProviders/oauthProvider.presets.js +7 -1
- package/dist/oauthSecurity/oauthJson.sanitize.js +3 -1
- package/dist/oauthSecurity/oauthUrl.guard.js +1 -3
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -92,7 +92,9 @@ app.get("/auth/callback", async (req, res) => {
|
|
|
92
92
|
const profile = await fetchUserInfo(config, tokens.accessToken);
|
|
93
93
|
|
|
94
94
|
if (profile.email === undefined) {
|
|
95
|
-
return res
|
|
95
|
+
return res
|
|
96
|
+
.status(400)
|
|
97
|
+
.send("This provider did not release an email address.");
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
// Now it is your application's turn: find or create the local account keyed
|
|
@@ -115,29 +117,29 @@ const fresh = await refreshAccessToken(config, storedRefreshToken);
|
|
|
115
117
|
|
|
116
118
|
## API
|
|
117
119
|
|
|
118
|
-
| Export
|
|
119
|
-
|
|
|
120
|
-
| `createAuthorizationUrl(config, options)`
|
|
121
|
-
| `exchangeCodeForToken(config, options)`
|
|
122
|
-
| `refreshAccessToken(config, refreshToken)`
|
|
123
|
-
| `fetchUserInfo(config, accessToken)`
|
|
124
|
-
| `generateState()` / `verifyState(expected, received)`
|
|
125
|
-
| `generateCodeVerifier()` / `deriveCodeChallenge(verifier)`
|
|
126
|
-
| `parseTokenResponse(payload)`
|
|
127
|
-
| `normalizeUserInfo(provider, payload)`
|
|
128
|
-
| `assertSafeUrl(url, label, use)` / `isBlockedFetchHost(host)` | The URL and SSRF guards, exposed for your own checks.
|
|
129
|
-
| `PROVIDER_PRESETS`
|
|
120
|
+
| Export | What it does |
|
|
121
|
+
| ------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
122
|
+
| `createAuthorizationUrl(config, options)` | Builds the authorize URL. Returns `{ url, state, codeVerifier, codeChallenge }`. |
|
|
123
|
+
| `exchangeCodeForToken(config, options)` | Authorization-code grant. Returns a validated `OAuthTokenSet`. |
|
|
124
|
+
| `refreshAccessToken(config, refreshToken)` | Refresh-token grant, where the provider supports one. |
|
|
125
|
+
| `fetchUserInfo(config, accessToken)` | Bearer GET of the user-info endpoint, normalised to `OAuthUserInfo`. |
|
|
126
|
+
| `generateState()` / `verifyState(expected, received)` | 256-bit state, timing-safe comparison. |
|
|
127
|
+
| `generateCodeVerifier()` / `deriveCodeChallenge(verifier)` | PKCE primitives (`S256`). |
|
|
128
|
+
| `parseTokenResponse(payload)` | Validate a token payload you obtained elsewhere. |
|
|
129
|
+
| `normalizeUserInfo(provider, payload)` | Normalise a profile payload you obtained elsewhere. |
|
|
130
|
+
| `assertSafeUrl(url, label, use)` / `isBlockedFetchHost(host)` | The URL and SSRF guards, exposed for your own checks. |
|
|
131
|
+
| `PROVIDER_PRESETS` | Endpoint defaults per provider. |
|
|
130
132
|
|
|
131
133
|
## Providers
|
|
132
134
|
|
|
133
|
-
| Provider
|
|
134
|
-
|
|
|
135
|
-
| `google`
|
|
136
|
-
| `github`
|
|
137
|
-
| `microsoft` | preset (`common` tenant)
|
|
138
|
-
| `apple`
|
|
139
|
-
| `discord`
|
|
140
|
-
| `custom`
|
|
135
|
+
| Provider | Endpoints | Client auth | Refresh | Notes |
|
|
136
|
+
| ----------- | ------------------------- | ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
137
|
+
| `google` | preset | body | yes | `access_type=offline` + `prompt=consent` are sent so a refresh token is actually issued. |
|
|
138
|
+
| `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. |
|
|
139
|
+
| `microsoft` | preset (`common` tenant) | body | yes | Override `authorizeUrl`/`tokenUrl` for a single-tenant app. |
|
|
140
|
+
| `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`. |
|
|
141
|
+
| `discord` | preset | **basic** | yes | |
|
|
142
|
+
| `custom` | you supply all three URLs | body | yes | |
|
|
141
143
|
|
|
142
144
|
Any preset URL can be overridden on the config; every override goes through the
|
|
143
145
|
same validation.
|
|
@@ -148,7 +150,7 @@ same validation.
|
|
|
148
150
|
|
|
149
151
|
- **GitHub** omits `email` from `/user` whenever the address is private, which
|
|
150
152
|
is the default. When the `user:email` scope was granted, this package asks
|
|
151
|
-
`/user/emails` and uses the primary
|
|
153
|
+
`/user/emails` and uses the primary _verified_ address. Without the scope, or
|
|
152
154
|
without a verified primary, the profile simply comes back with no email.
|
|
153
155
|
- **Discord** returns no email unless the `email` scope was granted.
|
|
154
156
|
|
|
@@ -173,7 +175,7 @@ Every one of these is covered by a test in `tests/`.
|
|
|
173
175
|
arbitrary redirect target is never reflected.
|
|
174
176
|
- **URL validation and SSRF guard.** Every URL must be `https` — `http` is
|
|
175
177
|
tolerated only for `localhost` / `127.0.0.1` / `[::1]` and only on
|
|
176
|
-
browser-facing URLs — and must not embed credentials. The
|
|
178
|
+
browser-facing URLs — and must not embed credentials. The _server-fetched_
|
|
177
179
|
endpoints (token, user-info) additionally may not point at a loopback,
|
|
178
180
|
private, CGNAT, link-local, unique-local, multicast or reserved address, at
|
|
179
181
|
`169.254.169.254` and friends, or at a `localhost` / `*.local` / `*.internal`
|
|
@@ -191,7 +193,7 @@ Every one of these is covered by a test in `tests/`.
|
|
|
191
193
|
`AbortSignal.timeout(timeoutMs)` (default 10s) and the body is streamed and
|
|
192
194
|
abandoned the moment it passes `maxResponseBytes` (default 256 KiB); an
|
|
193
195
|
oversized `Content-Length` is refused before a byte is read.
|
|
194
|
-
- **Defensive parsing.** A token response must be a JSON
|
|
196
|
+
- **Defensive parsing.** A token response must be a JSON _object_ with a
|
|
195
197
|
non-blank string `access_token`; `expires_in` must be a non-negative integer
|
|
196
198
|
(or its decimal string); `refresh_token`, `id_token`, `scope` and `token_type`
|
|
197
199
|
must be strings when present. `__proto__`, `constructor` and `prototype` are
|
|
@@ -214,30 +216,30 @@ Every failure is an `OAuthError` with a machine-readable `code`, a suggested
|
|
|
214
216
|
class below is also a `BaseError`, and its codes equal the shared
|
|
215
217
|
`ErrorCode.OAUTH_*` members.
|
|
216
218
|
|
|
217
|
-
| Class
|
|
218
|
-
|
|
|
219
|
-
| `OAuthConfigurationError`
|
|
220
|
-
| `OAuthEndpointNotAllowedError` | `OAUTH_ENDPOINT_NOT_ALLOWED`
|
|
221
|
-
| `OAuthRedirectUriError`
|
|
222
|
-
| `OAuthStateMismatchError`
|
|
223
|
-
| `OAuthProviderError`
|
|
224
|
-
| `OAuthResponseError`
|
|
225
|
-
| `OAuthResponseTooLargeError`
|
|
226
|
-
| `OAuthNetworkError`
|
|
219
|
+
| Class | Code | Status | Exposed |
|
|
220
|
+
| ------------------------------ | --------------------------------- | ------ | ------- |
|
|
221
|
+
| `OAuthConfigurationError` | `OAUTH_CONFIGURATION_INVALID` | 500 | no |
|
|
222
|
+
| `OAuthEndpointNotAllowedError` | `OAUTH_ENDPOINT_NOT_ALLOWED` | 500 | no |
|
|
223
|
+
| `OAuthRedirectUriError` | `OAUTH_REDIRECT_URI_NOT_ALLOWED` | 400 | yes |
|
|
224
|
+
| `OAuthStateMismatchError` | `OAUTH_STATE_MISMATCH` | 400 | yes |
|
|
225
|
+
| `OAuthProviderError` | `OAUTH_PROVIDER_REJECTED` | 502 | yes |
|
|
226
|
+
| `OAuthResponseError` | `OAUTH_PROVIDER_RESPONSE_INVALID` | 502 | yes |
|
|
227
|
+
| `OAuthResponseTooLargeError` | `OAUTH_RESPONSE_TOO_LARGE` | 502 | yes |
|
|
228
|
+
| `OAuthNetworkError` | `OAUTH_NETWORK` | 504 | yes |
|
|
227
229
|
|
|
228
230
|
## Configuration reference
|
|
229
231
|
|
|
230
|
-
| Field
|
|
231
|
-
|
|
|
232
|
-
| `provider`
|
|
233
|
-
| `clientId`, `clientSecret`
|
|
234
|
-
| `allowedRedirectUris`
|
|
235
|
-
| `authorizeUrl`, `tokenUrl`, `userInfoUrl` | only for `custom` | the preset's
|
|
236
|
-
| `scopes`
|
|
237
|
-
| `clientAuthMethod`
|
|
238
|
-
| `timeoutMs`
|
|
239
|
-
| `maxResponseBytes`
|
|
240
|
-
| `fetch`
|
|
232
|
+
| Field | Required | Default |
|
|
233
|
+
| ----------------------------------------- | ----------------- | ------------------------- |
|
|
234
|
+
| `provider` | yes | — |
|
|
235
|
+
| `clientId`, `clientSecret` | yes | — |
|
|
236
|
+
| `allowedRedirectUris` | yes, non-empty | — |
|
|
237
|
+
| `authorizeUrl`, `tokenUrl`, `userInfoUrl` | only for `custom` | the preset's |
|
|
238
|
+
| `scopes` | no | the preset's |
|
|
239
|
+
| `clientAuthMethod` | no | the preset's |
|
|
240
|
+
| `timeoutMs` | no | `10000` (1 - 120000) |
|
|
241
|
+
| `maxResponseBytes` | no | `262144` (1024 - 5242880) |
|
|
242
|
+
| `fetch` | no | global `fetch` |
|
|
241
243
|
|
|
242
244
|
Out-of-range values are rejected, not clamped.
|
|
243
245
|
|
|
@@ -28,7 +28,9 @@ function requireString(value, field) {
|
|
|
28
28
|
function boundedInt(value, fallback, min, max, field) {
|
|
29
29
|
if (value === undefined)
|
|
30
30
|
return fallback;
|
|
31
|
-
if (typeof value !== "number" ||
|
|
31
|
+
if (typeof value !== "number" ||
|
|
32
|
+
!Number.isFinite(value) ||
|
|
33
|
+
!Number.isInteger(value)) {
|
|
32
34
|
throw new OAuthConfigurationError(`${field} must be an integer.`);
|
|
33
35
|
}
|
|
34
36
|
if (value < min || value > max) {
|
|
@@ -50,7 +52,8 @@ export function assertScopes(scopes) {
|
|
|
50
52
|
throw new OAuthConfigurationError("scopes must be an array of scope-tokens.");
|
|
51
53
|
}
|
|
52
54
|
for (const scope of scopes) {
|
|
53
|
-
if (typeof scope !== "string" ||
|
|
55
|
+
if (typeof scope !== "string" ||
|
|
56
|
+
!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) {
|
|
54
57
|
throw new OAuthConfigurationError("Each scope must be a non-empty RFC 6749 scope-token.");
|
|
55
58
|
}
|
|
56
59
|
}
|
|
@@ -20,7 +20,9 @@ const SAFE_ERROR_CODE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
|
20
20
|
/** Read a response body, refusing to buffer more than `maxBytes`. */
|
|
21
21
|
async function readCappedText(response, maxBytes) {
|
|
22
22
|
const declared = response.headers.get("content-length");
|
|
23
|
-
if (declared !== null &&
|
|
23
|
+
if (declared !== null &&
|
|
24
|
+
/^\d+$/.test(declared) &&
|
|
25
|
+
Number(declared) > maxBytes) {
|
|
24
26
|
throw new OAuthResponseTooLargeError(maxBytes);
|
|
25
27
|
}
|
|
26
28
|
const body = response.body;
|
|
@@ -7,7 +7,7 @@ import { OAuthResponseError } from "../oauthErrors/index.js";
|
|
|
7
7
|
import { normalizeUserInfo } from "../oauthProviders/index.js";
|
|
8
8
|
import { assertSafeUrl } from "../oauthSecurity/index.js";
|
|
9
9
|
import { resolveConfig, resolveUserInfoUrl, } from "./oauthConfig.resolve.js";
|
|
10
|
-
import { requestProviderJson, requestProviderValue
|
|
10
|
+
import { requestProviderJson, requestProviderValue } from "./oauthHttp.core.js";
|
|
11
11
|
/**
|
|
12
12
|
* GitHub's `/user` omits `email` whenever the address is private — which is
|
|
13
13
|
* the default for new accounts. When `user:email` was granted we ask
|
|
@@ -62,7 +62,13 @@ export const PROVIDER_PRESETS = {
|
|
|
62
62
|
authorizeUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
63
63
|
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
64
64
|
userInfoUrl: "https://graph.microsoft.com/v1.0/me",
|
|
65
|
-
defaultScopes: [
|
|
65
|
+
defaultScopes: [
|
|
66
|
+
"openid",
|
|
67
|
+
"email",
|
|
68
|
+
"profile",
|
|
69
|
+
"offline_access",
|
|
70
|
+
"User.Read",
|
|
71
|
+
],
|
|
66
72
|
clientAuth: "body",
|
|
67
73
|
supportsRefresh: true,
|
|
68
74
|
},
|
|
@@ -59,7 +59,9 @@ export function sanitizeJsonValue(value) {
|
|
|
59
59
|
*/
|
|
60
60
|
export function parseJsonObject(text, label) {
|
|
61
61
|
const sanitized = parseJsonValue(text, label);
|
|
62
|
-
if (sanitized === null ||
|
|
62
|
+
if (sanitized === null ||
|
|
63
|
+
typeof sanitized !== "object" ||
|
|
64
|
+
Array.isArray(sanitized)) {
|
|
63
65
|
throw new OAuthResponseError(`${label} did not return a JSON object.`);
|
|
64
66
|
}
|
|
65
67
|
return sanitized;
|
|
@@ -115,9 +115,7 @@ function isNonPublicIpv6(raw) {
|
|
|
115
115
|
*/
|
|
116
116
|
export function isBlockedFetchHost(hostname) {
|
|
117
117
|
const host = hostname.toLowerCase();
|
|
118
|
-
const unbracketed = host.startsWith("[") && host.endsWith("]")
|
|
119
|
-
? host.slice(1, -1)
|
|
120
|
-
: host;
|
|
118
|
+
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
121
119
|
// A trailing dot marks a fully-qualified name (`localhost.`,
|
|
122
120
|
// `metadata.google.internal.`). DNS resolves it to the same address as
|
|
123
121
|
// the undotted form, but the WHATWG parser keeps the dot on domain
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/auth-oauth",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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
6
|
"author": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"typescript": "7.0.2",
|
|
32
|
-
"vitest": "^
|
|
33
|
-
"@types/node": "^26.
|
|
32
|
+
"vitest": "^5.0.1",
|
|
33
|
+
"@types/node": "^26.6.2"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"authorization-code",
|
|
44
44
|
"social-login"
|
|
45
45
|
],
|
|
46
|
-
"homepage": "https://
|
|
46
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-auth-oauth",
|
|
47
47
|
"bugs": {
|
|
48
48
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
49
49
|
},
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"directory": "packages/auth-oauth"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@zudojs/errors": "1.
|
|
57
|
-
"@zudojs/security": "1.
|
|
56
|
+
"@zudojs/errors": "1.3.0",
|
|
57
|
+
"@zudojs/security": "1.3.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"build": "tsc -p tsconfig.json",
|