@punch-in/strapi-admin 1.0.8 → 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.
@@ -139,20 +139,20 @@ const GlobalStyle = createGlobalStyle`
139
139
  }
140
140
 
141
141
  ::-webkit-scrollbar-track {
142
- background-color: #eee;
142
+ background-color: #ffffff;
143
143
  }
144
144
 
145
145
  ::-webkit-scrollbar-track:hover {
146
- background-color: #ddd;
146
+ background-color: #ffffff;
147
147
  }
148
148
 
149
149
  ::-webkit-scrollbar-thumb {
150
- background-color: #ccc;
150
+ background-color: #007eff;
151
151
  border-radius: 0.5rem;
152
152
  }
153
153
 
154
154
  ::-webkit-scrollbar-thumb:hover {
155
- background-color: #bbb;
155
+ background-color: #007eff;
156
156
  }
157
157
 
158
158
  ::-webkit-scrollbar-button {
@@ -162,7 +162,7 @@ const GlobalStyle = createGlobalStyle`
162
162
  /* firefox scrollbar */
163
163
  /* stylelint-disable */
164
164
  * {
165
- scrollbar-color: #bbb #eee;
165
+ scrollbar-color: #ffffff #007eff;
166
166
  scrollbar-width: thin;
167
167
  }
168
168
  /* stylelint-enable */
@@ -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,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 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
- ];
182
+ async ssoProviders(ctx) {
183
+ ctx.body = strapi.admin.services.sso.getConfiguredProviders();
184
+ },
212
185
 
213
- ctx.body = providers;
214
- } catch (err) {
215
- ctx.badRequest(null, 'An error occurred while retrieving providers');
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
- 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
- };
194
+ const redirectUri = getSsoCallbackUrl(ctx, providerUid);
195
+ const state = strapi.admin.services.sso.signState();
232
196
 
233
- const authUrl = providerUrls[provider];
234
-
235
- if (!authUrl) {
236
- return ctx.badRequest(null, 'Provider not supported');
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
- ctx.redirect(authUrl);
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 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
- };
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
- ctx.body = {
254
- data: options,
255
- };
256
- } catch (err) {
257
- ctx.badRequest(null, 'An error occurred while retrieving provider login options');
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
- async updateProviderLoginOptions(ctx) {
220
+ let email;
262
221
  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
- };
222
+ const redirectUri = getSsoCallbackUrl(ctx, providerUid);
223
+ email = await provider.getVerifiedEmail(code, redirectUri);
270
224
  } catch (err) {
271
- ctx.badRequest(null, 'An error occurred while updating provider login options');
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
@@ -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.8"
142
+ "version": "1.1.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,
@@ -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
+ };