@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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/oauthClient/index.d.ts +10 -0
- package/dist/oauthClient/index.js +10 -0
- package/dist/oauthClient/oauthAuthorize.core.d.ts +34 -0
- package/dist/oauthClient/oauthAuthorize.core.js +93 -0
- package/dist/oauthClient/oauthConfig.resolve.d.ts +60 -0
- package/dist/oauthClient/oauthConfig.resolve.js +140 -0
- package/dist/oauthClient/oauthHttp.core.d.ts +43 -0
- package/dist/oauthClient/oauthHttp.core.js +137 -0
- package/dist/oauthClient/oauthToken.core.d.ts +50 -0
- package/dist/oauthClient/oauthToken.core.js +171 -0
- package/dist/oauthClient/oauthUserInfo.core.d.ts +27 -0
- package/dist/oauthClient/oauthUserInfo.core.js +104 -0
- package/dist/oauthErrors/index.d.ts +7 -0
- package/dist/oauthErrors/index.js +7 -0
- package/dist/oauthErrors/oauthError.base.d.ts +113 -0
- package/dist/oauthErrors/oauthError.base.js +168 -0
- package/dist/oauthProviders/index.d.ts +7 -0
- package/dist/oauthProviders/index.js +7 -0
- package/dist/oauthProviders/oauthProvider.presets.d.ts +53 -0
- package/dist/oauthProviders/oauthProvider.presets.js +190 -0
- package/dist/oauthSecurity/index.d.ts +10 -0
- package/dist/oauthSecurity/index.js +10 -0
- package/dist/oauthSecurity/oauthJson.sanitize.d.ts +38 -0
- package/dist/oauthSecurity/oauthJson.sanitize.js +85 -0
- package/dist/oauthSecurity/oauthPkce.core.d.ts +34 -0
- package/dist/oauthSecurity/oauthPkce.core.js +47 -0
- package/dist/oauthSecurity/oauthState.core.d.ts +31 -0
- package/dist/oauthSecurity/oauthState.core.js +44 -0
- package/dist/oauthSecurity/oauthUrl.guard.d.ts +47 -0
- package/dist/oauthSecurity/oauthUrl.guard.js +190 -0
- package/dist/oauthTypes/index.d.ts +7 -0
- package/dist/oauthTypes/index.js +7 -0
- package/dist/oauthTypes/oauth.type.d.ts +165 -0
- package/dist/oauthTypes/oauth.type.js +7 -0
- package/package.json +58 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place this package talks to a provider.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthClient/oauthHttp
|
|
5
|
+
*
|
|
6
|
+
* An OAuth provider is an untrusted remote. Every request made here is
|
|
7
|
+
* bounded three ways:
|
|
8
|
+
*
|
|
9
|
+
* - **Time** — `AbortSignal.timeout(config.timeoutMs)`, default 10s.
|
|
10
|
+
* - **Size** — the body is streamed and abandoned the moment it exceeds
|
|
11
|
+
* `config.maxResponseBytes` (default 256 KiB), and a `Content-Length` over
|
|
12
|
+
* the cap is refused before reading at all.
|
|
13
|
+
* - **Reach** — `redirect: "manual"`, so a 3xx cannot walk the request to a
|
|
14
|
+
* host that never passed the SSRF guard.
|
|
15
|
+
*/
|
|
16
|
+
import type { ResolvedOAuthConfig } from "./oauthConfig.resolve.js";
|
|
17
|
+
/** One request to a provider endpoint. */
|
|
18
|
+
export interface ProviderRequest {
|
|
19
|
+
readonly url: URL;
|
|
20
|
+
readonly method: "GET" | "POST";
|
|
21
|
+
readonly headers: Record<string, string>;
|
|
22
|
+
readonly body?: string;
|
|
23
|
+
/** Endpoint name for error messages. Never contains a secret. */
|
|
24
|
+
readonly label: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Perform a bounded request and return the parsed, sanitized JSON object.
|
|
28
|
+
*
|
|
29
|
+
* A non-2xx status, a 3xx, or a payload carrying an OAuth `error` member all
|
|
30
|
+
* raise {@link OAuthProviderError}. Only the provider's `error` code reaches
|
|
31
|
+
* the message, and only after passing a strict character filter — an
|
|
32
|
+
* `error_description` is never interpolated, so a hostile or misconfigured
|
|
33
|
+
* provider cannot echo request material into your logs.
|
|
34
|
+
*/
|
|
35
|
+
export declare function requestProviderJson(resolved: ResolvedOAuthConfig, request: ProviderRequest): Promise<Record<string, unknown>>;
|
|
36
|
+
/**
|
|
37
|
+
* As {@link requestProviderJson}, but allows a top-level JSON array (GitHub's
|
|
38
|
+
* `/user/emails`). Same time, size and redirect bounds.
|
|
39
|
+
*/
|
|
40
|
+
export declare function requestProviderValue(resolved: ResolvedOAuthConfig, request: ProviderRequest): Promise<unknown>;
|
|
41
|
+
/** Build the `Authorization: Basic` header for client authentication. */
|
|
42
|
+
export declare function basicAuthHeader(clientId: string, clientSecret: string): string;
|
|
43
|
+
//# sourceMappingURL=oauthHttp.core.d.ts.map
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place this package talks to a provider.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthClient/oauthHttp
|
|
5
|
+
*
|
|
6
|
+
* An OAuth provider is an untrusted remote. Every request made here is
|
|
7
|
+
* bounded three ways:
|
|
8
|
+
*
|
|
9
|
+
* - **Time** — `AbortSignal.timeout(config.timeoutMs)`, default 10s.
|
|
10
|
+
* - **Size** — the body is streamed and abandoned the moment it exceeds
|
|
11
|
+
* `config.maxResponseBytes` (default 256 KiB), and a `Content-Length` over
|
|
12
|
+
* the cap is refused before reading at all.
|
|
13
|
+
* - **Reach** — `redirect: "manual"`, so a 3xx cannot walk the request to a
|
|
14
|
+
* host that never passed the SSRF guard.
|
|
15
|
+
*/
|
|
16
|
+
import { OAuthNetworkError, OAuthProviderError, OAuthResponseError, OAuthResponseTooLargeError, } from "../oauthErrors/index.js";
|
|
17
|
+
import { parseJsonObject, parseJsonValue } from "../oauthSecurity/index.js";
|
|
18
|
+
/** Provider `error` codes are echoed only if they look like OAuth error codes. */
|
|
19
|
+
const SAFE_ERROR_CODE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
20
|
+
/** Read a response body, refusing to buffer more than `maxBytes`. */
|
|
21
|
+
async function readCappedText(response, maxBytes) {
|
|
22
|
+
const declared = response.headers.get("content-length");
|
|
23
|
+
if (declared !== null && /^\d+$/.test(declared) && Number(declared) > maxBytes) {
|
|
24
|
+
throw new OAuthResponseTooLargeError(maxBytes);
|
|
25
|
+
}
|
|
26
|
+
const body = response.body;
|
|
27
|
+
if (body === null) {
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) {
|
|
30
|
+
throw new OAuthResponseTooLargeError(maxBytes);
|
|
31
|
+
}
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
const reader = body.getReader();
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const chunk = await reader.read();
|
|
39
|
+
if (chunk.done)
|
|
40
|
+
break;
|
|
41
|
+
const value = chunk.value;
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
total += value.byteLength;
|
|
45
|
+
if (total > maxBytes) {
|
|
46
|
+
await reader.cancel().catch(() => undefined);
|
|
47
|
+
throw new OAuthResponseTooLargeError(maxBytes);
|
|
48
|
+
}
|
|
49
|
+
chunks.push(value);
|
|
50
|
+
}
|
|
51
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
52
|
+
}
|
|
53
|
+
/** Extract a provider `error` code that is safe to put in a message. */
|
|
54
|
+
function safeProviderError(payload) {
|
|
55
|
+
const value = payload?.["error"];
|
|
56
|
+
if (typeof value === "string" && SAFE_ERROR_CODE.test(value))
|
|
57
|
+
return value;
|
|
58
|
+
if (value !== null && typeof value === "object") {
|
|
59
|
+
const nested = value["code"];
|
|
60
|
+
if (typeof nested === "string" && SAFE_ERROR_CODE.test(nested))
|
|
61
|
+
return nested;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Perform a bounded request and return the parsed, sanitized JSON object.
|
|
67
|
+
*
|
|
68
|
+
* A non-2xx status, a 3xx, or a payload carrying an OAuth `error` member all
|
|
69
|
+
* raise {@link OAuthProviderError}. Only the provider's `error` code reaches
|
|
70
|
+
* the message, and only after passing a strict character filter — an
|
|
71
|
+
* `error_description` is never interpolated, so a hostile or misconfigured
|
|
72
|
+
* provider cannot echo request material into your logs.
|
|
73
|
+
*/
|
|
74
|
+
export async function requestProviderJson(resolved, request) {
|
|
75
|
+
const value = await requestProviderValue(resolved, request);
|
|
76
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
77
|
+
throw new OAuthResponseError(`The ${request.label} endpoint did not return a JSON object.`);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* As {@link requestProviderJson}, but allows a top-level JSON array (GitHub's
|
|
83
|
+
* `/user/emails`). Same time, size and redirect bounds.
|
|
84
|
+
*/
|
|
85
|
+
export async function requestProviderValue(resolved, request) {
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await resolved.fetchImpl(request.url.toString(), {
|
|
89
|
+
method: request.method,
|
|
90
|
+
headers: request.headers,
|
|
91
|
+
...(request.body !== undefined ? { body: request.body } : {}),
|
|
92
|
+
redirect: "manual",
|
|
93
|
+
signal: AbortSignal.timeout(resolved.timeoutMs),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
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 });
|
|
102
|
+
}
|
|
103
|
+
if (response.status >= 300 && response.status < 400) {
|
|
104
|
+
throw new OAuthProviderError(`The ${request.label} endpoint returned an unexpected redirect.`, { providerStatus: response.status });
|
|
105
|
+
}
|
|
106
|
+
const text = await readCappedText(response, resolved.maxResponseBytes);
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
let code;
|
|
109
|
+
try {
|
|
110
|
+
code = safeProviderError(parseJsonObject(text, request.label));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
code = undefined;
|
|
114
|
+
}
|
|
115
|
+
throw new OAuthProviderError(code === undefined
|
|
116
|
+
? `The ${request.label} endpoint returned HTTP ${response.status}.`
|
|
117
|
+
: `The ${request.label} endpoint returned HTTP ${response.status} (${code}).`, {
|
|
118
|
+
providerStatus: response.status,
|
|
119
|
+
...(code !== undefined ? { providerError: code } : {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const payload = parseJsonValue(text, request.label);
|
|
123
|
+
const code = safeProviderError(payload !== null && typeof payload === "object" && !Array.isArray(payload)
|
|
124
|
+
? payload
|
|
125
|
+
: undefined);
|
|
126
|
+
if (code !== undefined) {
|
|
127
|
+
throw new OAuthProviderError(`The ${request.label} endpoint returned an OAuth error (${code}).`, { providerError: code, providerStatus: response.status });
|
|
128
|
+
}
|
|
129
|
+
return payload;
|
|
130
|
+
}
|
|
131
|
+
/** Build the `Authorization: Basic` header for client authentication. */
|
|
132
|
+
export function basicAuthHeader(clientId, clientSecret) {
|
|
133
|
+
// RFC 6749 §2.3.1: both halves are form-urlencoded before base64.
|
|
134
|
+
const encoded = Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`, "utf8").toString("base64");
|
|
135
|
+
return `Basic ${encoded}`;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=oauthHttp.core.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token exchange, token-response validation, and refresh.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthClient/oauthToken
|
|
5
|
+
*/
|
|
6
|
+
import type { CodeExchangeOptions, OAuthConfig, OAuthTokenSet } from "../oauthTypes/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* Validate a token-endpoint payload into an {@link OAuthTokenSet}.
|
|
9
|
+
*
|
|
10
|
+
* Rejects: a non-object body (caught earlier by the JSON parser), a missing,
|
|
11
|
+
* non-string or blank `access_token`, a non-numeric `expires_in`, and a
|
|
12
|
+
* non-string `refresh_token` / `id_token` / `scope`. Prototype-polluting keys
|
|
13
|
+
* were already stripped by {@link parseJsonObject}.
|
|
14
|
+
*
|
|
15
|
+
* Exported for the response-validation tests; also useful if you have a token
|
|
16
|
+
* payload from elsewhere.
|
|
17
|
+
*
|
|
18
|
+
* @throws {OAuthResponseError} On any malformed field. The message never
|
|
19
|
+
* contains the payload.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseTokenResponse(payload: Record<string, unknown>): OAuthTokenSet;
|
|
22
|
+
/**
|
|
23
|
+
* Exchange an authorization code for tokens.
|
|
24
|
+
*
|
|
25
|
+
* POSTs `application/x-www-form-urlencoded` with
|
|
26
|
+
* `grant_type=authorization_code`, the `code`, the allowlisted `redirect_uri`
|
|
27
|
+
* and the PKCE `code_verifier`. The client secret travels either in the
|
|
28
|
+
* `Authorization: Basic` header or in the body, whichever the provider
|
|
29
|
+
* expects — never in the URL, where it would land in access logs.
|
|
30
|
+
*
|
|
31
|
+
* @throws {OAuthRedirectUriError} If `redirectUri` is not allowlisted.
|
|
32
|
+
* @throws {OAuthProviderError} If the provider rejects the exchange.
|
|
33
|
+
* @throws {OAuthResponseError} If the token payload is malformed.
|
|
34
|
+
* @throws {OAuthNetworkError} On timeout or transport failure.
|
|
35
|
+
*/
|
|
36
|
+
export declare function exchangeCodeForToken(config: OAuthConfig, options: CodeExchangeOptions): Promise<OAuthTokenSet>;
|
|
37
|
+
/**
|
|
38
|
+
* Exchange a refresh token for a fresh access token.
|
|
39
|
+
*
|
|
40
|
+
* Refused for providers that do not issue refresh tokens on this flow —
|
|
41
|
+
* classic GitHub OAuth App tokens do not expire and GitHub issues none, so
|
|
42
|
+
* calling this for `github` throws rather than making a pointless request.
|
|
43
|
+
*
|
|
44
|
+
* Note that most providers do not return a new `refresh_token`; keep using
|
|
45
|
+
* the old one unless the response carries a replacement.
|
|
46
|
+
*
|
|
47
|
+
* @throws {OAuthConfigurationError} If the provider has no refresh support.
|
|
48
|
+
*/
|
|
49
|
+
export declare function refreshAccessToken(config: OAuthConfig, refreshToken: string): Promise<OAuthTokenSet>;
|
|
50
|
+
//# sourceMappingURL=oauthToken.core.d.ts.map
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token exchange, token-response validation, and refresh.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthClient/oauthToken
|
|
5
|
+
*/
|
|
6
|
+
import { OAuthConfigurationError, OAuthResponseError, } from "../oauthErrors/index.js";
|
|
7
|
+
import { assertValidCodeVerifier } from "../oauthSecurity/index.js";
|
|
8
|
+
import { assertRedirectUriAllowed, resolveConfig, resolveTokenUrl, } from "./oauthConfig.resolve.js";
|
|
9
|
+
import { basicAuthHeader, requestProviderJson } from "./oauthHttp.core.js";
|
|
10
|
+
/** Coerce a provider `expires_in` to seconds, or reject it. */
|
|
11
|
+
function readExpiresIn(value) {
|
|
12
|
+
if (value === undefined || value === null)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (typeof value === "number") {
|
|
15
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
16
|
+
throw new OAuthResponseError("The token endpoint returned a non-numeric expires_in.");
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === "string" && /^\d{1,15}$/.test(value)) {
|
|
21
|
+
return Number(value);
|
|
22
|
+
}
|
|
23
|
+
throw new OAuthResponseError("The token endpoint returned a non-numeric expires_in.");
|
|
24
|
+
}
|
|
25
|
+
function optionalString(payload, key) {
|
|
26
|
+
const value = payload[key];
|
|
27
|
+
if (value === undefined || value === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
30
|
+
throw new OAuthResponseError(`The token endpoint returned a malformed ${key}.`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate a token-endpoint payload into an {@link OAuthTokenSet}.
|
|
36
|
+
*
|
|
37
|
+
* Rejects: a non-object body (caught earlier by the JSON parser), a missing,
|
|
38
|
+
* non-string or blank `access_token`, a non-numeric `expires_in`, and a
|
|
39
|
+
* non-string `refresh_token` / `id_token` / `scope`. Prototype-polluting keys
|
|
40
|
+
* were already stripped by {@link parseJsonObject}.
|
|
41
|
+
*
|
|
42
|
+
* Exported for the response-validation tests; also useful if you have a token
|
|
43
|
+
* payload from elsewhere.
|
|
44
|
+
*
|
|
45
|
+
* @throws {OAuthResponseError} On any malformed field. The message never
|
|
46
|
+
* contains the payload.
|
|
47
|
+
*/
|
|
48
|
+
export function parseTokenResponse(payload) {
|
|
49
|
+
const accessToken = payload["access_token"];
|
|
50
|
+
if (typeof accessToken !== "string" || accessToken.trim().length === 0) {
|
|
51
|
+
throw new OAuthResponseError("The token endpoint returned no usable access_token.");
|
|
52
|
+
}
|
|
53
|
+
const tokenTypeRaw = payload["token_type"];
|
|
54
|
+
if (tokenTypeRaw !== undefined && typeof tokenTypeRaw !== "string") {
|
|
55
|
+
throw new OAuthResponseError("The token endpoint returned a malformed token_type.");
|
|
56
|
+
}
|
|
57
|
+
const expiresIn = readExpiresIn(payload["expires_in"]);
|
|
58
|
+
const refreshToken = optionalString(payload, "refresh_token");
|
|
59
|
+
const idToken = optionalString(payload, "id_token");
|
|
60
|
+
const scopeRaw = payload["scope"];
|
|
61
|
+
let scope;
|
|
62
|
+
if (scopeRaw !== undefined && scopeRaw !== null) {
|
|
63
|
+
if (typeof scopeRaw !== "string") {
|
|
64
|
+
throw new OAuthResponseError("The token endpoint returned a malformed scope.");
|
|
65
|
+
}
|
|
66
|
+
const parts = scopeRaw.split(/[\s,]+/).filter((part) => part.length > 0);
|
|
67
|
+
if (parts.length > 0)
|
|
68
|
+
scope = parts;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
accessToken,
|
|
72
|
+
tokenType: tokenTypeRaw !== undefined && tokenTypeRaw.length > 0
|
|
73
|
+
? tokenTypeRaw
|
|
74
|
+
: "Bearer",
|
|
75
|
+
...(expiresIn !== undefined ? { expiresIn } : {}),
|
|
76
|
+
...(refreshToken !== undefined ? { refreshToken } : {}),
|
|
77
|
+
...(scope !== undefined ? { scope } : {}),
|
|
78
|
+
...(idToken !== undefined ? { idToken } : {}),
|
|
79
|
+
raw: payload,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Build the token-request body and headers for the configured client auth. */
|
|
83
|
+
function tokenRequestParts(resolved, form) {
|
|
84
|
+
const headers = {
|
|
85
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
86
|
+
Accept: "application/json",
|
|
87
|
+
};
|
|
88
|
+
if (resolved.clientAuth === "basic") {
|
|
89
|
+
headers["Authorization"] = basicAuthHeader(resolved.clientId, resolved.clientSecret);
|
|
90
|
+
form.set("client_id", resolved.clientId);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
form.set("client_id", resolved.clientId);
|
|
94
|
+
form.set("client_secret", resolved.clientSecret);
|
|
95
|
+
}
|
|
96
|
+
return { headers, body: form.toString() };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Exchange an authorization code for tokens.
|
|
100
|
+
*
|
|
101
|
+
* POSTs `application/x-www-form-urlencoded` with
|
|
102
|
+
* `grant_type=authorization_code`, the `code`, the allowlisted `redirect_uri`
|
|
103
|
+
* and the PKCE `code_verifier`. The client secret travels either in the
|
|
104
|
+
* `Authorization: Basic` header or in the body, whichever the provider
|
|
105
|
+
* expects — never in the URL, where it would land in access logs.
|
|
106
|
+
*
|
|
107
|
+
* @throws {OAuthRedirectUriError} If `redirectUri` is not allowlisted.
|
|
108
|
+
* @throws {OAuthProviderError} If the provider rejects the exchange.
|
|
109
|
+
* @throws {OAuthResponseError} If the token payload is malformed.
|
|
110
|
+
* @throws {OAuthNetworkError} On timeout or transport failure.
|
|
111
|
+
*/
|
|
112
|
+
export async function exchangeCodeForToken(config, options) {
|
|
113
|
+
const resolved = resolveConfig(config);
|
|
114
|
+
if (typeof options.code !== "string" || options.code.trim().length === 0) {
|
|
115
|
+
throw new OAuthConfigurationError("An authorization code is required.");
|
|
116
|
+
}
|
|
117
|
+
assertValidCodeVerifier(options.codeVerifier);
|
|
118
|
+
const redirectUri = assertRedirectUriAllowed(resolved, options.redirectUri);
|
|
119
|
+
const url = resolveTokenUrl(resolved);
|
|
120
|
+
const form = new URLSearchParams();
|
|
121
|
+
form.set("grant_type", "authorization_code");
|
|
122
|
+
form.set("code", options.code);
|
|
123
|
+
form.set("redirect_uri", redirectUri);
|
|
124
|
+
form.set("code_verifier", options.codeVerifier);
|
|
125
|
+
const { headers, body } = tokenRequestParts(resolved, form);
|
|
126
|
+
const payload = await requestProviderJson(resolved, {
|
|
127
|
+
url,
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers,
|
|
130
|
+
body,
|
|
131
|
+
label: "token",
|
|
132
|
+
});
|
|
133
|
+
return parseTokenResponse(payload);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Exchange a refresh token for a fresh access token.
|
|
137
|
+
*
|
|
138
|
+
* Refused for providers that do not issue refresh tokens on this flow —
|
|
139
|
+
* classic GitHub OAuth App tokens do not expire and GitHub issues none, so
|
|
140
|
+
* calling this for `github` throws rather than making a pointless request.
|
|
141
|
+
*
|
|
142
|
+
* Note that most providers do not return a new `refresh_token`; keep using
|
|
143
|
+
* the old one unless the response carries a replacement.
|
|
144
|
+
*
|
|
145
|
+
* @throws {OAuthConfigurationError} If the provider has no refresh support.
|
|
146
|
+
*/
|
|
147
|
+
export async function refreshAccessToken(config, refreshToken) {
|
|
148
|
+
const resolved = resolveConfig(config);
|
|
149
|
+
if (!resolved.preset.supportsRefresh) {
|
|
150
|
+
throw new OAuthConfigurationError("This provider does not issue refresh tokens for the authorization-code flow.");
|
|
151
|
+
}
|
|
152
|
+
if (typeof refreshToken !== "string" || refreshToken.trim().length === 0) {
|
|
153
|
+
throw new OAuthConfigurationError("A refresh token is required.");
|
|
154
|
+
}
|
|
155
|
+
const url = resolveTokenUrl(resolved);
|
|
156
|
+
const form = new URLSearchParams();
|
|
157
|
+
form.set("grant_type", "refresh_token");
|
|
158
|
+
form.set("refresh_token", refreshToken);
|
|
159
|
+
if (resolved.scopes.length > 0)
|
|
160
|
+
form.set("scope", resolved.scopes.join(" "));
|
|
161
|
+
const { headers, body } = tokenRequestParts(resolved, form);
|
|
162
|
+
const payload = await requestProviderJson(resolved, {
|
|
163
|
+
url,
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers,
|
|
166
|
+
body,
|
|
167
|
+
label: "token",
|
|
168
|
+
});
|
|
169
|
+
return parseTokenResponse(payload);
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=oauthToken.core.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-info retrieval and normalisation.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthClient/oauthUserInfo
|
|
5
|
+
*/
|
|
6
|
+
import type { OAuthConfig, OAuthUserInfo } from "../oauthTypes/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* Fetch and normalise the authenticated user's profile.
|
|
9
|
+
*
|
|
10
|
+
* The access token is sent as `Authorization: Bearer` — never as a query
|
|
11
|
+
* parameter, where it would be captured by proxy and server logs.
|
|
12
|
+
*
|
|
13
|
+
* `email` is optional in the result on purpose: GitHub omits it for private
|
|
14
|
+
* addresses (this function then tries `/user/emails`, which needs the
|
|
15
|
+
* `user:email` scope) and Discord omits it without the `email` scope. If you
|
|
16
|
+
* key accounts by email, check for its absence and tell the user to grant the
|
|
17
|
+
* scope — do not fall back to a synthesised address.
|
|
18
|
+
*
|
|
19
|
+
* Apple has no user-info endpoint at all; for `apple` this throws an
|
|
20
|
+
* `OAuthConfigurationError` pointing you at the `id_token`.
|
|
21
|
+
*
|
|
22
|
+
* @throws {OAuthConfigurationError} If the provider has no user-info endpoint.
|
|
23
|
+
* @throws {OAuthProviderError} If the provider rejects the request.
|
|
24
|
+
* @throws {OAuthResponseError} If the payload has no usable user id.
|
|
25
|
+
*/
|
|
26
|
+
export declare function fetchUserInfo(config: OAuthConfig, accessToken: string): Promise<OAuthUserInfo>;
|
|
27
|
+
//# sourceMappingURL=oauthUserInfo.core.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-info retrieval and normalisation.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthClient/oauthUserInfo
|
|
5
|
+
*/
|
|
6
|
+
import { OAuthResponseError } from "../oauthErrors/index.js";
|
|
7
|
+
import { normalizeUserInfo } from "../oauthProviders/index.js";
|
|
8
|
+
import { assertSafeUrl } from "../oauthSecurity/index.js";
|
|
9
|
+
import { resolveConfig, resolveUserInfoUrl, } from "./oauthConfig.resolve.js";
|
|
10
|
+
import { requestProviderJson, requestProviderValue, } from "./oauthHttp.core.js";
|
|
11
|
+
/**
|
|
12
|
+
* GitHub's `/user` omits `email` whenever the address is private — which is
|
|
13
|
+
* the default for new accounts. When `user:email` was granted we ask
|
|
14
|
+
* `/user/emails` for the primary, verified address; otherwise the profile
|
|
15
|
+
* comes back without an email and the caller decides what to do. No address
|
|
16
|
+
* is ever synthesised from the login name.
|
|
17
|
+
*/
|
|
18
|
+
async function fetchGithubPrimaryEmail(resolved, userInfoUrl, accessToken) {
|
|
19
|
+
const emailsUrl = assertSafeUrl(new URL("/user/emails", userInfoUrl.origin).toString(), "userInfoUrl", "fetch");
|
|
20
|
+
let payload;
|
|
21
|
+
try {
|
|
22
|
+
payload = await requestProviderValue(resolved, {
|
|
23
|
+
url: emailsUrl,
|
|
24
|
+
method: "GET",
|
|
25
|
+
headers: userInfoHeaders(resolved, accessToken),
|
|
26
|
+
label: "user-info",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// The scope may not have been granted; that is not an auth failure.
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return readEmailArray(payload);
|
|
34
|
+
}
|
|
35
|
+
function readEmailArray(payload) {
|
|
36
|
+
if (!Array.isArray(payload))
|
|
37
|
+
return undefined;
|
|
38
|
+
for (const entry of payload) {
|
|
39
|
+
if (entry === null || typeof entry !== "object")
|
|
40
|
+
continue;
|
|
41
|
+
const record = entry;
|
|
42
|
+
const email = record["email"];
|
|
43
|
+
if (typeof email !== "string" || email.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
if (record["primary"] === true && record["verified"] === true) {
|
|
46
|
+
return { email, verified: true };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
function userInfoHeaders(resolved, accessToken) {
|
|
52
|
+
return {
|
|
53
|
+
Authorization: `Bearer ${accessToken}`,
|
|
54
|
+
Accept: "application/json",
|
|
55
|
+
"User-Agent": "zudojs-auth-oauth",
|
|
56
|
+
...(resolved.preset.userInfoHeaders ?? {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Fetch and normalise the authenticated user's profile.
|
|
61
|
+
*
|
|
62
|
+
* The access token is sent as `Authorization: Bearer` — never as a query
|
|
63
|
+
* parameter, where it would be captured by proxy and server logs.
|
|
64
|
+
*
|
|
65
|
+
* `email` is optional in the result on purpose: GitHub omits it for private
|
|
66
|
+
* addresses (this function then tries `/user/emails`, which needs the
|
|
67
|
+
* `user:email` scope) and Discord omits it without the `email` scope. If you
|
|
68
|
+
* key accounts by email, check for its absence and tell the user to grant the
|
|
69
|
+
* scope — do not fall back to a synthesised address.
|
|
70
|
+
*
|
|
71
|
+
* Apple has no user-info endpoint at all; for `apple` this throws an
|
|
72
|
+
* `OAuthConfigurationError` pointing you at the `id_token`.
|
|
73
|
+
*
|
|
74
|
+
* @throws {OAuthConfigurationError} If the provider has no user-info endpoint.
|
|
75
|
+
* @throws {OAuthProviderError} If the provider rejects the request.
|
|
76
|
+
* @throws {OAuthResponseError} If the payload has no usable user id.
|
|
77
|
+
*/
|
|
78
|
+
export async function fetchUserInfo(config, accessToken) {
|
|
79
|
+
const resolved = resolveConfig(config);
|
|
80
|
+
if (typeof accessToken !== "string" || accessToken.trim().length === 0) {
|
|
81
|
+
throw new OAuthResponseError("An access token is required.");
|
|
82
|
+
}
|
|
83
|
+
const url = resolveUserInfoUrl(resolved);
|
|
84
|
+
const payload = await requestProviderJson(resolved, {
|
|
85
|
+
url,
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: userInfoHeaders(resolved, accessToken),
|
|
88
|
+
label: "user-info",
|
|
89
|
+
});
|
|
90
|
+
const info = normalizeUserInfo(resolved.provider, payload);
|
|
91
|
+
if (info === undefined) {
|
|
92
|
+
throw new OAuthResponseError("The user-info endpoint returned no stable user identifier.");
|
|
93
|
+
}
|
|
94
|
+
if (resolved.provider === "github" &&
|
|
95
|
+
info.email === undefined &&
|
|
96
|
+
resolved.scopes.includes("user:email")) {
|
|
97
|
+
const primary = await fetchGithubPrimaryEmail(resolved, url, accessToken);
|
|
98
|
+
if (primary !== undefined) {
|
|
99
|
+
return { ...info, email: primary.email, emailVerified: primary.verified };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return info;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=oauthUserInfo.core.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 error classes and codes.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthErrors
|
|
5
|
+
*/
|
|
6
|
+
export { OAuthErrorCode, type OAuthErrorOptions, OAuthError, OAuthConfigurationError, OAuthEndpointNotAllowedError, OAuthRedirectUriError, OAuthStateMismatchError, OAuthProviderError, OAuthResponseError, OAuthResponseTooLargeError, OAuthNetworkError, } from "./oauthError.base.js";
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 error classes and codes.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthErrors
|
|
5
|
+
*/
|
|
6
|
+
export { OAuthErrorCode, OAuthError, OAuthConfigurationError, OAuthEndpointNotAllowedError, OAuthRedirectUriError, OAuthStateMismatchError, OAuthProviderError, OAuthResponseError, OAuthResponseTooLargeError, OAuthNetworkError, } from "./oauthError.base.js";
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error classes for the OAuth2 client.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthErrors/oauthError
|
|
5
|
+
*
|
|
6
|
+
* These are defined locally rather than extending `@zudojs/errors` so the
|
|
7
|
+
* package has no `@zudojs/*` dependency at all.
|
|
8
|
+
*
|
|
9
|
+
* **Secret hygiene.** No constructor here ever interpolates a client secret,
|
|
10
|
+
* an access token, a refresh token or a code verifier into `message`. The
|
|
11
|
+
* only provider-supplied text that reaches a message is an OAuth `error`
|
|
12
|
+
* code, and it is passed through a strict `[a-z0-9_.-]{1,64}` filter first,
|
|
13
|
+
* so a provider cannot echo a secret back into your logs. `message` is the
|
|
14
|
+
* first line of `stack`, so keeping it clean keeps the stack clean.
|
|
15
|
+
*/
|
|
16
|
+
/** Stable, machine-readable error codes. */
|
|
17
|
+
export declare const OAuthErrorCode: {
|
|
18
|
+
/** The config is unusable: missing field, bad URL, unsupported operation. */
|
|
19
|
+
readonly CONFIGURATION_INVALID: "OAUTH_CONFIGURATION_INVALID";
|
|
20
|
+
/** A URL failed the scheme / credential / SSRF guard. */
|
|
21
|
+
readonly ENDPOINT_NOT_ALLOWED: "OAUTH_ENDPOINT_NOT_ALLOWED";
|
|
22
|
+
/** The redirect URI is not in the caller's allowlist. */
|
|
23
|
+
readonly REDIRECT_URI_NOT_ALLOWED: "OAUTH_REDIRECT_URI_NOT_ALLOWED";
|
|
24
|
+
/** `state` did not match, was absent, or was malformed. */
|
|
25
|
+
readonly STATE_MISMATCH: "OAUTH_STATE_MISMATCH";
|
|
26
|
+
/** A PKCE verifier was malformed. */
|
|
27
|
+
readonly PKCE_INVALID: "OAUTH_PKCE_INVALID";
|
|
28
|
+
/** The provider returned a non-2xx or an OAuth `error` payload. */
|
|
29
|
+
readonly PROVIDER_REJECTED: "OAUTH_PROVIDER_REJECTED";
|
|
30
|
+
/** The provider's response was unparseable or structurally invalid. */
|
|
31
|
+
readonly PROVIDER_RESPONSE_INVALID: "OAUTH_PROVIDER_RESPONSE_INVALID";
|
|
32
|
+
/** The response body exceeded the configured cap. */
|
|
33
|
+
readonly RESPONSE_TOO_LARGE: "OAUTH_RESPONSE_TOO_LARGE";
|
|
34
|
+
/** The request timed out or the transport failed. */
|
|
35
|
+
readonly NETWORK: "OAUTH_NETWORK";
|
|
36
|
+
};
|
|
37
|
+
/** Union of {@link OAuthErrorCode} values. */
|
|
38
|
+
export type OAuthErrorCode = (typeof OAuthErrorCode)[keyof typeof OAuthErrorCode];
|
|
39
|
+
/** Options accepted by {@link OAuthError} and every subclass. */
|
|
40
|
+
export interface OAuthErrorOptions {
|
|
41
|
+
readonly code?: OAuthErrorCode;
|
|
42
|
+
readonly statusCode?: number;
|
|
43
|
+
readonly expose?: boolean;
|
|
44
|
+
readonly cause?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Base error for every OAuth2 failure.
|
|
48
|
+
*
|
|
49
|
+
* `expose` says whether the message is safe to hand to an end user; it is
|
|
50
|
+
* `true` for request-caused failures and `false` for configuration ones
|
|
51
|
+
* (which describe your deployment, not the request).
|
|
52
|
+
*/
|
|
53
|
+
export declare class OAuthError extends Error {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
/** Machine-readable code. */
|
|
56
|
+
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
|
+
constructor(message: string, options?: OAuthErrorOptions);
|
|
62
|
+
}
|
|
63
|
+
/** The configuration is missing something or is structurally unusable. */
|
|
64
|
+
export declare class OAuthConfigurationError extends OAuthError {
|
|
65
|
+
readonly name: string;
|
|
66
|
+
constructor(message: string, options?: OAuthErrorOptions);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* An endpoint URL is not an acceptable target: wrong scheme, embedded
|
|
70
|
+
* credentials, or a private / loopback / link-local / metadata host.
|
|
71
|
+
*/
|
|
72
|
+
export declare class OAuthEndpointNotAllowedError extends OAuthError {
|
|
73
|
+
readonly name: string;
|
|
74
|
+
constructor(message: string, options?: OAuthErrorOptions);
|
|
75
|
+
}
|
|
76
|
+
/** The requested redirect URI is not in `config.allowedRedirectUris`. */
|
|
77
|
+
export declare class OAuthRedirectUriError extends OAuthError {
|
|
78
|
+
readonly name: string;
|
|
79
|
+
constructor(message: string, options?: OAuthErrorOptions);
|
|
80
|
+
}
|
|
81
|
+
/** The callback's `state` did not match the one that was issued. */
|
|
82
|
+
export declare class OAuthStateMismatchError extends OAuthError {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
constructor(message?: string, options?: OAuthErrorOptions);
|
|
85
|
+
}
|
|
86
|
+
/** The provider refused the request. */
|
|
87
|
+
export declare class OAuthProviderError extends OAuthError {
|
|
88
|
+
readonly name: string;
|
|
89
|
+
/** The provider's OAuth `error` code, if it sent a well-formed one. */
|
|
90
|
+
readonly providerError?: string;
|
|
91
|
+
/** The provider's HTTP status, if the exchange got that far. */
|
|
92
|
+
readonly providerStatus?: number;
|
|
93
|
+
constructor(message: string, options?: OAuthErrorOptions & {
|
|
94
|
+
readonly providerError?: string;
|
|
95
|
+
readonly providerStatus?: number;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** The provider's response was not a usable OAuth2 payload. */
|
|
99
|
+
export declare class OAuthResponseError extends OAuthError {
|
|
100
|
+
readonly name: string;
|
|
101
|
+
constructor(message: string, options?: OAuthErrorOptions);
|
|
102
|
+
}
|
|
103
|
+
/** The provider's response body exceeded `maxResponseBytes`. */
|
|
104
|
+
export declare class OAuthResponseTooLargeError extends OAuthError {
|
|
105
|
+
readonly name: string;
|
|
106
|
+
constructor(limitBytes: number, options?: OAuthErrorOptions);
|
|
107
|
+
}
|
|
108
|
+
/** The request timed out or the transport failed. */
|
|
109
|
+
export declare class OAuthNetworkError extends OAuthError {
|
|
110
|
+
readonly name: string;
|
|
111
|
+
constructor(message: string, options?: OAuthErrorOptions);
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=oauthError.base.d.ts.map
|