@adminforth/open-signup 2.0.1 → 2.0.3

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.
@@ -2,11 +2,10 @@
2
2
  #!/bin/bash
3
3
 
4
4
  # write npm run output both to console and to build.log
5
- npm run build 2>&1 | tee build.log
5
+ npm test 2>&1 | tee build.log
6
6
  build_status=${PIPESTATUS[0]}
7
7
 
8
- # if exist status from the npm run build is not 0
9
- # then exit with the status code from the npm run build
8
+ # if exit status is not 0, exit with the status code from the build+test run
10
9
  if [ $build_status -ne 0 ]; then
11
10
  echo "Build failed. Exiting with status code $build_status"
12
11
  exit $build_status
package/build.log CHANGED
@@ -1,4 +1,8 @@
1
1
 
2
+ > @adminforth/open-signup@1.0.9 test
3
+ > npm run build && node --test tests/*.test.mjs
4
+
5
+
2
6
  > @adminforth/open-signup@1.0.9 build
3
7
  > tsc && rsync -av --exclude 'node_modules' custom dist/
4
8
 
@@ -10,3 +14,38 @@ custom/tsconfig.json
10
14
 
11
15
  sent 14,167 bytes received 77 bytes 28,488.00 bytes/sec
12
16
  total size is 13,884 speedup is 0.97
17
+ TAP version 13
18
+ # Subtest: a case-variant signup is rejected instead of creating a second account
19
+ ok 1 - a case-variant signup is rejected instead of creating a second account
20
+ ---
21
+ duration_ms: 45.208471
22
+ ...
23
+ # Subtest: case folding does not depend on which column is the auth username field
24
+ ok 2 - case folding does not depend on which column is the auth username field
25
+ ---
26
+ duration_ms: 43.686471
27
+ ...
28
+ # Subtest: a configured column normalize runs on the folded value, and the stored value is the one probed
29
+ ok 3 - a configured column normalize runs on the folded value, and the stored value is the one probed
30
+ ---
31
+ duration_ms: 48.158384
32
+ ...
33
+ # Subtest: the probed value survives the column normalize core re-applies on write
34
+ ok 4 - the probed value survives the column normalize core re-applies on write
35
+ ---
36
+ duration_ms: 41.121963
37
+ ...
38
+ # Subtest: a login whose account lookup finds nothing returns an error instead of crashing
39
+ ok 5 - a login whose account lookup finds nothing returns an error instead of crashing
40
+ ---
41
+ duration_ms: 0.192068
42
+ ...
43
+ 1..5
44
+ # tests 5
45
+ # suites 0
46
+ # pass 5
47
+ # fail 0
48
+ # cancelled 0
49
+ # skipped 0
50
+ # todo 0
51
+ # duration_ms 345.451587
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
11
11
  constructor(options: PluginOptions);
12
12
  modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource): Promise<void>;
13
13
  getAllowedOrigins(): string[];
14
+ normalizeUsername(email: string): string;
14
15
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource): void;
15
16
  instanceUniqueRepresentation(pluginOptions: any): string;
16
17
  doLogin(email: string, response: any, extra: HttpExtra): Promise<{
package/dist/index.js CHANGED
@@ -115,6 +115,12 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
115
115
  const originList = Array.isArray(rawOrigins) ? rawOrigins : [rawOrigins];
116
116
  return originList.map(o => new URL(o).origin);
117
117
  }
118
+ // Unconditional fold: this value is both the lookup filter and the value stored.
119
+ normalizeUsername(email) {
120
+ const folded = typeof email === 'string' ? email.toLowerCase() : email;
121
+ const normalize = this.emailField.normalize;
122
+ return normalize ? normalize(folded) : folded;
123
+ }
118
124
  validateConfigAfterDiscover(adminforth, resourceConfig) {
119
125
  if (this.options.confirmEmails) {
120
126
  this.options.confirmEmails.adapter.validate();
@@ -133,8 +139,11 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
133
139
  }
134
140
  doLogin(email, response, extra) {
135
141
  return __awaiter(this, void 0, void 0, function* () {
136
- const username = email;
137
- const userRecord = yield this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, email));
142
+ const username = this.normalizeUsername(email);
143
+ const userRecord = yield this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, username));
144
+ if (!userRecord) {
145
+ return { allowedLogin: false, error: 'User not found' };
146
+ }
138
147
  const adminUser = {
139
148
  dbUser: userRecord,
140
149
  pk: userRecord[this.authResource.columns.find((col) => col.primaryKey).name],
@@ -189,7 +198,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
189
198
  if (!decoded || !decoded.email) {
190
199
  return { error: yield tr('Invalid token', 'opensignup'), ok: false };
191
200
  }
192
- const { email } = decoded;
201
+ const email = this.normalizeUsername(decoded.email);
193
202
  if (!password) {
194
203
  return { error: yield tr('Password is required', 'opensignup'), ok: false };
195
204
  }
@@ -219,6 +228,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
219
228
  if (!email || typeof email !== 'string') {
220
229
  return { error: yield tr('Email is required', 'opensignup'), ok: false };
221
230
  }
231
+ const normalizedEmail = this.normalizeUsername(email);
222
232
  if (!this.options.confirmEmails && !password) {
223
233
  return { error: yield tr('Password is required', 'opensignup'), ok: false };
224
234
  }
@@ -238,7 +248,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
238
248
  // validate email
239
249
  if (this.emailField.validation) {
240
250
  for (const { regExp, message } of this.emailField.validation) {
241
- if (!new RegExp(regExp).test(email)) {
251
+ if (!new RegExp(regExp).test(normalizedEmail)) {
242
252
  return { error: yield tr(message, 'opensignup'), ok: false };
243
253
  }
244
254
  }
@@ -265,9 +275,6 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
265
275
  }
266
276
  }
267
277
  }
268
- // This is not needed when right email validator is set on email field because
269
- // it will not allow to create such email, but if user forgot to set it it might save situation
270
- const normalizedEmail = email.toLowerCase(); // normalize email
271
278
  // first check again if email already exists
272
279
  const existingUser = yield this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, normalizedEmail));
273
280
  if ((!this.options.confirmEmails && existingUser) || (this.options.confirmEmails && (existingUser === null || existingUser === void 0 ? void 0 : existingUser[this.emailConfirmedField.name]))) {
@@ -315,7 +322,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
315
322
  }
316
323
  // send confirmation email
317
324
  const brandName = this.adminforth.config.customization.brandName;
318
- const verifyToken = this.adminforth.auth.issueJWT({ email, issuer: brandName }, 'tempVerifyEmailToken', '2h');
325
+ const verifyToken = this.adminforth.auth.issueJWT({ email: normalizedEmail, issuer: brandName }, 'tempVerifyEmailToken', '2h');
319
326
  process.env.HEAVY_DEBUG && console.log('🐛Sending reset tok to', verifyToken);
320
327
  const emailText = yield tr(`
321
328
  Dear user,
@@ -359,7 +366,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
359
366
  `;
360
367
  const emailSubject = yield tr(`Signup request at {brandName}`, 'opensignup', { brandName });
361
368
  // send email with AWS SES this.options.providerOptions.AWS_SES
362
- this.options.confirmEmails.adapter.sendEmail(this.options.confirmEmails.sendFrom, email, emailText, emailHtml, emailSubject);
369
+ this.options.confirmEmails.adapter.sendEmail(this.options.confirmEmails.sendFrom, normalizedEmail, emailText, emailHtml, emailSubject);
363
370
  return { ok: true };
364
371
  })
365
372
  });
package/index.ts CHANGED
@@ -129,6 +129,13 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
129
129
  const originList = Array.isArray(rawOrigins) ? rawOrigins : [rawOrigins];
130
130
  return originList.map(o => new URL(o).origin);
131
131
  }
132
+
133
+ // Unconditional fold: this value is both the lookup filter and the value stored.
134
+ normalizeUsername(email: string): string {
135
+ const folded = typeof email === 'string' ? email.toLowerCase() : email;
136
+ const normalize = (this.emailField as AdminForthResourceColumn & { normalize?: (value: any) => any }).normalize;
137
+ return normalize ? normalize(folded) : folded;
138
+ }
132
139
 
133
140
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
134
141
  if (this.options.confirmEmails) {
@@ -151,10 +158,12 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
151
158
  }
152
159
 
153
160
  async doLogin(email: string, response: any, extra: HttpExtra): Promise<{ error?: string; allowedLogin: boolean; redirectTo?: string; }> {
154
-
155
- const username = email;
156
- const userRecord = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, email));
157
- const adminUser = {
161
+ const username = this.normalizeUsername(email);
162
+ const userRecord = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, username));
163
+ if (!userRecord) {
164
+ return { allowedLogin: false, error: 'User not found' };
165
+ }
166
+ const adminUser = {
158
167
  dbUser: userRecord,
159
168
  pk: userRecord[this.authResource.columns.find((col) => col.primaryKey).name],
160
169
  username,
@@ -218,7 +227,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
218
227
  if (!decoded || !decoded.email) {
219
228
  return { error: await tr('Invalid token', 'opensignup'), ok: false };
220
229
  }
221
- const { email } = decoded;
230
+ const email = this.normalizeUsername(decoded.email);
222
231
 
223
232
  if(!password) {
224
233
  return { error: await tr('Password is required', 'opensignup'), ok: false };
@@ -251,6 +260,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
251
260
  if (!email || typeof email !== 'string') {
252
261
  return { error: await tr('Email is required', 'opensignup'), ok: false };
253
262
  }
263
+ const normalizedEmail = this.normalizeUsername(email);
254
264
  if (!this.options.confirmEmails && !password) {
255
265
  return { error: await tr('Password is required', 'opensignup'), ok: false };
256
266
  }
@@ -271,7 +281,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
271
281
  // validate email
272
282
  if (this.emailField.validation) {
273
283
  for (const { regExp, message } of this.emailField.validation) {
274
- if (!new RegExp(regExp).test(email)) {
284
+ if (!new RegExp(regExp).test(normalizedEmail)) {
275
285
  return { error: await tr(message, 'opensignup'), ok: false };
276
286
  }
277
287
  }
@@ -299,10 +309,6 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
299
309
  }
300
310
  }
301
311
 
302
- // This is not needed when right email validator is set on email field because
303
- // it will not allow to create such email, but if user forgot to set it it might save situation
304
- const normalizedEmail = email.toLowerCase(); // normalize email
305
-
306
312
  // first check again if email already exists
307
313
  const existingUser = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, normalizedEmail));
308
314
  if ((!this.options.confirmEmails && existingUser) || (this.options.confirmEmails && existingUser?.[this.emailConfirmedField.name])) {
@@ -365,7 +371,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
365
371
 
366
372
  const brandName = this.adminforth.config.customization.brandName;
367
373
 
368
- const verifyToken = this.adminforth.auth.issueJWT({email, issuer: brandName }, 'tempVerifyEmailToken', '2h');
374
+ const verifyToken = this.adminforth.auth.issueJWT({ email: normalizedEmail, issuer: brandName }, 'tempVerifyEmailToken', '2h');
369
375
  process.env.HEAVY_DEBUG && console.log('🐛Sending reset tok to', verifyToken);
370
376
  const emailText = await tr(`
371
377
  Dear user,
@@ -413,7 +419,7 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
413
419
  const emailSubject = await tr(`Signup request at {brandName}`, 'opensignup', { brandName });
414
420
 
415
421
  // send email with AWS SES this.options.providerOptions.AWS_SES
416
- this.options.confirmEmails.adapter.sendEmail(this.options.confirmEmails.sendFrom, email, emailText, emailHtml, emailSubject);
422
+ this.options.confirmEmails.adapter.sendEmail(this.options.confirmEmails.sendFrom, normalizedEmail, emailText, emailHtml, emailSubject);
417
423
 
418
424
  return { ok: true };
419
425
  }
@@ -423,4 +429,4 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
423
429
 
424
430
  }
425
431
 
426
- }
432
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/open-signup",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "homepage": "https://adminforth.dev/docs/tutorial/Plugins/open-signup/",
11
11
  "scripts": {
12
- "build": "tsc && rsync -av --exclude 'node_modules' custom dist/"
12
+ "build": "tsc && rsync -av --exclude 'node_modules' custom dist/",
13
+ "test": "npm run build && node --test tests/*.test.mjs"
13
14
  },
14
15
  "repository": {
15
16
  "type": "git",
@@ -0,0 +1,113 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import OpenSignupPlugin from '../dist/index.js';
4
+
5
+ function makePlugin({ usernameField = 'email', normalize } = {}) {
6
+ const records = [];
7
+ const cookies = [];
8
+ const plugin = Object.create(OpenSignupPlugin.prototype);
9
+
10
+ plugin.options = {
11
+ emailField: 'email',
12
+ passwordHashField: 'password_hash',
13
+ expectedOrigin: 'https://app.example.com',
14
+ };
15
+ plugin.pluginInstanceId = 'open-signup-test';
16
+ plugin.emailField = { name: 'email', ...(normalize ? { normalize } : {}) };
17
+ plugin.passwordField = { minLength: 8, maxLength: 64 };
18
+ plugin.authResource = {
19
+ resourceId: 'users',
20
+ columns: [{ name: 'id', primaryKey: true }, { name: 'email' }],
21
+ };
22
+ plugin.adminforth = {
23
+ config: { auth: { usernameField }, customization: { brandName: 'Test' } },
24
+ resource: () => ({
25
+ get: async (filter) => records.find((record) => record.email === filter.value) ?? null,
26
+ create: async (record) => {
27
+ // core applies the column's normalize again on write, so the harness has to as well
28
+ const email = normalize ? normalize(record.email) : record.email;
29
+ const created = { id: `user-${records.length + 1}`, ...record, email };
30
+ records.push(created);
31
+ return created;
32
+ },
33
+ }),
34
+ restApi: { processLoginCallbacks: async () => {} },
35
+ auth: { setAuthCookie: (args) => cookies.push(args) },
36
+ };
37
+
38
+ let handler;
39
+ plugin.setupEndpoints({
40
+ endpoint: (options) => {
41
+ if (options.path.endsWith('/signup')) {
42
+ handler = options.handler;
43
+ }
44
+ },
45
+ });
46
+
47
+ const signup = (email) => handler({
48
+ body: { email, url: 'https://app.example.com/signup', password: 'S3cret-pass' },
49
+ response: {},
50
+ headers: {},
51
+ query: {},
52
+ cookies: [],
53
+ requestUrl: '/plugin/open-signup-test/signup',
54
+ tr: async (message) => message,
55
+ });
56
+
57
+ return { plugin, records, cookies, signup };
58
+ }
59
+
60
+ test('a case-variant signup is rejected instead of creating a second account', async () => {
61
+ const { records, signup, cookies } = makePlugin();
62
+
63
+ // the first address is deliberately mixed-case: it is what pins the stored value to the folded one
64
+ const first = await signup('Bob@Example.COM');
65
+ assert.equal(first.allowedLogin, true);
66
+ assert.equal(records.length, 1);
67
+ assert.equal(records[0].email, 'bob@example.com');
68
+ assert.equal(cookies.at(-1).username, 'bob@example.com');
69
+
70
+ const second = await signup('bob@example.com');
71
+ assert.match(second.error, /already exists/i);
72
+ assert.equal(records.length, 1);
73
+ });
74
+
75
+ test('case folding does not depend on which column is the auth username field', async () => {
76
+ const { records, signup } = makePlugin({ usernameField: 'login' });
77
+
78
+ await signup('bob@example.com');
79
+ const second = await signup('BOB@example.com');
80
+
81
+ assert.match(second.error, /already exists/i);
82
+ assert.equal(records.length, 1);
83
+ });
84
+
85
+ test('a configured column normalize runs on the folded value, and the stored value is the one probed', async () => {
86
+ const { records, signup } = makePlugin({ normalize: (value) => value.trim() });
87
+
88
+ await signup(' bob@example.com ');
89
+ const second = await signup('Bob@Example.com');
90
+
91
+ assert.match(second.error, /already exists/i);
92
+ assert.equal(records.length, 1);
93
+ assert.equal(records[0].email, 'bob@example.com');
94
+ });
95
+
96
+ test('the probed value survives the column normalize core re-applies on write', async () => {
97
+ const { records, signup } = makePlugin({ normalize: (value) => value.toUpperCase() });
98
+
99
+ await signup('bob@example.com');
100
+ const second = await signup('Bob@Example.com');
101
+
102
+ assert.match(second.error, /already exists/i);
103
+ assert.equal(records.length, 1);
104
+ });
105
+
106
+ test('a login whose account lookup finds nothing returns an error instead of crashing', async () => {
107
+ const { plugin } = makePlugin();
108
+
109
+ const result = await plugin.doLogin('nobody@example.com', {}, {});
110
+
111
+ assert.equal(result.allowedLogin, false);
112
+ assert.match(result.error, /not found/i);
113
+ });