@punch-in/strapi-admin 1.0.9 → 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.
@@ -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',
@@ -83,6 +83,24 @@
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": "POST",
98
+ "path": "/sso/verify",
99
+ "handler": "authentication.ssoVerify",
100
+ "config": {
101
+ "policies": []
102
+ }
103
+ },
86
104
  {
87
105
  "method": "GET",
88
106
  "path": "/webhooks",
@@ -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,96 +181,80 @@ module.exports = {
179
181
  };
180
182
  },
181
183
 
182
- async getProviders(ctx) {
183
- try {
184
- // Mock providers - in a real implementation, these would come from configuration
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
- ];
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.
212
191
 
213
- ctx.body = providers;
192
+ async ssoProviders(ctx) {
193
+ try {
194
+ const { data } = await axios.get(`${getCentralApiBase()}/sso/auth/providers`, {
195
+ timeout: 5000,
196
+ });
197
+ ctx.body = data;
214
198
  } catch (err) {
215
- ctx.badRequest(null, 'An error occurred while retrieving providers');
199
+ strapi.log.error(`Failed to fetch SSO providers: ${err.message}`);
200
+ ctx.body = [];
216
201
  }
217
202
  },
218
203
 
219
- async providerLogin(ctx) {
220
- try {
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
- };
232
-
233
- const authUrl = providerUrls[provider];
234
-
235
- if (!authUrl) {
236
- return ctx.badRequest(null, 'Provider not supported');
237
- }
204
+ async ssoConnect(ctx) {
205
+ const { provider: providerUid } = ctx.params;
206
+ const subdomain = process.env.SUBDOMAIN;
238
207
 
239
- ctx.redirect(authUrl);
240
- } catch (err) {
241
- ctx.badRequest(null, 'An error occurred while connecting to provider');
208
+ if (!subdomain) {
209
+ return ctx.badRequest('SUBDOMAIN is not configured on this Strapi instance');
242
210
  }
211
+
212
+ const url = new URL(`${getCentralApiBase()}/sso/auth/${providerUid}`);
213
+ url.searchParams.set('subdomain', subdomain);
214
+
215
+ ctx.redirect(url.toString());
243
216
  },
244
217
 
245
- async getProviderLoginOptions(ctx) {
246
- try {
247
- // Mock provider login options - in a real implementation, these would come from configuration
248
- const options = {
249
- autoRegister: true,
250
- defaultRole: 'authenticated',
251
- };
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
+ }
252
228
 
253
- ctx.body = {
254
- data: options,
255
- };
256
- } catch (err) {
257
- ctx.badRequest(null, 'An error occurred while retrieving provider login options');
229
+ const provided = ctx.request.header['x-sync-secret'];
230
+ if (!secretsMatch(provided, expected)) {
231
+ return ctx.forbidden('Invalid sync secret');
258
232
  }
259
- },
260
233
 
261
- async updateProviderLoginOptions(ctx) {
262
- try {
263
- const input = ctx.request.body;
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
- };
270
- } catch (err) {
271
- ctx.badRequest(null, 'An error occurred while updating provider login options');
234
+ const { email, provider } = ctx.request.body || {};
235
+ const user = await strapi.admin.services.auth.findActiveAdminByVerifiedEmail(email);
236
+
237
+ if (!user) {
238
+ strapi.eventHub.emit('admin.auth.error', {
239
+ error: new Error('No matching active admin account for SSO email'),
240
+ provider: provider || 'sso',
241
+ });
242
+ return ctx.notFound('No matching active admin account');
272
243
  }
244
+
245
+ strapi.eventHub.emit('admin.auth.success', { user, provider: provider || 'sso' });
246
+
247
+ ctx.body = { token: strapi.admin.services.token.createJwtToken(user) };
273
248
  },
274
249
  };
250
+
251
+ function getCentralApiBase() {
252
+ return (process.env.PUNCHIN_INTEGRATIONS_API_BASE || 'https://api.punch-in.co.uk').replace(/\/$/, '');
253
+ }
254
+
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);
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.0.9"
142
+ "version": "1.2.0"
143
143
  }
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,