@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.
@@ -94,9 +94,12 @@
94
94
  "handler": "authentication.ssoConnect"
95
95
  },
96
96
  {
97
- "method": "GET",
98
- "path": "/connect/:provider/callback",
99
- "handler": "authentication.ssoCallback"
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
- ctx.body = strapi.admin.services.sso.getConfiguredProviders();
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 provider = strapi.admin.services.sso.getProvider(providerUid);
206
+ const subdomain = process.env.SUBDOMAIN;
189
207
 
190
- if (!provider) {
191
- return ctx.notFound('Unknown or unconfigured SSO provider');
208
+ if (!subdomain) {
209
+ return ctx.badRequest('SUBDOMAIN is not configured on this Strapi instance');
192
210
  }
193
211
 
194
- const redirectUri = getSsoCallbackUrl(ctx, providerUid);
195
- const state = strapi.admin.services.sso.signState();
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(authorizeUrl.toString());
215
+ ctx.redirect(url.toString());
205
216
  },
206
217
 
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)}`);
212
-
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');
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
- if (!email) return fail('email_not_verified');
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
- // Read-only lookup - an unrecognized or inactive email is rejected,
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: providerUid,
240
+ provider: provider || 'sso',
239
241
  });
240
- return fail('no_admin_account');
242
+ return ctx.notFound('No matching active admin account');
241
243
  }
242
244
 
243
- strapi.eventHub.emit('admin.auth.success', { user, provider: providerUid });
245
+ strapi.eventHub.emit('admin.auth.success', { user, provider: provider || 'sso' });
244
246
 
245
- const token = strapi.admin.services.token.createJwtToken(user);
246
- ctx.redirect(`${adminLoginUrl}?ssoToken=${encodeURIComponent(token)}`);
247
+ ctx.body = { token: strapi.admin.services.token.createJwtToken(user) };
247
248
  },
248
249
  };
249
250
 
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`;
251
+ function getCentralApiBase() {
252
+ return (process.env.PUNCHIN_INTEGRATIONS_API_BASE || 'https://api.punch-in.co.uk').replace(/\/$/, '');
260
253
  }
261
254
 
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`;
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
@@ -139,5 +139,5 @@
139
139
  "develop:ce": "STRAPI_DISABLE_EE=true webpack-dev-server --config webpack.config.dev.js",
140
140
  "test": "echo \"no tests yet\""
141
141
  },
142
- "version": "1.1.0"
142
+ "version": "1.2.0"
143
143
  }
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
- };