@punch-in/strapi-admin 1.1.0 → 1.2.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/config/routes.json +6 -3
- package/controllers/authentication.js +50 -65
- package/package.json +1 -1
- package/services/sso.js +0 -185
package/config/routes.json
CHANGED
|
@@ -94,9 +94,12 @@
|
|
|
94
94
|
"handler": "authentication.ssoConnect"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
|
-
"method": "
|
|
98
|
-
"path": "/
|
|
99
|
-
"handler": "authentication.
|
|
97
|
+
"method": "POST",
|
|
98
|
+
"path": "/sso/verify",
|
|
99
|
+
"handler": "authentication.ssoVerify",
|
|
100
|
+
"config": {
|
|
101
|
+
"policies": []
|
|
102
|
+
}
|
|
100
103
|
},
|
|
101
104
|
{
|
|
102
105
|
"method": "GET",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const passport = require('koa-passport');
|
|
4
4
|
const compose = require('koa-compose');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const axios = require('axios');
|
|
5
7
|
|
|
6
8
|
const {
|
|
7
9
|
validateRegistrationInput,
|
|
@@ -179,97 +181,80 @@ module.exports = {
|
|
|
179
181
|
};
|
|
180
182
|
},
|
|
181
183
|
|
|
184
|
+
// Admin SSO now goes through the central Lambda (api.punch-in.co.uk),
|
|
185
|
+
// registered once per provider instead of once per tenant - see
|
|
186
|
+
// stripe-charge/strapi-charge/{sso.js,controllers/ssoController.js}.
|
|
187
|
+
// This instance's only remaining jobs are: (1) tell the login page which
|
|
188
|
+
// provider buttons to show, (2) hand the browser off with its own
|
|
189
|
+
// subdomain attached, (3) later, verify a provider-confirmed email
|
|
190
|
+
// against *this* tenant's admin_users - never creating one.
|
|
191
|
+
|
|
182
192
|
async ssoProviders(ctx) {
|
|
183
|
-
|
|
193
|
+
try {
|
|
194
|
+
const { data } = await axios.get(`${getCentralApiBase()}/sso/auth/providers`, {
|
|
195
|
+
timeout: 5000,
|
|
196
|
+
});
|
|
197
|
+
ctx.body = data;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
strapi.log.error(`Failed to fetch SSO providers: ${err.message}`);
|
|
200
|
+
ctx.body = [];
|
|
201
|
+
}
|
|
184
202
|
},
|
|
185
203
|
|
|
186
204
|
async ssoConnect(ctx) {
|
|
187
205
|
const { provider: providerUid } = ctx.params;
|
|
188
|
-
const
|
|
206
|
+
const subdomain = process.env.SUBDOMAIN;
|
|
189
207
|
|
|
190
|
-
if (!
|
|
191
|
-
return ctx.
|
|
208
|
+
if (!subdomain) {
|
|
209
|
+
return ctx.badRequest('SUBDOMAIN is not configured on this Strapi instance');
|
|
192
210
|
}
|
|
193
211
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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);
|
|
212
|
+
const url = new URL(`${getCentralApiBase()}/sso/auth/${providerUid}`);
|
|
213
|
+
url.searchParams.set('subdomain', subdomain);
|
|
203
214
|
|
|
204
|
-
ctx.redirect(
|
|
215
|
+
ctx.redirect(url.toString());
|
|
205
216
|
},
|
|
206
217
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (!
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const provider = strapi.admin.services.sso.getProvider(providerUid);
|
|
218
|
-
if (!provider) return fail('unsupported_provider');
|
|
219
|
-
|
|
220
|
-
let email;
|
|
221
|
-
try {
|
|
222
|
-
const redirectUri = getSsoCallbackUrl(ctx, providerUid);
|
|
223
|
-
email = await provider.getVerifiedEmail(code, redirectUri);
|
|
224
|
-
} catch (err) {
|
|
225
|
-
strapi.log.error(`SSO callback error for ${providerUid}: ${err.message}`);
|
|
226
|
-
return fail('provider_error');
|
|
218
|
+
// POST /admin/sso/verify - called server-to-server by the central Lambda
|
|
219
|
+
// once it has a provider-verified email, authenticated the same way
|
|
220
|
+
// api/provider-connections' callback already is (X-Sync-Secret, constant-
|
|
221
|
+
// time compare). Read-only: an unmatched or inactive email is rejected,
|
|
222
|
+
// never used to create or activate an admin account.
|
|
223
|
+
async ssoVerify(ctx) {
|
|
224
|
+
const expected = process.env.SYNC_SECRET;
|
|
225
|
+
if (!expected) {
|
|
226
|
+
return ctx.internalServerError('SYNC_SECRET is not configured on this Strapi instance');
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
const provided = ctx.request.header['x-sync-secret'];
|
|
230
|
+
if (!secretsMatch(provided, expected)) {
|
|
231
|
+
return ctx.forbidden('Invalid sync secret');
|
|
232
|
+
}
|
|
230
233
|
|
|
231
|
-
|
|
232
|
-
// never used to create or activate an admin account.
|
|
234
|
+
const { email, provider } = ctx.request.body || {};
|
|
233
235
|
const user = await strapi.admin.services.auth.findActiveAdminByVerifiedEmail(email);
|
|
234
236
|
|
|
235
237
|
if (!user) {
|
|
236
238
|
strapi.eventHub.emit('admin.auth.error', {
|
|
237
239
|
error: new Error('No matching active admin account for SSO email'),
|
|
238
|
-
provider:
|
|
240
|
+
provider: provider || 'sso',
|
|
239
241
|
});
|
|
240
|
-
return
|
|
242
|
+
return ctx.notFound('No matching active admin account');
|
|
241
243
|
}
|
|
242
244
|
|
|
243
|
-
strapi.eventHub.emit('admin.auth.success', { user, provider:
|
|
245
|
+
strapi.eventHub.emit('admin.auth.success', { user, provider: provider || 'sso' });
|
|
244
246
|
|
|
245
|
-
|
|
246
|
-
ctx.redirect(`${adminLoginUrl}?ssoToken=${encodeURIComponent(token)}`);
|
|
247
|
+
ctx.body = { token: strapi.admin.services.token.createJwtToken(user) };
|
|
247
248
|
},
|
|
248
249
|
};
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
|
|
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`;
|
|
251
|
+
function getCentralApiBase() {
|
|
252
|
+
return (process.env.PUNCHIN_INTEGRATIONS_API_BASE || 'https://api.punch-in.co.uk').replace(/\/$/, '');
|
|
260
253
|
}
|
|
261
254
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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`;
|
|
255
|
+
function secretsMatch(provided, expected) {
|
|
256
|
+
if (!provided || !expected) return false;
|
|
257
|
+
const a = Buffer.from(provided);
|
|
258
|
+
const b = Buffer.from(expected);
|
|
259
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
275
260
|
}
|
package/package.json
CHANGED
package/services/sso.js
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
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
|
-
};
|