@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,168 @@
|
|
|
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 const OAuthErrorCode = {
|
|
18
|
+
/** The config is unusable: missing field, bad URL, unsupported operation. */
|
|
19
|
+
CONFIGURATION_INVALID: "OAUTH_CONFIGURATION_INVALID",
|
|
20
|
+
/** A URL failed the scheme / credential / SSRF guard. */
|
|
21
|
+
ENDPOINT_NOT_ALLOWED: "OAUTH_ENDPOINT_NOT_ALLOWED",
|
|
22
|
+
/** The redirect URI is not in the caller's allowlist. */
|
|
23
|
+
REDIRECT_URI_NOT_ALLOWED: "OAUTH_REDIRECT_URI_NOT_ALLOWED",
|
|
24
|
+
/** `state` did not match, was absent, or was malformed. */
|
|
25
|
+
STATE_MISMATCH: "OAUTH_STATE_MISMATCH",
|
|
26
|
+
/** A PKCE verifier was malformed. */
|
|
27
|
+
PKCE_INVALID: "OAUTH_PKCE_INVALID",
|
|
28
|
+
/** The provider returned a non-2xx or an OAuth `error` payload. */
|
|
29
|
+
PROVIDER_REJECTED: "OAUTH_PROVIDER_REJECTED",
|
|
30
|
+
/** The provider's response was unparseable or structurally invalid. */
|
|
31
|
+
PROVIDER_RESPONSE_INVALID: "OAUTH_PROVIDER_RESPONSE_INVALID",
|
|
32
|
+
/** The response body exceeded the configured cap. */
|
|
33
|
+
RESPONSE_TOO_LARGE: "OAUTH_RESPONSE_TOO_LARGE",
|
|
34
|
+
/** The request timed out or the transport failed. */
|
|
35
|
+
NETWORK: "OAUTH_NETWORK",
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Base error for every OAuth2 failure.
|
|
39
|
+
*
|
|
40
|
+
* `expose` says whether the message is safe to hand to an end user; it is
|
|
41
|
+
* `true` for request-caused failures and `false` for configuration ones
|
|
42
|
+
* (which describe your deployment, not the request).
|
|
43
|
+
*/
|
|
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;
|
|
52
|
+
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;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The configuration is missing something or is structurally unusable. */
|
|
60
|
+
export class OAuthConfigurationError extends OAuthError {
|
|
61
|
+
name = "OAuthConfigurationError";
|
|
62
|
+
constructor(message, options) {
|
|
63
|
+
super(message, {
|
|
64
|
+
code: OAuthErrorCode.CONFIGURATION_INVALID,
|
|
65
|
+
statusCode: 500,
|
|
66
|
+
expose: false,
|
|
67
|
+
...options,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* An endpoint URL is not an acceptable target: wrong scheme, embedded
|
|
73
|
+
* credentials, or a private / loopback / link-local / metadata host.
|
|
74
|
+
*/
|
|
75
|
+
export class OAuthEndpointNotAllowedError extends OAuthError {
|
|
76
|
+
name = "OAuthEndpointNotAllowedError";
|
|
77
|
+
constructor(message, options) {
|
|
78
|
+
super(message, {
|
|
79
|
+
code: OAuthErrorCode.ENDPOINT_NOT_ALLOWED,
|
|
80
|
+
statusCode: 500,
|
|
81
|
+
expose: false,
|
|
82
|
+
...options,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The requested redirect URI is not in `config.allowedRedirectUris`. */
|
|
87
|
+
export class OAuthRedirectUriError extends OAuthError {
|
|
88
|
+
name = "OAuthRedirectUriError";
|
|
89
|
+
constructor(message, options) {
|
|
90
|
+
super(message, {
|
|
91
|
+
code: OAuthErrorCode.REDIRECT_URI_NOT_ALLOWED,
|
|
92
|
+
statusCode: 400,
|
|
93
|
+
expose: true,
|
|
94
|
+
...options,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** The callback's `state` did not match the one that was issued. */
|
|
99
|
+
export class OAuthStateMismatchError extends OAuthError {
|
|
100
|
+
name = "OAuthStateMismatchError";
|
|
101
|
+
constructor(message = "OAuth state verification failed.", options) {
|
|
102
|
+
super(message, {
|
|
103
|
+
code: OAuthErrorCode.STATE_MISMATCH,
|
|
104
|
+
statusCode: 400,
|
|
105
|
+
expose: true,
|
|
106
|
+
...options,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The provider refused the request. */
|
|
111
|
+
export class OAuthProviderError extends OAuthError {
|
|
112
|
+
name = "OAuthProviderError";
|
|
113
|
+
/** The provider's OAuth `error` code, if it sent a well-formed one. */
|
|
114
|
+
providerError;
|
|
115
|
+
/** The provider's HTTP status, if the exchange got that far. */
|
|
116
|
+
providerStatus;
|
|
117
|
+
constructor(message, options) {
|
|
118
|
+
super(message, {
|
|
119
|
+
code: OAuthErrorCode.PROVIDER_REJECTED,
|
|
120
|
+
statusCode: 502,
|
|
121
|
+
expose: true,
|
|
122
|
+
...options,
|
|
123
|
+
});
|
|
124
|
+
if (options?.providerError !== undefined) {
|
|
125
|
+
this.providerError = options.providerError;
|
|
126
|
+
}
|
|
127
|
+
if (options?.providerStatus !== undefined) {
|
|
128
|
+
this.providerStatus = options.providerStatus;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** The provider's response was not a usable OAuth2 payload. */
|
|
133
|
+
export class OAuthResponseError extends OAuthError {
|
|
134
|
+
name = "OAuthResponseError";
|
|
135
|
+
constructor(message, options) {
|
|
136
|
+
super(message, {
|
|
137
|
+
code: OAuthErrorCode.PROVIDER_RESPONSE_INVALID,
|
|
138
|
+
statusCode: 502,
|
|
139
|
+
expose: true,
|
|
140
|
+
...options,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** The provider's response body exceeded `maxResponseBytes`. */
|
|
145
|
+
export class OAuthResponseTooLargeError extends OAuthError {
|
|
146
|
+
name = "OAuthResponseTooLargeError";
|
|
147
|
+
constructor(limitBytes, options) {
|
|
148
|
+
super(`OAuth provider response exceeded the ${limitBytes}-byte limit.`, {
|
|
149
|
+
code: OAuthErrorCode.RESPONSE_TOO_LARGE,
|
|
150
|
+
statusCode: 502,
|
|
151
|
+
expose: true,
|
|
152
|
+
...options,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** The request timed out or the transport failed. */
|
|
157
|
+
export class OAuthNetworkError extends OAuthError {
|
|
158
|
+
name = "OAuthNetworkError";
|
|
159
|
+
constructor(message, options) {
|
|
160
|
+
super(message, {
|
|
161
|
+
code: OAuthErrorCode.NETWORK,
|
|
162
|
+
statusCode: 504,
|
|
163
|
+
expose: true,
|
|
164
|
+
...options,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=oauthError.base.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint presets and profile normalisation for the supported providers.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthProviders/oauthProvider
|
|
5
|
+
*
|
|
6
|
+
* A preset only supplies defaults. Anything set on the `OAuthConfig` wins, so
|
|
7
|
+
* a tenanted Microsoft install or a self-hosted GitLab can override the URLs
|
|
8
|
+
* without leaving the `custom` provider behind.
|
|
9
|
+
*/
|
|
10
|
+
import type { ClientAuthMethod, OAuthProvider, OAuthUserInfo } from "../oauthTypes/index.js";
|
|
11
|
+
/** Everything the client needs to know about one provider. */
|
|
12
|
+
export interface OAuthProviderPreset {
|
|
13
|
+
/** Default authorization endpoint. */
|
|
14
|
+
readonly authorizeUrl?: string;
|
|
15
|
+
/** Default token endpoint. */
|
|
16
|
+
readonly tokenUrl?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Default user-info endpoint. Absent for providers that have none — Apple
|
|
19
|
+
* returns the profile in the `id_token` on the first authorization only.
|
|
20
|
+
*/
|
|
21
|
+
readonly userInfoUrl?: string;
|
|
22
|
+
/** Scopes requested when the caller does not specify any. */
|
|
23
|
+
readonly defaultScopes: readonly string[];
|
|
24
|
+
/** How this provider expects the client to authenticate at the token endpoint. */
|
|
25
|
+
readonly clientAuth: ClientAuthMethod;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the provider issues refresh tokens on the standard flow.
|
|
28
|
+
*
|
|
29
|
+
* `false` for GitHub: classic OAuth App tokens do not expire and no refresh
|
|
30
|
+
* token is issued. (GitHub Apps with expiring tokens do — override the
|
|
31
|
+
* endpoints via `custom` if that is your setup.)
|
|
32
|
+
*/
|
|
33
|
+
readonly supportsRefresh: boolean;
|
|
34
|
+
/** Extra parameters appended to every authorization request. */
|
|
35
|
+
readonly authorizeParams?: Readonly<Record<string, string>>;
|
|
36
|
+
/** Extra headers sent to the user-info endpoint. */
|
|
37
|
+
readonly userInfoHeaders?: Readonly<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
/** Endpoint presets, keyed by provider. */
|
|
40
|
+
export declare const PROVIDER_PRESETS: Readonly<Record<OAuthProvider, OAuthProviderPreset>>;
|
|
41
|
+
/**
|
|
42
|
+
* Normalise a provider's user-info payload into {@link OAuthUserInfo}.
|
|
43
|
+
*
|
|
44
|
+
* No field is invented. Where a provider does not return an email — GitHub
|
|
45
|
+
* with a private address, Discord without the `email` scope — the result
|
|
46
|
+
* simply has no `email`, and the caller decides what to do about it.
|
|
47
|
+
*
|
|
48
|
+
* @param provider - Which provider produced the payload.
|
|
49
|
+
* @param payload - The sanitized JSON object from the user-info endpoint.
|
|
50
|
+
* @returns The normalised profile, or `undefined` if no stable id was found.
|
|
51
|
+
*/
|
|
52
|
+
export declare function normalizeUserInfo(provider: OAuthProvider, payload: Record<string, unknown>): OAuthUserInfo | undefined;
|
|
53
|
+
//# sourceMappingURL=oauthProvider.presets.d.ts.map
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint presets and profile normalisation for the supported providers.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthProviders/oauthProvider
|
|
5
|
+
*
|
|
6
|
+
* A preset only supplies defaults. Anything set on the `OAuthConfig` wins, so
|
|
7
|
+
* a tenanted Microsoft install or a self-hosted GitLab can override the URLs
|
|
8
|
+
* without leaving the `custom` provider behind.
|
|
9
|
+
*/
|
|
10
|
+
/** Read a string field from a sanitized provider payload. */
|
|
11
|
+
function str(payload, key) {
|
|
12
|
+
const value = payload[key];
|
|
13
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
14
|
+
return value;
|
|
15
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
16
|
+
return String(value);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Read a boolean field from a sanitized provider payload. */
|
|
20
|
+
function bool(payload, key) {
|
|
21
|
+
const value = payload[key];
|
|
22
|
+
return typeof value === "boolean" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
/** Assemble an {@link OAuthUserInfo} without emitting `undefined` fields. */
|
|
25
|
+
function profile(providerId, fields, raw) {
|
|
26
|
+
return {
|
|
27
|
+
providerId,
|
|
28
|
+
...(fields.email !== undefined ? { email: fields.email } : {}),
|
|
29
|
+
...(fields.emailVerified !== undefined
|
|
30
|
+
? { emailVerified: fields.emailVerified }
|
|
31
|
+
: {}),
|
|
32
|
+
...(fields.name !== undefined ? { name: fields.name } : {}),
|
|
33
|
+
...(fields.avatarUrl !== undefined ? { avatarUrl: fields.avatarUrl } : {}),
|
|
34
|
+
raw,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Endpoint presets, keyed by provider. */
|
|
38
|
+
export const PROVIDER_PRESETS = {
|
|
39
|
+
google: {
|
|
40
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
41
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
42
|
+
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
43
|
+
defaultScopes: ["openid", "email", "profile"],
|
|
44
|
+
clientAuth: "body",
|
|
45
|
+
supportsRefresh: true,
|
|
46
|
+
// Google only returns a refresh token when both are set.
|
|
47
|
+
authorizeParams: { access_type: "offline", prompt: "consent" },
|
|
48
|
+
},
|
|
49
|
+
github: {
|
|
50
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
51
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
52
|
+
userInfoUrl: "https://api.github.com/user",
|
|
53
|
+
defaultScopes: ["read:user", "user:email"],
|
|
54
|
+
clientAuth: "body",
|
|
55
|
+
supportsRefresh: false,
|
|
56
|
+
userInfoHeaders: {
|
|
57
|
+
Accept: "application/vnd.github+json",
|
|
58
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
microsoft: {
|
|
62
|
+
authorizeUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
63
|
+
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
64
|
+
userInfoUrl: "https://graph.microsoft.com/v1.0/me",
|
|
65
|
+
defaultScopes: ["openid", "email", "profile", "offline_access", "User.Read"],
|
|
66
|
+
clientAuth: "body",
|
|
67
|
+
supportsRefresh: true,
|
|
68
|
+
},
|
|
69
|
+
apple: {
|
|
70
|
+
authorizeUrl: "https://appleid.apple.com/auth/authorize",
|
|
71
|
+
tokenUrl: "https://appleid.apple.com/auth/token",
|
|
72
|
+
// Apple has no user-info endpoint; the profile arrives in the id_token.
|
|
73
|
+
defaultScopes: ["name", "email"],
|
|
74
|
+
clientAuth: "body",
|
|
75
|
+
supportsRefresh: true,
|
|
76
|
+
// Apple requires form_post when name/email scopes are requested.
|
|
77
|
+
authorizeParams: { response_mode: "form_post" },
|
|
78
|
+
},
|
|
79
|
+
discord: {
|
|
80
|
+
authorizeUrl: "https://discord.com/oauth2/authorize",
|
|
81
|
+
tokenUrl: "https://discord.com/api/oauth2/token",
|
|
82
|
+
userInfoUrl: "https://discord.com/api/v10/users/@me",
|
|
83
|
+
defaultScopes: ["identify", "email"],
|
|
84
|
+
clientAuth: "basic",
|
|
85
|
+
supportsRefresh: true,
|
|
86
|
+
},
|
|
87
|
+
custom: {
|
|
88
|
+
defaultScopes: [],
|
|
89
|
+
clientAuth: "body",
|
|
90
|
+
supportsRefresh: true,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Normalise a provider's user-info payload into {@link OAuthUserInfo}.
|
|
95
|
+
*
|
|
96
|
+
* No field is invented. Where a provider does not return an email — GitHub
|
|
97
|
+
* with a private address, Discord without the `email` scope — the result
|
|
98
|
+
* simply has no `email`, and the caller decides what to do about it.
|
|
99
|
+
*
|
|
100
|
+
* @param provider - Which provider produced the payload.
|
|
101
|
+
* @param payload - The sanitized JSON object from the user-info endpoint.
|
|
102
|
+
* @returns The normalised profile, or `undefined` if no stable id was found.
|
|
103
|
+
*/
|
|
104
|
+
export function normalizeUserInfo(provider, payload) {
|
|
105
|
+
switch (provider) {
|
|
106
|
+
case "google": {
|
|
107
|
+
const id = str(payload, "sub");
|
|
108
|
+
if (id === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
return profile(id, {
|
|
111
|
+
...(str(payload, "email") !== undefined
|
|
112
|
+
? { email: str(payload, "email") }
|
|
113
|
+
: {}),
|
|
114
|
+
...(bool(payload, "email_verified") !== undefined
|
|
115
|
+
? { emailVerified: bool(payload, "email_verified") }
|
|
116
|
+
: {}),
|
|
117
|
+
...(str(payload, "name") !== undefined
|
|
118
|
+
? { name: str(payload, "name") }
|
|
119
|
+
: {}),
|
|
120
|
+
...(str(payload, "picture") !== undefined
|
|
121
|
+
? { avatarUrl: str(payload, "picture") }
|
|
122
|
+
: {}),
|
|
123
|
+
}, payload);
|
|
124
|
+
}
|
|
125
|
+
case "github": {
|
|
126
|
+
const id = str(payload, "id");
|
|
127
|
+
if (id === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
const email = str(payload, "email");
|
|
130
|
+
const name = str(payload, "name") ?? str(payload, "login");
|
|
131
|
+
return profile(id, {
|
|
132
|
+
...(email !== undefined ? { email } : {}),
|
|
133
|
+
...(name !== undefined ? { name } : {}),
|
|
134
|
+
...(str(payload, "avatar_url") !== undefined
|
|
135
|
+
? { avatarUrl: str(payload, "avatar_url") }
|
|
136
|
+
: {}),
|
|
137
|
+
}, payload);
|
|
138
|
+
}
|
|
139
|
+
case "microsoft": {
|
|
140
|
+
const id = str(payload, "id") ?? str(payload, "sub");
|
|
141
|
+
if (id === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
const email = str(payload, "mail") ?? str(payload, "userPrincipalName");
|
|
144
|
+
const name = str(payload, "displayName") ?? str(payload, "name");
|
|
145
|
+
return profile(id, {
|
|
146
|
+
...(email !== undefined ? { email } : {}),
|
|
147
|
+
...(name !== undefined ? { name } : {}),
|
|
148
|
+
}, payload);
|
|
149
|
+
}
|
|
150
|
+
case "discord": {
|
|
151
|
+
const id = str(payload, "id");
|
|
152
|
+
if (id === undefined)
|
|
153
|
+
return undefined;
|
|
154
|
+
const email = str(payload, "email");
|
|
155
|
+
const verified = bool(payload, "verified");
|
|
156
|
+
const name = str(payload, "global_name") ?? str(payload, "username");
|
|
157
|
+
const avatarHash = str(payload, "avatar");
|
|
158
|
+
const avatarUrl = avatarHash === undefined
|
|
159
|
+
? undefined
|
|
160
|
+
: `https://cdn.discordapp.com/avatars/${encodeURIComponent(id)}/${encodeURIComponent(avatarHash)}.png`;
|
|
161
|
+
return profile(id, {
|
|
162
|
+
...(email !== undefined ? { email } : {}),
|
|
163
|
+
...(email !== undefined && verified !== undefined
|
|
164
|
+
? { emailVerified: verified }
|
|
165
|
+
: {}),
|
|
166
|
+
...(name !== undefined ? { name } : {}),
|
|
167
|
+
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
|
168
|
+
}, payload);
|
|
169
|
+
}
|
|
170
|
+
case "apple":
|
|
171
|
+
case "custom": {
|
|
172
|
+
const id = str(payload, "sub") ?? str(payload, "id");
|
|
173
|
+
if (id === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
const name = str(payload, "name") ?? str(payload, "displayName");
|
|
176
|
+
const avatarUrl = str(payload, "picture") ?? str(payload, "avatar_url");
|
|
177
|
+
return profile(id, {
|
|
178
|
+
...(str(payload, "email") !== undefined
|
|
179
|
+
? { email: str(payload, "email") }
|
|
180
|
+
: {}),
|
|
181
|
+
...(bool(payload, "email_verified") !== undefined
|
|
182
|
+
? { emailVerified: bool(payload, "email_verified") }
|
|
183
|
+
: {}),
|
|
184
|
+
...(name !== undefined ? { name } : {}),
|
|
185
|
+
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
|
186
|
+
}, payload);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=oauthProvider.presets.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security primitives: PKCE, state, URL guards, defensive JSON parsing.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthSecurity
|
|
5
|
+
*/
|
|
6
|
+
export { generateCodeVerifier, assertValidCodeVerifier, deriveCodeChallenge, } from "./oauthPkce.core.js";
|
|
7
|
+
export { generateState, verifyState } from "./oauthState.core.js";
|
|
8
|
+
export { isBlockedFetchHost, assertSafeUrl, type UrlUse, } from "./oauthUrl.guard.js";
|
|
9
|
+
export { sanitizeJsonValue, parseJsonObject, parseJsonValue, } from "./oauthJson.sanitize.js";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security primitives: PKCE, state, URL guards, defensive JSON parsing.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthSecurity
|
|
5
|
+
*/
|
|
6
|
+
export { generateCodeVerifier, assertValidCodeVerifier, deriveCodeChallenge, } from "./oauthPkce.core.js";
|
|
7
|
+
export { generateState, verifyState } from "./oauthState.core.js";
|
|
8
|
+
export { isBlockedFetchHost, assertSafeUrl, } from "./oauthUrl.guard.js";
|
|
9
|
+
export { sanitizeJsonValue, parseJsonObject, parseJsonValue, } from "./oauthJson.sanitize.js";
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive parsing of JSON that came from a provider.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthSecurity/oauthJson
|
|
5
|
+
*
|
|
6
|
+
* A provider response is untrusted input. `JSON.parse` itself is safe, but
|
|
7
|
+
* anything that later merges or spreads the result can be steered by
|
|
8
|
+
* `__proto__`, `constructor` or `prototype` keys, so those keys are stripped
|
|
9
|
+
* from every object before the payload is handed on. Depth and breadth are
|
|
10
|
+
* also bounded, so a deeply nested body cannot exhaust the stack.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Strip prototype-polluting keys from a JSON-derived object graph.
|
|
14
|
+
*
|
|
15
|
+
* @param value - Any value produced by `JSON.parse`.
|
|
16
|
+
* @returns A structurally identical value with the forbidden keys removed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function sanitizeJsonValue(value: unknown): unknown;
|
|
19
|
+
/**
|
|
20
|
+
* Parse a provider body as a JSON object and sanitize it.
|
|
21
|
+
*
|
|
22
|
+
* @param text - The (already size-capped) response body.
|
|
23
|
+
* @param label - Endpoint name for the error message. Never a secret.
|
|
24
|
+
* @returns A plain object with prototype-polluting keys removed.
|
|
25
|
+
* @throws {OAuthResponseError} If the body is not JSON, or is not an object
|
|
26
|
+
* (a top-level array, string, number or `null` is rejected).
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseJsonObject(text: string, label: string): Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Parse any JSON body and sanitize it, allowing a top-level array.
|
|
31
|
+
*
|
|
32
|
+
* Needed for endpoints that legitimately return a list — GitHub's
|
|
33
|
+
* `/user/emails`, for one.
|
|
34
|
+
*
|
|
35
|
+
* @throws {OAuthResponseError} If the body is not valid JSON.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseJsonValue(text: string, label: string): unknown;
|
|
38
|
+
//# sourceMappingURL=oauthJson.sanitize.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive parsing of JSON that came from a provider.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthSecurity/oauthJson
|
|
5
|
+
*
|
|
6
|
+
* A provider response is untrusted input. `JSON.parse` itself is safe, but
|
|
7
|
+
* anything that later merges or spreads the result can be steered by
|
|
8
|
+
* `__proto__`, `constructor` or `prototype` keys, so those keys are stripped
|
|
9
|
+
* from every object before the payload is handed on. Depth and breadth are
|
|
10
|
+
* also bounded, so a deeply nested body cannot exhaust the stack.
|
|
11
|
+
*/
|
|
12
|
+
import { OAuthResponseError } from "../oauthErrors/index.js";
|
|
13
|
+
/** Keys removed from every object reconstructed from provider JSON. */
|
|
14
|
+
const FORBIDDEN_KEYS = new Set([
|
|
15
|
+
"__proto__",
|
|
16
|
+
"constructor",
|
|
17
|
+
"prototype",
|
|
18
|
+
]);
|
|
19
|
+
/** Maximum nesting depth kept from a provider payload. */
|
|
20
|
+
const MAX_DEPTH = 12;
|
|
21
|
+
function sanitizeValue(value, depth) {
|
|
22
|
+
if (depth > MAX_DEPTH)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const item of value) {
|
|
27
|
+
out.push(sanitizeValue(item, depth + 1));
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
if (value !== null && typeof value === "object") {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [key, item] of Object.entries(value)) {
|
|
34
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
35
|
+
continue;
|
|
36
|
+
out[key] = sanitizeValue(item, depth + 1);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Strip prototype-polluting keys from a JSON-derived object graph.
|
|
44
|
+
*
|
|
45
|
+
* @param value - Any value produced by `JSON.parse`.
|
|
46
|
+
* @returns A structurally identical value with the forbidden keys removed.
|
|
47
|
+
*/
|
|
48
|
+
export function sanitizeJsonValue(value) {
|
|
49
|
+
return sanitizeValue(value, 0);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse a provider body as a JSON object and sanitize it.
|
|
53
|
+
*
|
|
54
|
+
* @param text - The (already size-capped) response body.
|
|
55
|
+
* @param label - Endpoint name for the error message. Never a secret.
|
|
56
|
+
* @returns A plain object with prototype-polluting keys removed.
|
|
57
|
+
* @throws {OAuthResponseError} If the body is not JSON, or is not an object
|
|
58
|
+
* (a top-level array, string, number or `null` is rejected).
|
|
59
|
+
*/
|
|
60
|
+
export function parseJsonObject(text, label) {
|
|
61
|
+
const sanitized = parseJsonValue(text, label);
|
|
62
|
+
if (sanitized === null || typeof sanitized !== "object" || Array.isArray(sanitized)) {
|
|
63
|
+
throw new OAuthResponseError(`${label} did not return a JSON object.`);
|
|
64
|
+
}
|
|
65
|
+
return sanitized;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Parse any JSON body and sanitize it, allowing a top-level array.
|
|
69
|
+
*
|
|
70
|
+
* Needed for endpoints that legitimately return a list — GitHub's
|
|
71
|
+
* `/user/emails`, for one.
|
|
72
|
+
*
|
|
73
|
+
* @throws {OAuthResponseError} If the body is not valid JSON.
|
|
74
|
+
*/
|
|
75
|
+
export function parseJsonValue(text, label) {
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new OAuthResponseError(`${label} did not return valid JSON.`);
|
|
82
|
+
}
|
|
83
|
+
return sanitizeValue(parsed, 0);
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=oauthJson.sanitize.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) verifier and challenge handling.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthSecurity/oauthPkce
|
|
5
|
+
*
|
|
6
|
+
* PKCE is always on and always `S256`. The `plain` method is not implemented
|
|
7
|
+
* and never will be: it offers no protection against an attacker who can read
|
|
8
|
+
* the authorization request, which is the threat PKCE exists to address.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Generate a cryptographically random PKCE `code_verifier`.
|
|
12
|
+
*
|
|
13
|
+
* 48 random bytes rendered as base64url give 64 characters drawn only from
|
|
14
|
+
* the unreserved alphabet, with 384 bits of entropy and no modulo bias.
|
|
15
|
+
*
|
|
16
|
+
* @returns A verifier that satisfies {@link assertValidCodeVerifier}.
|
|
17
|
+
*/
|
|
18
|
+
export declare function generateCodeVerifier(): string;
|
|
19
|
+
/**
|
|
20
|
+
* Throw unless `verifier` is a syntactically valid RFC 7636 code verifier.
|
|
21
|
+
*
|
|
22
|
+
* @throws {OAuthError} With code `OAUTH_PKCE_INVALID`. The verifier itself is
|
|
23
|
+
* a secret and never appears in the message.
|
|
24
|
+
*/
|
|
25
|
+
export declare function assertValidCodeVerifier(verifier: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* Derive the `S256` code challenge: `base64url(SHA-256(ASCII(verifier)))`.
|
|
28
|
+
*
|
|
29
|
+
* @param verifier - A valid code verifier.
|
|
30
|
+
* @returns The challenge to send as `code_challenge`.
|
|
31
|
+
* @throws {OAuthError} If the verifier is malformed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function deriveCodeChallenge(verifier: string): string;
|
|
34
|
+
//# sourceMappingURL=oauthPkce.core.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) verifier and challenge handling.
|
|
3
|
+
*
|
|
4
|
+
* @module oauthSecurity/oauthPkce
|
|
5
|
+
*
|
|
6
|
+
* PKCE is always on and always `S256`. The `plain` method is not implemented
|
|
7
|
+
* and never will be: it offers no protection against an attacker who can read
|
|
8
|
+
* the authorization request, which is the threat PKCE exists to address.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { OAuthError, OAuthErrorCode } from "../oauthErrors/index.js";
|
|
12
|
+
/** RFC 7636 §4.1 — verifiers are 43-128 chars of the unreserved alphabet. */
|
|
13
|
+
const VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
14
|
+
/**
|
|
15
|
+
* Generate a cryptographically random PKCE `code_verifier`.
|
|
16
|
+
*
|
|
17
|
+
* 48 random bytes rendered as base64url give 64 characters drawn only from
|
|
18
|
+
* the unreserved alphabet, with 384 bits of entropy and no modulo bias.
|
|
19
|
+
*
|
|
20
|
+
* @returns A verifier that satisfies {@link assertValidCodeVerifier}.
|
|
21
|
+
*/
|
|
22
|
+
export function generateCodeVerifier() {
|
|
23
|
+
return randomBytes(48).toString("base64url");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Throw unless `verifier` is a syntactically valid RFC 7636 code verifier.
|
|
27
|
+
*
|
|
28
|
+
* @throws {OAuthError} With code `OAUTH_PKCE_INVALID`. The verifier itself is
|
|
29
|
+
* a secret and never appears in the message.
|
|
30
|
+
*/
|
|
31
|
+
export function assertValidCodeVerifier(verifier) {
|
|
32
|
+
if (typeof verifier !== "string" || !VERIFIER_PATTERN.test(verifier)) {
|
|
33
|
+
throw new OAuthError("PKCE code verifier must be 43-128 characters from the unreserved alphabet.", { code: OAuthErrorCode.PKCE_INVALID, statusCode: 400, expose: false });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Derive the `S256` code challenge: `base64url(SHA-256(ASCII(verifier)))`.
|
|
38
|
+
*
|
|
39
|
+
* @param verifier - A valid code verifier.
|
|
40
|
+
* @returns The challenge to send as `code_challenge`.
|
|
41
|
+
* @throws {OAuthError} If the verifier is malformed.
|
|
42
|
+
*/
|
|
43
|
+
export function deriveCodeChallenge(verifier) {
|
|
44
|
+
assertValidCodeVerifier(verifier);
|
|
45
|
+
return createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=oauthPkce.core.js.map
|