@punch-in/strapi-admin 1.0.9 → 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/admin/src/containers/AuthPage/index.js +53 -0
- package/config/routes.json +15 -0
- package/controllers/authentication.js +80 -79
- package/package.json +1 -1
- package/services/auth.js +18 -0
- package/services/sso.js +185 -0
|
@@ -87,6 +87,59 @@ const AuthPage = ({ hasAdmin, setHasAdmin }) => {
|
|
|
87
87
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
88
|
}, [authType]);
|
|
89
89
|
|
|
90
|
+
// Lands here after a full-page round trip through an SSO provider
|
|
91
|
+
// (controllers/authentication.js's ssoCallback). The token in the URL is
|
|
92
|
+
// a normal admin JWT for an *existing* admin the provider's verified
|
|
93
|
+
// email matched - nothing was created. Fetch the profile it belongs to,
|
|
94
|
+
// persist it the same way a password login does, then drop both params
|
|
95
|
+
// from the URL so the JWT doesn't linger in browser history.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const ssoToken = query.get('ssoToken');
|
|
98
|
+
const ssoError = query.get('ssoError');
|
|
99
|
+
|
|
100
|
+
if (!ssoToken && !ssoError) return;
|
|
101
|
+
|
|
102
|
+
window.history.replaceState(null, '', window.location.pathname);
|
|
103
|
+
|
|
104
|
+
if (ssoError) {
|
|
105
|
+
const messages = {
|
|
106
|
+
access_denied: 'Sign-in was cancelled.',
|
|
107
|
+
no_admin_account: 'No admin account exists for that email address.',
|
|
108
|
+
email_not_verified: "That provider didn't return a verified email address.",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
strapi.notification.toggle({
|
|
112
|
+
type: 'warning',
|
|
113
|
+
message: messages[ssoError] || 'Single sign-on failed. Please try again.',
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const completeSsoLogin = async () => {
|
|
119
|
+
try {
|
|
120
|
+
const {
|
|
121
|
+
data: { data: user },
|
|
122
|
+
} = await axios.get(`${strapi.backendURL}/admin/users/me`, {
|
|
123
|
+
headers: { Authorization: `Bearer ${ssoToken}` },
|
|
124
|
+
cancelToken: source.token,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
auth.setToken(ssoToken, false);
|
|
128
|
+
auth.setUserInfo(user, false);
|
|
129
|
+
|
|
130
|
+
push('/');
|
|
131
|
+
} catch (err) {
|
|
132
|
+
strapi.notification.toggle({
|
|
133
|
+
type: 'warning',
|
|
134
|
+
message: 'Single sign-on failed. Please try again.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
completeSsoLogin();
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
90
143
|
const handleChange = ({ target: { name, value } }) => {
|
|
91
144
|
dispatch({
|
|
92
145
|
type: 'ON_CHANGE',
|
package/config/routes.json
CHANGED
|
@@ -83,6 +83,21 @@
|
|
|
83
83
|
"path": "/reset-password",
|
|
84
84
|
"handler": "authentication.resetPassword"
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
"method": "GET",
|
|
88
|
+
"path": "/providers",
|
|
89
|
+
"handler": "authentication.ssoProviders"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"method": "GET",
|
|
93
|
+
"path": "/connect/:provider",
|
|
94
|
+
"handler": "authentication.ssoConnect"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"method": "GET",
|
|
98
|
+
"path": "/connect/:provider/callback",
|
|
99
|
+
"handler": "authentication.ssoCallback"
|
|
100
|
+
},
|
|
86
101
|
{
|
|
87
102
|
"method": "GET",
|
|
88
103
|
"path": "/webhooks",
|
|
@@ -179,96 +179,97 @@ module.exports = {
|
|
|
179
179
|
};
|
|
180
180
|
},
|
|
181
181
|
|
|
182
|
-
async
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const providers = [
|
|
186
|
-
{
|
|
187
|
-
uid: 'google',
|
|
188
|
-
displayName: 'Google',
|
|
189
|
-
icon: 'https://developers.google.com/identity/images/g-logo.png',
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
uid: 'github',
|
|
193
|
-
displayName: 'GitHub',
|
|
194
|
-
icon: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
uid: 'microsoft',
|
|
198
|
-
displayName: 'Microsoft',
|
|
199
|
-
icon: 'https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg',
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
uid: 'facebook',
|
|
203
|
-
displayName: 'Facebook',
|
|
204
|
-
icon: 'https://upload.wikimedia.org/wikipedia/commons/5/51/Facebook_f_logo_%282019%29.svg',
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
uid: 'linkedin',
|
|
208
|
-
displayName: 'LinkedIn',
|
|
209
|
-
icon: 'https://upload.wikimedia.org/wikipedia/commons/c/ca/LinkedIn_logo_initials.png',
|
|
210
|
-
},
|
|
211
|
-
];
|
|
182
|
+
async ssoProviders(ctx) {
|
|
183
|
+
ctx.body = strapi.admin.services.sso.getConfiguredProviders();
|
|
184
|
+
},
|
|
212
185
|
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
186
|
+
async ssoConnect(ctx) {
|
|
187
|
+
const { provider: providerUid } = ctx.params;
|
|
188
|
+
const provider = strapi.admin.services.sso.getProvider(providerUid);
|
|
189
|
+
|
|
190
|
+
if (!provider) {
|
|
191
|
+
return ctx.notFound('Unknown or unconfigured SSO provider');
|
|
216
192
|
}
|
|
217
|
-
},
|
|
218
193
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const { provider } = ctx.params;
|
|
222
|
-
|
|
223
|
-
// This is where you would implement the actual OAuth flow
|
|
224
|
-
// For now, we'll just redirect to the provider's OAuth URL
|
|
225
|
-
const providerUrls = {
|
|
226
|
-
google: `https://accounts.google.com/oauth/authorize?client_id=${process.env.GOOGLE_CLIENT_ID}&redirect_uri=${process.env.GOOGLE_REDIRECT_URI}&scope=openid%20email%20profile&response_type=code`,
|
|
227
|
-
github: `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&redirect_uri=${process.env.GITHUB_REDIRECT_URI}&scope=user:email`,
|
|
228
|
-
microsoft: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${process.env.MICROSOFT_CLIENT_ID}&redirect_uri=${process.env.MICROSOFT_REDIRECT_URI}&scope=openid%20email%20profile&response_type=code`,
|
|
229
|
-
facebook: `https://www.facebook.com/v18.0/dialog/oauth?client_id=${process.env.FACEBOOK_CLIENT_ID}&redirect_uri=${process.env.FACEBOOK_REDIRECT_URI}&scope=email%20public_profile&response_type=code`,
|
|
230
|
-
linkedin: `https://www.linkedin.com/oauth/v2/authorization?client_id=${process.env.LINKEDIN_CLIENT_ID}&redirect_uri=${process.env.LINKEDIN_REDIRECT_URI}&scope=r_liteprofile%20r_emailaddress&response_type=code`,
|
|
231
|
-
};
|
|
194
|
+
const redirectUri = getSsoCallbackUrl(ctx, providerUid);
|
|
195
|
+
const state = strapi.admin.services.sso.signState();
|
|
232
196
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
197
|
+
const authorizeUrl = new URL(provider.authorizeUrl);
|
|
198
|
+
authorizeUrl.searchParams.set('client_id', process.env[`${providerUid.toUpperCase()}_CLIENT_ID`]);
|
|
199
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
200
|
+
authorizeUrl.searchParams.set('scope', provider.scope);
|
|
201
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
202
|
+
authorizeUrl.searchParams.set('state', state);
|
|
238
203
|
|
|
239
|
-
|
|
240
|
-
} catch (err) {
|
|
241
|
-
ctx.badRequest(null, 'An error occurred while connecting to provider');
|
|
242
|
-
}
|
|
204
|
+
ctx.redirect(authorizeUrl.toString());
|
|
243
205
|
},
|
|
244
206
|
|
|
245
|
-
async
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
defaultRole: 'authenticated',
|
|
251
|
-
};
|
|
207
|
+
async ssoCallback(ctx) {
|
|
208
|
+
const { provider: providerUid } = ctx.params;
|
|
209
|
+
const { code, state, error } = ctx.query;
|
|
210
|
+
const adminLoginUrl = getAdminLoginUrl(ctx);
|
|
211
|
+
const fail = reason => ctx.redirect(`${adminLoginUrl}?ssoError=${encodeURIComponent(reason)}`);
|
|
252
212
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
},
|
|
213
|
+
if (error) return fail('access_denied');
|
|
214
|
+
if (!code) return fail('missing_code');
|
|
215
|
+
if (!strapi.admin.services.sso.verifyState(state)) return fail('invalid_state');
|
|
216
|
+
|
|
217
|
+
const provider = strapi.admin.services.sso.getProvider(providerUid);
|
|
218
|
+
if (!provider) return fail('unsupported_provider');
|
|
260
219
|
|
|
261
|
-
|
|
220
|
+
let email;
|
|
262
221
|
try {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
// In a real implementation, you would validate and save these options
|
|
266
|
-
// For now, we'll just return the input as confirmation
|
|
267
|
-
ctx.body = {
|
|
268
|
-
data: input,
|
|
269
|
-
};
|
|
222
|
+
const redirectUri = getSsoCallbackUrl(ctx, providerUid);
|
|
223
|
+
email = await provider.getVerifiedEmail(code, redirectUri);
|
|
270
224
|
} catch (err) {
|
|
271
|
-
|
|
225
|
+
strapi.log.error(`SSO callback error for ${providerUid}: ${err.message}`);
|
|
226
|
+
return fail('provider_error');
|
|
272
227
|
}
|
|
228
|
+
|
|
229
|
+
if (!email) return fail('email_not_verified');
|
|
230
|
+
|
|
231
|
+
// Read-only lookup - an unrecognized or inactive email is rejected,
|
|
232
|
+
// never used to create or activate an admin account.
|
|
233
|
+
const user = await strapi.admin.services.auth.findActiveAdminByVerifiedEmail(email);
|
|
234
|
+
|
|
235
|
+
if (!user) {
|
|
236
|
+
strapi.eventHub.emit('admin.auth.error', {
|
|
237
|
+
error: new Error('No matching active admin account for SSO email'),
|
|
238
|
+
provider: providerUid,
|
|
239
|
+
});
|
|
240
|
+
return fail('no_admin_account');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
strapi.eventHub.emit('admin.auth.success', { user, provider: providerUid });
|
|
244
|
+
|
|
245
|
+
const token = strapi.admin.services.token.createJwtToken(user);
|
|
246
|
+
ctx.redirect(`${adminLoginUrl}?ssoToken=${encodeURIComponent(token)}`);
|
|
273
247
|
},
|
|
274
248
|
};
|
|
249
|
+
|
|
250
|
+
// The provider must redirect back to this exact URI - derived from the
|
|
251
|
+
// incoming request rather than strapi.config.server.url (which isn't
|
|
252
|
+
// reliably populated per-tenant), so it matches whatever host the browser
|
|
253
|
+
// is actually talking to.
|
|
254
|
+
function getBackendOrigin(ctx) {
|
|
255
|
+
return `${ctx.request.protocol}://${ctx.request.header.host}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function getSsoCallbackUrl(ctx, providerUid) {
|
|
259
|
+
return `${getBackendOrigin(ctx)}/admin/connect/${providerUid}/callback`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The admin panel frontend is served separately from this API (see
|
|
263
|
+
// config/server.js's serveAdminPanel: false) - ADMIN_PANEL_URL is an
|
|
264
|
+
// explicit override, SUBDOMAIN is what the tenant CloudFormation stack
|
|
265
|
+
// already provisions (see api/provider-connections), and same-origin is
|
|
266
|
+
// the fallback for local/single-host setups.
|
|
267
|
+
function getAdminLoginUrl(ctx) {
|
|
268
|
+
const base = process.env.ADMIN_PANEL_URL
|
|
269
|
+
? process.env.ADMIN_PANEL_URL.replace(/\/$/, '')
|
|
270
|
+
: process.env.SUBDOMAIN
|
|
271
|
+
? `https://${process.env.SUBDOMAIN}-admin.punch-in.co.uk`
|
|
272
|
+
: getBackendOrigin(ctx);
|
|
273
|
+
|
|
274
|
+
return `${base}/auth/login`;
|
|
275
|
+
}
|
package/package.json
CHANGED
package/services/auth.js
CHANGED
|
@@ -45,6 +45,23 @@ const checkCredentials = async ({ email, password }) => {
|
|
|
45
45
|
return [null, user];
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Look up an existing, active admin by a *verified* email address for SSO
|
|
50
|
+
* login. Deliberately read-only - unlike checkCredentials, this must never
|
|
51
|
+
* create, activate, or otherwise modify an admin_users row. An email with
|
|
52
|
+
* no matching account, or matching an inactive one, yields no session.
|
|
53
|
+
* @param {string} email - email already verified by the SSO provider
|
|
54
|
+
*/
|
|
55
|
+
const findActiveAdminByVerifiedEmail = async email => {
|
|
56
|
+
if (!email) return null;
|
|
57
|
+
|
|
58
|
+
const user = await strapi.query('user', 'admin').findOne({ email });
|
|
59
|
+
if (!user) return null;
|
|
60
|
+
if (!(user.isActive === true)) return null;
|
|
61
|
+
|
|
62
|
+
return user;
|
|
63
|
+
};
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Send an email to the user if it exists or do nothing
|
|
50
67
|
* @param {Object} param params
|
|
@@ -106,6 +123,7 @@ const resetPassword = async ({ resetPasswordToken, password } = {}) => {
|
|
|
106
123
|
|
|
107
124
|
module.exports = {
|
|
108
125
|
checkCredentials,
|
|
126
|
+
findActiveAdminByVerifiedEmail,
|
|
109
127
|
validatePassword,
|
|
110
128
|
hashPassword,
|
|
111
129
|
forgotPassword,
|
package/services/sso.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin-panel SSO login.
|
|
5
|
+
*
|
|
6
|
+
* This intentionally never creates or modifies admin_users - it only ever
|
|
7
|
+
* verifies that the caller controls an email address (via the provider's
|
|
8
|
+
* own OAuth token exchange + profile/userinfo endpoint) and, if that email
|
|
9
|
+
* matches an existing *active* admin, mints a normal admin session for
|
|
10
|
+
* them. An email with no matching admin is rejected outright.
|
|
11
|
+
*
|
|
12
|
+
* Each provider needs its own OAuth app registered in that provider's
|
|
13
|
+
* developer console, with the callback URL
|
|
14
|
+
* "<this Strapi instance's public origin>/admin/connect/<uid>/callback"
|
|
15
|
+
* registered as an allowed redirect URI. Credentials are read from env
|
|
16
|
+
* vars named "<UID>_CLIENT_ID" / "<UID>_CLIENT_SECRET".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const axios = require('axios');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
|
|
22
|
+
const envVar = (uid, suffix) => process.env[`${uid.toUpperCase()}_CLIENT_${suffix}`];
|
|
23
|
+
|
|
24
|
+
const PROVIDERS = {
|
|
25
|
+
google: {
|
|
26
|
+
displayName: 'Google',
|
|
27
|
+
icon: 'https://developers.google.com/identity/images/g-logo.png',
|
|
28
|
+
scope: 'openid email',
|
|
29
|
+
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
30
|
+
async getVerifiedEmail(code, redirectUri) {
|
|
31
|
+
const { data: tokenData } = await axios.post('https://oauth2.googleapis.com/token', {
|
|
32
|
+
code,
|
|
33
|
+
client_id: envVar('google', 'ID'),
|
|
34
|
+
client_secret: envVar('google', 'SECRET'),
|
|
35
|
+
redirect_uri: redirectUri,
|
|
36
|
+
grant_type: 'authorization_code',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!tokenData.access_token) return null;
|
|
40
|
+
|
|
41
|
+
const { data: profile } = await axios.get(
|
|
42
|
+
'https://openidconnect.googleapis.com/v1/userinfo',
|
|
43
|
+
{ headers: { Authorization: `Bearer ${tokenData.access_token}` } }
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return profile.email_verified === true ? profile.email : null;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
github: {
|
|
51
|
+
displayName: 'GitHub',
|
|
52
|
+
icon: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
|
|
53
|
+
scope: 'user:email',
|
|
54
|
+
authorizeUrl: 'https://github.com/login/oauth/authorize',
|
|
55
|
+
async getVerifiedEmail(code, redirectUri) {
|
|
56
|
+
const { data: tokenData } = await axios.post(
|
|
57
|
+
'https://github.com/login/oauth/access_token',
|
|
58
|
+
{
|
|
59
|
+
code,
|
|
60
|
+
client_id: envVar('github', 'ID'),
|
|
61
|
+
client_secret: envVar('github', 'SECRET'),
|
|
62
|
+
redirect_uri: redirectUri,
|
|
63
|
+
},
|
|
64
|
+
{ headers: { Accept: 'application/json' } }
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (!tokenData.access_token) return null;
|
|
68
|
+
|
|
69
|
+
const { data: emails } = await axios.get('https://api.github.com/user/emails', {
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `token ${tokenData.access_token}`,
|
|
72
|
+
'User-Agent': 'punch-in-admin-sso',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const primary = Array.isArray(emails) && emails.find(e => e.primary && e.verified);
|
|
77
|
+
return primary ? primary.email : null;
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
facebook: {
|
|
82
|
+
displayName: 'Facebook',
|
|
83
|
+
icon: 'https://upload.wikimedia.org/wikipedia/commons/5/51/Facebook_f_logo_%282019%29.svg',
|
|
84
|
+
scope: 'email',
|
|
85
|
+
authorizeUrl: 'https://www.facebook.com/v18.0/dialog/oauth',
|
|
86
|
+
async getVerifiedEmail(code, redirectUri) {
|
|
87
|
+
const { data: tokenData } = await axios.get(
|
|
88
|
+
'https://graph.facebook.com/v18.0/oauth/access_token',
|
|
89
|
+
{
|
|
90
|
+
params: {
|
|
91
|
+
code,
|
|
92
|
+
client_id: envVar('facebook', 'ID'),
|
|
93
|
+
client_secret: envVar('facebook', 'SECRET'),
|
|
94
|
+
redirect_uri: redirectUri,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!tokenData.access_token) return null;
|
|
100
|
+
|
|
101
|
+
const { data: profile } = await axios.get('https://graph.facebook.com/me', {
|
|
102
|
+
params: { fields: 'id,email', access_token: tokenData.access_token },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Facebook's platform only ever returns `email` for accounts with a
|
|
106
|
+
// confirmed address, so its mere presence here is the verification.
|
|
107
|
+
return profile.email || null;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
linkedin: {
|
|
112
|
+
displayName: 'LinkedIn',
|
|
113
|
+
icon: 'https://upload.wikimedia.org/wikipedia/commons/c/ca/LinkedIn_logo_initials.png',
|
|
114
|
+
scope: 'openid email profile',
|
|
115
|
+
authorizeUrl: 'https://www.linkedin.com/oauth/v2/authorization',
|
|
116
|
+
async getVerifiedEmail(code, redirectUri) {
|
|
117
|
+
const params = new URLSearchParams({
|
|
118
|
+
grant_type: 'authorization_code',
|
|
119
|
+
code,
|
|
120
|
+
redirect_uri: redirectUri,
|
|
121
|
+
client_id: envVar('linkedin', 'ID'),
|
|
122
|
+
client_secret: envVar('linkedin', 'SECRET'),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const { data: tokenData } = await axios.post(
|
|
126
|
+
'https://www.linkedin.com/oauth/v2/accessToken',
|
|
127
|
+
params.toString(),
|
|
128
|
+
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (!tokenData.access_token) return null;
|
|
132
|
+
|
|
133
|
+
const { data: profile } = await axios.get('https://api.linkedin.com/v2/userinfo', {
|
|
134
|
+
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return profile.email_verified === true ? profile.email : null;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const isConfigured = uid => Boolean(envVar(uid, 'ID') && envVar(uid, 'SECRET'));
|
|
143
|
+
|
|
144
|
+
const getConfiguredProviders = () =>
|
|
145
|
+
Object.entries(PROVIDERS)
|
|
146
|
+
.filter(([uid]) => isConfigured(uid))
|
|
147
|
+
.map(([uid, p]) => ({ uid, displayName: p.displayName, icon: p.icon }));
|
|
148
|
+
|
|
149
|
+
const getProvider = uid => (isConfigured(uid) ? PROVIDERS[uid] : null);
|
|
150
|
+
|
|
151
|
+
// Stateless CSRF protection for the OAuth `state` param: HMAC-signed with
|
|
152
|
+
// the same secret used for admin JWTs, so no server-side session/store is
|
|
153
|
+
// needed to verify it came from a connect() call we actually issued.
|
|
154
|
+
const STATE_MAX_AGE_MS = 10 * 60 * 1000;
|
|
155
|
+
|
|
156
|
+
const signState = () => {
|
|
157
|
+
const { secret } = strapi.admin.services.token.getTokenOptions();
|
|
158
|
+
const timestamp = Date.now().toString();
|
|
159
|
+
const hmac = crypto.createHmac('sha256', secret).update(timestamp).digest('hex');
|
|
160
|
+
return `${timestamp}.${hmac}`;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const verifyState = state => {
|
|
164
|
+
if (typeof state !== 'string') return false;
|
|
165
|
+
|
|
166
|
+
const [timestamp, hmac] = state.split('.');
|
|
167
|
+
if (!timestamp || !hmac) return false;
|
|
168
|
+
|
|
169
|
+
const age = Date.now() - Number(timestamp);
|
|
170
|
+
if (!Number.isFinite(age) || age < 0 || age > STATE_MAX_AGE_MS) return false;
|
|
171
|
+
|
|
172
|
+
const { secret } = strapi.admin.services.token.getTokenOptions();
|
|
173
|
+
const expected = crypto.createHmac('sha256', secret).update(timestamp).digest('hex');
|
|
174
|
+
|
|
175
|
+
const a = Buffer.from(hmac);
|
|
176
|
+
const b = Buffer.from(expected);
|
|
177
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
getConfiguredProviders,
|
|
182
|
+
getProvider,
|
|
183
|
+
signState,
|
|
184
|
+
verifyState,
|
|
185
|
+
};
|