@strapi/plugin-users-permissions 5.50.1 → 5.51.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/admin/src/pages/Roles/pages/EditPage.jsx +1 -1
- package/admin/src/translations/en.json +2 -0
- package/admin/src/translations/ko.json +2 -1
- package/dist/admin/pages/Roles/pages/EditPage.js +1 -1
- package/dist/admin/pages/Roles/pages/EditPage.js.map +1 -1
- package/dist/admin/pages/Roles/pages/EditPage.mjs +1 -1
- package/dist/admin/pages/Roles/pages/EditPage.mjs.map +1 -1
- package/dist/admin/translations/en.json.js +1 -0
- package/dist/admin/translations/en.json.js.map +1 -1
- package/dist/admin/translations/en.json.mjs +1 -0
- package/dist/admin/translations/en.json.mjs.map +1 -1
- package/dist/admin/translations/ko.json.js +2 -1
- package/dist/admin/translations/ko.json.js.map +1 -1
- package/dist/admin/translations/ko.json.mjs +2 -1
- package/dist/admin/translations/ko.json.mjs.map +1 -1
- package/dist/server/_virtual/_node_crypto.js +10 -0
- package/dist/server/_virtual/_node_crypto.js.map +1 -0
- package/dist/server/_virtual/_node_crypto.mjs +7 -0
- package/dist/server/_virtual/_node_crypto.mjs.map +1 -0
- package/dist/server/_virtual/_node_url.js +10 -0
- package/dist/server/_virtual/_node_url.js.map +1 -0
- package/dist/server/_virtual/_node_url.mjs +7 -0
- package/dist/server/_virtual/_node_url.mjs.map +1 -0
- package/dist/server/controllers/auth.js +19 -19
- package/dist/server/controllers/auth.js.map +1 -1
- package/dist/server/controllers/auth.mjs +19 -18
- package/dist/server/controllers/auth.mjs.map +1 -1
- package/dist/server/services/providers-registry.js +121 -242
- package/dist/server/services/providers-registry.js.map +1 -1
- package/dist/server/services/providers-registry.mjs +121 -239
- package/dist/server/services/providers-registry.mjs.map +1 -1
- package/dist/server/utils/oauth-connect/index.js +236 -0
- package/dist/server/utils/oauth-connect/index.js.map +1 -0
- package/dist/server/utils/oauth-connect/index.mjs +230 -0
- package/dist/server/utils/oauth-connect/index.mjs.map +1 -0
- package/dist/server/utils/oauth-connect/oauth1.js +138 -0
- package/dist/server/utils/oauth-connect/oauth1.js.map +1 -0
- package/dist/server/utils/oauth-connect/oauth1.mjs +136 -0
- package/dist/server/utils/oauth-connect/oauth1.mjs.map +1 -0
- package/dist/server/utils/oauth-connect/oauth2.js +117 -0
- package/dist/server/utils/oauth-connect/oauth2.js.map +1 -0
- package/dist/server/utils/oauth-connect/oauth2.mjs +115 -0
- package/dist/server/utils/oauth-connect/oauth2.mjs.map +1 -0
- package/dist/server/utils/oauth-connect/providers.js +109 -0
- package/dist/server/utils/oauth-connect/providers.js.map +1 -0
- package/dist/server/utils/oauth-connect/providers.mjs +107 -0
- package/dist/server/utils/oauth-connect/providers.mjs.map +1 -0
- package/dist/server/utils/provider-http.js +52 -0
- package/dist/server/utils/provider-http.js.map +1 -0
- package/dist/server/utils/provider-http.mjs +50 -0
- package/dist/server/utils/provider-http.mjs.map +1 -0
- package/dist/server/utils/verify-jwt-with-jwks.js +60 -0
- package/dist/server/utils/verify-jwt-with-jwks.js.map +1 -0
- package/dist/server/utils/verify-jwt-with-jwks.mjs +54 -0
- package/dist/server/utils/verify-jwt-with-jwks.mjs.map +1 -0
- package/jsconfig.json +7 -0
- package/package.json +12 -12
- package/server/controllers/auth.js +13 -20
- package/server/services/providers-registry.js +137 -293
- package/server/utils/oauth-connect/index.js +256 -0
- package/server/utils/oauth-connect/oauth1.js +169 -0
- package/server/utils/oauth-connect/oauth2.js +123 -0
- package/server/utils/oauth-connect/providers.js +100 -0
- package/server/utils/provider-http.js +49 -0
- package/server/utils/verify-jwt-with-jwks.js +43 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { errors } = require('@strapi/utils');
|
|
4
|
+
|
|
5
|
+
const builtinProviderEndpoints = require('./providers');
|
|
6
|
+
const oauth1 = require('./oauth1');
|
|
7
|
+
const oauth2 = require('./oauth2');
|
|
8
|
+
|
|
9
|
+
const CONNECT_PREFIX = '/connect';
|
|
10
|
+
|
|
11
|
+
const parseConnectPath = (requestPath, apiPrefix) => {
|
|
12
|
+
const prefix = `${apiPrefix}${CONNECT_PREFIX}/`;
|
|
13
|
+
if (!requestPath.startsWith(prefix)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const remainder = requestPath.slice(prefix.length);
|
|
18
|
+
const [provider, segment] = remainder.split('/').filter(Boolean);
|
|
19
|
+
|
|
20
|
+
if (!provider) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
provider,
|
|
26
|
+
isCallback: segment === 'callback',
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve OAuth endpoint config from built-ins, falling back to store-defined
|
|
32
|
+
* endpoints so custom providers registered via providers-registry still work.
|
|
33
|
+
*/
|
|
34
|
+
const buildProviderConfig = (providerName, storedConfig, redirectUri) => {
|
|
35
|
+
const defaults = builtinProviderEndpoints[providerName];
|
|
36
|
+
const endpoints = defaults || {
|
|
37
|
+
oauth: storedConfig.oauth,
|
|
38
|
+
authorize_url: storedConfig.authorize_url,
|
|
39
|
+
access_url: storedConfig.access_url,
|
|
40
|
+
request_url: storedConfig.request_url,
|
|
41
|
+
scope_delimiter: storedConfig.scope_delimiter,
|
|
42
|
+
token_endpoint_auth_method: storedConfig.token_endpoint_auth_method,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (endpoints.oauth === 1) {
|
|
46
|
+
if (!endpoints.request_url || !endpoints.authorize_url || !endpoints.access_url) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
} else if (!endpoints.authorize_url || !endpoints.access_url) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: providerName,
|
|
55
|
+
...endpoints,
|
|
56
|
+
key: storedConfig.key,
|
|
57
|
+
secret: storedConfig.secret,
|
|
58
|
+
scope: storedConfig.scope,
|
|
59
|
+
subdomain: storedConfig.subdomain,
|
|
60
|
+
callback: storedConfig.callback,
|
|
61
|
+
redirect_uri: redirectUri,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const redirectWithPayload = (ctx, callbackUrl, payload) => {
|
|
66
|
+
const url = new URL(callbackUrl);
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
|
|
69
|
+
Object.entries(payload).forEach(([key, value]) => {
|
|
70
|
+
if (key === 'raw') {
|
|
71
|
+
Object.entries(value).forEach(([rawKey, rawValue]) => {
|
|
72
|
+
params.set(`raw[${rawKey}]`, String(rawValue));
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (value !== undefined && value !== null) {
|
|
77
|
+
params.set(key, String(value));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
url.search = params.toString();
|
|
82
|
+
ctx.redirect(url.toString());
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const preserveGrantDynamic = (ctx) => {
|
|
86
|
+
const dynamic = ctx.session.grant?.dynamic;
|
|
87
|
+
return dynamic ? { dynamic } : {};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const startOAuth1Flow = async (ctx, provider, parsed, redirectUri) => {
|
|
91
|
+
const requestToken = await oauth1.requestToken({
|
|
92
|
+
requestUrl: provider.request_url,
|
|
93
|
+
redirectUri,
|
|
94
|
+
consumerKey: provider.key,
|
|
95
|
+
clientCredential: provider.secret,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
ctx.session.grant = {
|
|
99
|
+
...preserveGrantDynamic(ctx),
|
|
100
|
+
provider: parsed.provider,
|
|
101
|
+
request: requestToken,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const authorizeUrl = `${provider.authorize_url}?oauth_token=${encodeURIComponent(requestToken.oauth_token)}`;
|
|
105
|
+
return ctx.redirect(authorizeUrl);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const startOAuth2Flow = (ctx, provider, parsed, redirectUri) => {
|
|
109
|
+
const state = oauth2.generateState();
|
|
110
|
+
ctx.session.grant = {
|
|
111
|
+
...preserveGrantDynamic(ctx),
|
|
112
|
+
provider: parsed.provider,
|
|
113
|
+
state,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let authorizeUrl = oauth2.buildAuthorizeUrl(provider, {
|
|
117
|
+
key: provider.key,
|
|
118
|
+
redirectUri,
|
|
119
|
+
scope: provider.scope,
|
|
120
|
+
subdomain: provider.subdomain,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
authorizeUrl += `&state=${encodeURIComponent(state)}`;
|
|
124
|
+
return ctx.redirect(authorizeUrl);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleOAuth1Callback = async (ctx, provider, callbackUrl) => {
|
|
128
|
+
const session = ctx.session.grant || {};
|
|
129
|
+
const { oauth_token: oauthToken, oauth_verifier: oauthVerifier } = ctx.query;
|
|
130
|
+
const requestToken = session.request;
|
|
131
|
+
|
|
132
|
+
if (!requestToken?.oauth_token || oauthToken !== requestToken.oauth_token) {
|
|
133
|
+
throw new Error('OAuth1 token mismatch');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const tokenResponse = await oauth1.accessToken({
|
|
137
|
+
accessUrl: provider.access_url,
|
|
138
|
+
consumerKey: provider.key,
|
|
139
|
+
clientCredential: provider.secret,
|
|
140
|
+
oauthToken,
|
|
141
|
+
oauthVerifier,
|
|
142
|
+
oauthTokenCredential: requestToken.oauth_token_secret,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const payload = oauth2.tokensToQueryPayload(provider, tokenResponse);
|
|
146
|
+
ctx.session.grant = {};
|
|
147
|
+
return redirectWithPayload(ctx, callbackUrl, payload);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleOAuth2Callback = async (ctx, provider, callbackUrl, redirectUri) => {
|
|
151
|
+
const session = ctx.session.grant || {};
|
|
152
|
+
const { code, state: queryState, error, error_description: errorDescription } = ctx.query;
|
|
153
|
+
|
|
154
|
+
if (error) {
|
|
155
|
+
ctx.session.grant = {};
|
|
156
|
+
return redirectWithPayload(ctx, callbackUrl, {
|
|
157
|
+
error,
|
|
158
|
+
error_description: errorDescription,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!code) {
|
|
163
|
+
throw new Error('OAuth2 missing code parameter');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Reject when session state is missing (not only on mismatch) to close login-CSRF.
|
|
167
|
+
if (!session.state || queryState !== session.state) {
|
|
168
|
+
throw new Error('OAuth2 state mismatch');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tokenResponse = await oauth2.exchangeAuthorizationCode(provider, {
|
|
172
|
+
key: provider.key,
|
|
173
|
+
secret: provider.secret,
|
|
174
|
+
redirectUri,
|
|
175
|
+
code,
|
|
176
|
+
subdomain: provider.subdomain,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const payload = oauth2.tokensToQueryPayload(provider, tokenResponse);
|
|
180
|
+
ctx.session.grant = {};
|
|
181
|
+
return redirectWithPayload(ctx, callbackUrl, payload);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const createOAuthConnectMiddleware = () => {
|
|
185
|
+
return async (ctx, next) => {
|
|
186
|
+
const apiPrefix = strapi.config.get('api.rest.prefix');
|
|
187
|
+
const [requestPath] = ctx.request.url.split('?');
|
|
188
|
+
const parsed = parseConnectPath(requestPath, apiPrefix);
|
|
189
|
+
|
|
190
|
+
if (!parsed) {
|
|
191
|
+
return next();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!ctx.session) {
|
|
195
|
+
ctx.throw(400, 'OAuth connect requires session middleware');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const storedProviders = await strapi
|
|
199
|
+
.store({ type: 'plugin', name: 'users-permissions', key: 'grant' })
|
|
200
|
+
.get();
|
|
201
|
+
|
|
202
|
+
const storedConfig = storedProviders?.[parsed.provider];
|
|
203
|
+
if (!storedConfig?.enabled) {
|
|
204
|
+
throw new errors.ApplicationError('This provider is disabled');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const { getService } = require('..');
|
|
208
|
+
const redirectUri = getService('providers').buildRedirectUri(parsed.provider);
|
|
209
|
+
|
|
210
|
+
const callbackOverride =
|
|
211
|
+
ctx.state.oauthConnect?.callback ?? ctx.session.grant?.dynamic?.callback;
|
|
212
|
+
const effectiveConfig = callbackOverride
|
|
213
|
+
? { ...storedConfig, callback: callbackOverride }
|
|
214
|
+
: storedConfig;
|
|
215
|
+
|
|
216
|
+
const provider = buildProviderConfig(parsed.provider, effectiveConfig, redirectUri);
|
|
217
|
+
|
|
218
|
+
if (!provider) {
|
|
219
|
+
throw new errors.ApplicationError('Unknown OAuth provider');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
ctx.session.grant = ctx.session.grant || {};
|
|
223
|
+
|
|
224
|
+
if (!parsed.isCallback) {
|
|
225
|
+
if (provider.oauth === 1) {
|
|
226
|
+
return startOAuth1Flow(ctx, provider, parsed, redirectUri);
|
|
227
|
+
}
|
|
228
|
+
return startOAuth2Flow(ctx, provider, parsed, redirectUri);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const callbackUrl = effectiveConfig.callback;
|
|
232
|
+
if (!callbackUrl) {
|
|
233
|
+
throw new errors.ApplicationError('Provider callback URL is not configured');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
if (provider.oauth === 1) {
|
|
238
|
+
return await handleOAuth1Callback(ctx, provider, callbackUrl);
|
|
239
|
+
}
|
|
240
|
+
return await handleOAuth2Callback(ctx, provider, callbackUrl, redirectUri);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
ctx.session.grant = {};
|
|
243
|
+
return redirectWithPayload(ctx, callbackUrl, {
|
|
244
|
+
error: 'oauth_error',
|
|
245
|
+
error_description: err.message,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
createOAuthConnectMiddleware,
|
|
253
|
+
parseConnectPath,
|
|
254
|
+
buildProviderConfig,
|
|
255
|
+
redirectWithPayload,
|
|
256
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { URLSearchParams } = require('node:url');
|
|
5
|
+
|
|
6
|
+
const encode = (str) =>
|
|
7
|
+
encodeURIComponent(str).replace(
|
|
8
|
+
/[!'()*]/g,
|
|
9
|
+
(c) => `%${c.codePointAt(0).toString(16).toUpperCase()}`
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const sortKeys = (keys) =>
|
|
13
|
+
keys.sort((a, b) => {
|
|
14
|
+
if (a < b) return -1;
|
|
15
|
+
if (a > b) return 1;
|
|
16
|
+
return 0;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* OAuth 1.0 (RFC 5849) request signature — HMAC-SHA1 is required by the protocol.
|
|
21
|
+
* This is not password storage or user-credential hashing (those use bcrypt).
|
|
22
|
+
*/
|
|
23
|
+
const signRfc5849BaseString = (signatureBaseString, signingMaterial) => {
|
|
24
|
+
// codeql[js/insufficient-password-hash] OAuth 1.0 signing material, not user password storage
|
|
25
|
+
return crypto.createHmac('sha1', signingMaterial).update(signatureBaseString).digest('base64');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const buildOAuth1Header = ({
|
|
29
|
+
method,
|
|
30
|
+
url,
|
|
31
|
+
params,
|
|
32
|
+
consumerKey,
|
|
33
|
+
clientCredential,
|
|
34
|
+
token,
|
|
35
|
+
tokenCredential,
|
|
36
|
+
}) => {
|
|
37
|
+
const requestParameters = {
|
|
38
|
+
oauth_consumer_key: consumerKey,
|
|
39
|
+
oauth_nonce: crypto.randomBytes(16).toString('hex'),
|
|
40
|
+
oauth_signature_method: 'HMAC-SHA1',
|
|
41
|
+
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
|
|
42
|
+
oauth_version: '1.0',
|
|
43
|
+
...(token ? { oauth_token: token } : {}),
|
|
44
|
+
...params,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const paramString = sortKeys(Object.keys(requestParameters))
|
|
48
|
+
.map((key) => `${encode(key)}=${encode(requestParameters[key])}`)
|
|
49
|
+
.join('&');
|
|
50
|
+
|
|
51
|
+
const signatureBaseString = [method.toUpperCase(), encode(url), encode(paramString)].join('&');
|
|
52
|
+
const signingMaterial = `${encode(clientCredential)}&${encode(tokenCredential || '')}`;
|
|
53
|
+
const signature = signRfc5849BaseString(signatureBaseString, signingMaterial);
|
|
54
|
+
|
|
55
|
+
const headerParameters = { ...requestParameters, oauth_signature: signature };
|
|
56
|
+
const header = `OAuth ${sortKeys(Object.keys(headerParameters))
|
|
57
|
+
.map((key) => `${encode(key)}="${encode(headerParameters[key])}"`)
|
|
58
|
+
.join(', ')}`;
|
|
59
|
+
|
|
60
|
+
return header;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const oauth1Request = async ({
|
|
64
|
+
method,
|
|
65
|
+
url,
|
|
66
|
+
consumerKey,
|
|
67
|
+
clientCredential,
|
|
68
|
+
token,
|
|
69
|
+
tokenCredential,
|
|
70
|
+
params = {},
|
|
71
|
+
}) => {
|
|
72
|
+
const authorization = buildOAuth1Header({
|
|
73
|
+
method,
|
|
74
|
+
url,
|
|
75
|
+
params,
|
|
76
|
+
consumerKey,
|
|
77
|
+
clientCredential,
|
|
78
|
+
token,
|
|
79
|
+
tokenCredential,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const response = await fetch(url, {
|
|
83
|
+
method,
|
|
84
|
+
headers: { Authorization: authorization },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const text = await response.text();
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
throw new Error(text || `OAuth1 request failed (${response.status})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const requestToken = async ({ requestUrl, redirectUri, consumerKey, clientCredential }) =>
|
|
96
|
+
oauth1Request({
|
|
97
|
+
method: 'POST',
|
|
98
|
+
url: requestUrl,
|
|
99
|
+
consumerKey,
|
|
100
|
+
clientCredential,
|
|
101
|
+
params: { oauth_callback: redirectUri },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const accessToken = async ({
|
|
105
|
+
accessUrl,
|
|
106
|
+
consumerKey,
|
|
107
|
+
clientCredential,
|
|
108
|
+
oauthToken,
|
|
109
|
+
oauthVerifier,
|
|
110
|
+
oauthTokenCredential,
|
|
111
|
+
}) =>
|
|
112
|
+
oauth1Request({
|
|
113
|
+
method: 'POST',
|
|
114
|
+
url: accessUrl,
|
|
115
|
+
consumerKey,
|
|
116
|
+
clientCredential,
|
|
117
|
+
token: oauthToken,
|
|
118
|
+
tokenCredential: oauthTokenCredential,
|
|
119
|
+
params: { oauth_verifier: oauthVerifier },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const twitterGet = async ({
|
|
123
|
+
url,
|
|
124
|
+
accessToken,
|
|
125
|
+
accessCredential,
|
|
126
|
+
consumerKey,
|
|
127
|
+
clientCredential,
|
|
128
|
+
qs = {},
|
|
129
|
+
}) => {
|
|
130
|
+
const target = new URL(url);
|
|
131
|
+
const signedParams = {};
|
|
132
|
+
|
|
133
|
+
Object.entries(qs).forEach(([key, value]) => {
|
|
134
|
+
if (value !== undefined && value !== null) {
|
|
135
|
+
const stringValue = String(value);
|
|
136
|
+
target.searchParams.set(key, stringValue);
|
|
137
|
+
// RFC 5849 §3.4.1: base-string URI excludes the query; params are signed separately.
|
|
138
|
+
signedParams[key] = stringValue;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const authorization = buildOAuth1Header({
|
|
143
|
+
method: 'GET',
|
|
144
|
+
url: target.origin + target.pathname,
|
|
145
|
+
params: signedParams,
|
|
146
|
+
consumerKey,
|
|
147
|
+
clientCredential,
|
|
148
|
+
token: accessToken,
|
|
149
|
+
tokenCredential: accessCredential,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const response = await fetch(target, {
|
|
153
|
+
headers: { Authorization: authorization },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const body = await response.json();
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
throw new Error(body.errors?.[0]?.message || `Twitter API error (${response.status})`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { body };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
buildOAuth1Header,
|
|
166
|
+
requestToken,
|
|
167
|
+
accessToken,
|
|
168
|
+
twitterGet,
|
|
169
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const formatScope = (scope, delimiter = ',') => {
|
|
6
|
+
if (Array.isArray(scope)) {
|
|
7
|
+
return scope.filter(Boolean).join(delimiter) || undefined;
|
|
8
|
+
}
|
|
9
|
+
return scope || undefined;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const substituteSubdomain = (url, subdomain) =>
|
|
13
|
+
subdomain ? url.replace('[subdomain]', subdomain) : url;
|
|
14
|
+
|
|
15
|
+
const buildAuthorizeUrl = (provider, { key, redirectUri, scope, subdomain }) => {
|
|
16
|
+
const authorizeUrl = substituteSubdomain(provider.authorize_url, subdomain);
|
|
17
|
+
const params = new URLSearchParams({
|
|
18
|
+
client_id: key,
|
|
19
|
+
response_type: 'code',
|
|
20
|
+
redirect_uri: redirectUri,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const formattedScope = formatScope(scope, provider.scope_delimiter);
|
|
24
|
+
if (formattedScope) {
|
|
25
|
+
params.set('scope', formattedScope);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (provider.name === 'instagram' && /^\d+$/.test(key)) {
|
|
29
|
+
params.delete('client_id');
|
|
30
|
+
params.set('app_id', key);
|
|
31
|
+
if (formattedScope) {
|
|
32
|
+
params.set('scope', formattedScope.replaceAll(' ', ','));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return `${authorizeUrl}?${params.toString()}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const parseTokenResponse = async (response) => {
|
|
40
|
+
const contentType = response.headers.get('content-type') || '';
|
|
41
|
+
if (contentType.includes('application/json')) {
|
|
42
|
+
return response.json();
|
|
43
|
+
}
|
|
44
|
+
const text = await response.text();
|
|
45
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const exchangeAuthorizationCode = async (
|
|
49
|
+
provider,
|
|
50
|
+
{ key, secret, redirectUri, code, subdomain }
|
|
51
|
+
) => {
|
|
52
|
+
const accessUrl = substituteSubdomain(provider.access_url, subdomain);
|
|
53
|
+
const body = new URLSearchParams({
|
|
54
|
+
grant_type: 'authorization_code',
|
|
55
|
+
code,
|
|
56
|
+
redirect_uri: redirectUri,
|
|
57
|
+
client_id: key,
|
|
58
|
+
client_secret: secret,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
62
|
+
|
|
63
|
+
if (provider.token_endpoint_auth_method === 'client_secret_basic') {
|
|
64
|
+
const credentials = Buffer.from(`${key}:${secret}`).toString('base64');
|
|
65
|
+
headers.Authorization = `Basic ${credentials}`;
|
|
66
|
+
body.delete('client_id');
|
|
67
|
+
body.delete('client_secret');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (provider.name === 'instagram' && /^\d+$/.test(key)) {
|
|
71
|
+
body.delete('client_id');
|
|
72
|
+
body.delete('client_secret');
|
|
73
|
+
body.set('app_id', key);
|
|
74
|
+
body.set('app_secret', secret);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const response = await fetch(accessUrl, { method: 'POST', headers, body });
|
|
78
|
+
const output = await parseTokenResponse(response);
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
output.error_description || output.error || `Token exchange failed (${response.status})`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return output;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const tokensToQueryPayload = (provider, tokenResponse) => {
|
|
90
|
+
const data = { raw: tokenResponse };
|
|
91
|
+
|
|
92
|
+
if (provider.oauth === 1) {
|
|
93
|
+
if (tokenResponse.oauth_token) {
|
|
94
|
+
data.access_token = tokenResponse.oauth_token;
|
|
95
|
+
}
|
|
96
|
+
if (tokenResponse.oauth_token_secret) {
|
|
97
|
+
data.access_secret = tokenResponse.oauth_token_secret;
|
|
98
|
+
}
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (tokenResponse.id_token) {
|
|
103
|
+
data.id_token = tokenResponse.id_token;
|
|
104
|
+
}
|
|
105
|
+
if (tokenResponse.access_token) {
|
|
106
|
+
data.access_token = tokenResponse.access_token;
|
|
107
|
+
}
|
|
108
|
+
if (tokenResponse.refresh_token) {
|
|
109
|
+
data.refresh_token = tokenResponse.refresh_token;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return data;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const generateState = () => crypto.randomBytes(20).toString('hex');
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
buildAuthorizeUrl,
|
|
119
|
+
exchangeAuthorizationCode,
|
|
120
|
+
tokensToQueryPayload,
|
|
121
|
+
generateState,
|
|
122
|
+
substituteSubdomain,
|
|
123
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OAuth endpoint definitions for built-in users-permissions providers.
|
|
5
|
+
* Derived from grant's oauth.json (MIT) — inlined to drop the grant dependency.
|
|
6
|
+
*/
|
|
7
|
+
module.exports = {
|
|
8
|
+
discord: {
|
|
9
|
+
oauth: 2,
|
|
10
|
+
authorize_url: 'https://discord.com/api/oauth2/authorize',
|
|
11
|
+
access_url: 'https://discord.com/api/oauth2/token',
|
|
12
|
+
scope_delimiter: ' ',
|
|
13
|
+
},
|
|
14
|
+
facebook: {
|
|
15
|
+
oauth: 2,
|
|
16
|
+
authorize_url: 'https://www.facebook.com/dialog/oauth',
|
|
17
|
+
access_url: 'https://graph.facebook.com/oauth/access_token',
|
|
18
|
+
},
|
|
19
|
+
google: {
|
|
20
|
+
oauth: 2,
|
|
21
|
+
authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
22
|
+
access_url: 'https://oauth2.googleapis.com/token',
|
|
23
|
+
scope_delimiter: ' ',
|
|
24
|
+
},
|
|
25
|
+
github: {
|
|
26
|
+
oauth: 2,
|
|
27
|
+
authorize_url: 'https://github.com/login/oauth/authorize',
|
|
28
|
+
access_url: 'https://github.com/login/oauth/access_token',
|
|
29
|
+
},
|
|
30
|
+
microsoft: {
|
|
31
|
+
oauth: 2,
|
|
32
|
+
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
33
|
+
access_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
34
|
+
scope_delimiter: ' ',
|
|
35
|
+
},
|
|
36
|
+
twitter: {
|
|
37
|
+
oauth: 1,
|
|
38
|
+
request_url: 'https://api.twitter.com/oauth/request_token',
|
|
39
|
+
authorize_url: 'https://api.twitter.com/oauth/authenticate',
|
|
40
|
+
access_url: 'https://api.twitter.com/oauth/access_token',
|
|
41
|
+
},
|
|
42
|
+
instagram: {
|
|
43
|
+
oauth: 2,
|
|
44
|
+
authorize_url: 'https://api.instagram.com/oauth/authorize',
|
|
45
|
+
access_url: 'https://api.instagram.com/oauth/access_token',
|
|
46
|
+
scope_delimiter: ' ',
|
|
47
|
+
},
|
|
48
|
+
vk: {
|
|
49
|
+
oauth: 2,
|
|
50
|
+
authorize_url: 'https://oauth.vk.com/authorize',
|
|
51
|
+
access_url: 'https://oauth.vk.com/access_token',
|
|
52
|
+
},
|
|
53
|
+
twitch: {
|
|
54
|
+
oauth: 2,
|
|
55
|
+
authorize_url: 'https://id.twitch.tv/oauth2/authorize',
|
|
56
|
+
access_url: 'https://id.twitch.tv/oauth2/token',
|
|
57
|
+
scope_delimiter: ' ',
|
|
58
|
+
},
|
|
59
|
+
linkedin: {
|
|
60
|
+
oauth: 2,
|
|
61
|
+
authorize_url: 'https://www.linkedin.com/oauth/v2/authorization',
|
|
62
|
+
access_url: 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
63
|
+
scope_delimiter: ' ',
|
|
64
|
+
},
|
|
65
|
+
cognito: {
|
|
66
|
+
oauth: 2,
|
|
67
|
+
authorize_url: 'https://[subdomain]/oauth2/authorize',
|
|
68
|
+
access_url: 'https://[subdomain]/oauth2/token',
|
|
69
|
+
scope_delimiter: ' ',
|
|
70
|
+
},
|
|
71
|
+
reddit: {
|
|
72
|
+
oauth: 2,
|
|
73
|
+
authorize_url: 'https://ssl.reddit.com/api/v1/authorize',
|
|
74
|
+
access_url: 'https://ssl.reddit.com/api/v1/access_token',
|
|
75
|
+
token_endpoint_auth_method: 'client_secret_basic',
|
|
76
|
+
},
|
|
77
|
+
auth0: {
|
|
78
|
+
oauth: 2,
|
|
79
|
+
authorize_url: 'https://[subdomain].auth0.com/authorize',
|
|
80
|
+
access_url: 'https://[subdomain].auth0.com/oauth/token',
|
|
81
|
+
scope_delimiter: ' ',
|
|
82
|
+
},
|
|
83
|
+
cas: {
|
|
84
|
+
oauth: 2,
|
|
85
|
+
authorize_url: 'https://[subdomain]/oidc/authorize',
|
|
86
|
+
access_url: 'https://[subdomain]/oidc/token',
|
|
87
|
+
},
|
|
88
|
+
patreon: {
|
|
89
|
+
oauth: 2,
|
|
90
|
+
authorize_url: 'https://www.patreon.com/oauth2/authorize',
|
|
91
|
+
access_url: 'https://www.patreon.com/api/oauth2/token',
|
|
92
|
+
scope_delimiter: ' ',
|
|
93
|
+
},
|
|
94
|
+
keycloak: {
|
|
95
|
+
oauth: 2,
|
|
96
|
+
authorize_url: 'https://[subdomain]/protocol/openid-connect/auth',
|
|
97
|
+
access_url: 'https://[subdomain]/protocol/openid-connect/token',
|
|
98
|
+
scope_delimiter: ' ',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fetchJson = async (url, options = {}) => {
|
|
4
|
+
const response = await fetch(url, options);
|
|
5
|
+
const contentType = response.headers.get('content-type') || '';
|
|
6
|
+
|
|
7
|
+
let body;
|
|
8
|
+
if (contentType.includes('application/json')) {
|
|
9
|
+
body = await response.json();
|
|
10
|
+
} else {
|
|
11
|
+
const text = await response.text();
|
|
12
|
+
try {
|
|
13
|
+
body = JSON.parse(text);
|
|
14
|
+
} catch {
|
|
15
|
+
body = text;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const message =
|
|
21
|
+
typeof body === 'object' && body !== null
|
|
22
|
+
? body.error_description || body.error || body.message
|
|
23
|
+
: body;
|
|
24
|
+
throw new Error(message || `HTTP ${response.status}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { body };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const bearerGet = (url, accessToken, { headers = {}, qs = {} } = {}) => {
|
|
31
|
+
const target = new URL(url);
|
|
32
|
+
Object.entries(qs).forEach(([key, value]) => {
|
|
33
|
+
if (value !== undefined && value !== null) {
|
|
34
|
+
target.searchParams.set(key, String(value));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return fetchJson(target, {
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${accessToken}`,
|
|
41
|
+
...headers,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
fetchJson,
|
|
48
|
+
bearerGet,
|
|
49
|
+
};
|