@proteinjs/user-server 1.21.0 → 1.21.1
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/CHANGELOG.md +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/authentication/PasswordResetToken.d.ts +45 -0
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -0
- package/dist/src/authentication/PasswordResetToken.js +138 -0
- package/dist/src/authentication/PasswordResetToken.js.map +1 -0
- package/dist/src/routes/executePasswordReset.d.ts +4 -4
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +27 -26
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +2 -2
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +13 -16
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/test/ExecutePasswordReset.test.d.ts +2 -0
- package/dist/test/ExecutePasswordReset.test.d.ts.map +1 -0
- package/dist/test/ExecutePasswordReset.test.js +394 -0
- package/dist/test/ExecutePasswordReset.test.js.map +1 -0
- package/dist/test/ValidateResetToken.test.js +30 -7
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/generated/index.ts +1 -1
- package/package.json +2 -2
- package/src/authentication/PasswordResetToken.ts +96 -0
- package/src/routes/executePasswordReset.ts +25 -24
- package/src/routes/initiatePasswordReset.ts +2 -2
- package/src/routes/validateResetPasswordToken.ts +12 -15
- package/test/ExecutePasswordReset.test.ts +224 -0
- package/test/ValidateResetToken.test.ts +24 -6
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
|
-
import {
|
|
3
|
-
import { routes, tables } from '@proteinjs/user';
|
|
2
|
+
import { routes } from '@proteinjs/user';
|
|
4
3
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
-
import moment from 'moment';
|
|
6
4
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
5
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Route handler for executing a password reset.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Resolves the presented token through `PasswordResetToken` — which refuses anything but a
|
|
11
|
+
* well-formed token before any lookup — checks its expiry, and redeems it: the new password is
|
|
12
|
+
* written and the token cleared in one conditional update, so a token resets a password once.
|
|
13
|
+
* The token itself never reaches the log.
|
|
13
14
|
*
|
|
14
15
|
* @bodyParam {string} token - The password reset token.
|
|
15
16
|
* @bodyParam {string} newPassword - The new password for the user.
|
|
16
|
-
*
|
|
17
|
-
* @throws {Error} If there's an issue with the database operations or if the token is invalid or expired.
|
|
18
17
|
*/
|
|
19
18
|
export const executePasswordReset: Route = {
|
|
20
19
|
path: routes.executePasswordReset.path,
|
|
@@ -22,33 +21,35 @@ export const executePasswordReset: Route = {
|
|
|
22
21
|
onRequest: async (request, response): Promise<void> => {
|
|
23
22
|
const logger = new Logger({ name: 'executePasswordReset' });
|
|
24
23
|
const { token, newPassword } = request.body;
|
|
25
|
-
|
|
24
|
+
if (typeof newPassword !== 'string' || newPassword.length === 0) {
|
|
25
|
+
response.status(400).send({ error: 'New password cannot be blank' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
logger.info({
|
|
29
|
+
const resetToken = new PasswordResetToken();
|
|
30
|
+
const resolution = await resetToken.resolve(token);
|
|
31
|
+
if (resolution.status === 'malformed' || resolution.status === 'unknown') {
|
|
32
|
+
logger.info({
|
|
33
|
+
message: `Invalid reset token used`,
|
|
34
|
+
obj: { reason: resolution.status, token: resetToken.fingerprint(token) },
|
|
35
|
+
});
|
|
31
36
|
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
32
37
|
return;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const tokenExpiration = moment(user.passwordResetTokenExpiration);
|
|
38
|
-
if (currentTime.isAfter(tokenExpiration)) {
|
|
39
|
-
logger.info({ message: `Expired reset token used`, obj: { email: user.email } });
|
|
40
|
+
if (resolution.status === 'expired') {
|
|
41
|
+
logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
|
|
40
42
|
response.status(400).send({ error: 'Reset token has expired' });
|
|
41
43
|
return;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
const { user } = resolution;
|
|
45
47
|
const hashedPassword = await new PasswordHasher().hash(newPassword);
|
|
46
|
-
await
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
48
|
+
if (!(await resetToken.redeem(user, resolution.token, hashedPassword))) {
|
|
49
|
+
logger.info({ message: `Reset token already redeemed`, obj: { email: user.email } });
|
|
50
|
+
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
52
53
|
|
|
53
54
|
logger.info({ message: `Password successfully reset`, obj: { email: user.email } });
|
|
54
55
|
response.send({ message: 'Password has been successfully reset' });
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
EmailSender,
|
|
8
8
|
getDefaultPasswordResetEmailConfigFactory as getDefaultConfigFactory,
|
|
9
9
|
} from '@proteinjs/email-server';
|
|
10
|
-
import {
|
|
10
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Route for initiating a password reset process.
|
|
@@ -62,7 +62,7 @@ export const initiatePasswordReset: Route = {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Generate reset token
|
|
65
|
-
const passwordResetToken =
|
|
65
|
+
const passwordResetToken = new PasswordResetToken().mint();
|
|
66
66
|
const passwordResetTokenExpiration = moment().add(1, 'hour');
|
|
67
67
|
|
|
68
68
|
try {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
|
-
import {
|
|
3
|
-
import { routes, tables } from '@proteinjs/user';
|
|
2
|
+
import { routes } from '@proteinjs/user';
|
|
4
3
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
-
import
|
|
4
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
6
5
|
|
|
7
6
|
export const validateResetPasswordToken: Route = {
|
|
8
7
|
path: routes.validateResetToken.path,
|
|
@@ -10,26 +9,24 @@ export const validateResetPasswordToken: Route = {
|
|
|
10
9
|
onRequest: async (request, response): Promise<void> => {
|
|
11
10
|
const logger = new Logger({ name: 'validateResetToken' });
|
|
12
11
|
const { token } = request.query;
|
|
13
|
-
const db = getDbAsSystem();
|
|
14
|
-
|
|
15
12
|
if (!token) {
|
|
16
13
|
response.status(400).send({ isValid: false, message: 'No token provided' });
|
|
17
14
|
return;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
logger.info({
|
|
17
|
+
const resetToken = new PasswordResetToken();
|
|
18
|
+
const resolution = await resetToken.resolve(token);
|
|
19
|
+
if (resolution.status === 'malformed' || resolution.status === 'unknown') {
|
|
20
|
+
logger.info({
|
|
21
|
+
message: `Invalid reset token used`,
|
|
22
|
+
obj: { reason: resolution.status, token: resetToken.fingerprint(token) },
|
|
23
|
+
});
|
|
24
24
|
response.status(200).send({ isValid: false, message: 'Invalid token' });
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const tokenExpiration = moment(user.passwordResetTokenExpiration);
|
|
31
|
-
if (currentTime.isAfter(tokenExpiration)) {
|
|
32
|
-
logger.info({ message: `Expired reset token used`, obj: { email: user.email } });
|
|
28
|
+
if (resolution.status === 'expired') {
|
|
29
|
+
logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
|
|
33
30
|
response.status(200).send({ isValid: false, message: 'Token has expired' });
|
|
34
31
|
return;
|
|
35
32
|
}
|
|
@@ -38,6 +35,6 @@ export const validateResetPasswordToken: Route = {
|
|
|
38
35
|
// read-only `autocomplete="username"` field so password managers associate the updated
|
|
39
36
|
// password with the stored credential. The token was delivered to this very inbox, so a
|
|
40
37
|
// valid-token holder learns nothing new; invalid/expired verdicts stay email-free.
|
|
41
|
-
response.status(200).send({ isValid: true, email: user.email });
|
|
38
|
+
response.status(200).send({ isValid: true, email: resolution.user.email });
|
|
42
39
|
},
|
|
43
40
|
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import moment, { Moment } from 'moment';
|
|
2
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { tables, User } from '@proteinjs/user';
|
|
5
|
+
import { executePasswordReset } from '../src/routes/executePasswordReset';
|
|
6
|
+
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
7
|
+
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
8
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
9
|
+
|
|
10
|
+
const testEnv = new UserServerTestEnvironment();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `POST /user/execute-password-reset`. The token is the only credential this route accepts, so
|
|
14
|
+
* the lookup must never be built from anything but a well-formed token: a `null` in the body
|
|
15
|
+
* renders as `IS NULL` and matches every account with no pending reset, an empty string matches
|
|
16
|
+
* an emptied column, and other types reach the driver. Covered here, outcomes only (rows
|
|
17
|
+
* written), against the Spanner emulator:
|
|
18
|
+
* - an absent, null, empty or malformed token: 400, and no password in the table changes — an
|
|
19
|
+
* account with no pending reset is never matched, whatever the request carries;
|
|
20
|
+
* - a live token resets the password once: the row verifies the new password, the token and its
|
|
21
|
+
* expiry are cleared, and the same token presented again is refused;
|
|
22
|
+
* - an expired token, or a token whose row carries no expiry, is refused;
|
|
23
|
+
* - a blank new password is refused without consuming the token;
|
|
24
|
+
* - the presented token never reaches the log;
|
|
25
|
+
* - the token owner's own contract: mint shape, the stored-vs-presented match, liveness, and the
|
|
26
|
+
* conditional redemption.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
type RouteOutcome = { status: number; body?: any };
|
|
30
|
+
|
|
31
|
+
const invokeExecute = async (body: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
32
|
+
const outcome: RouteOutcome = { status: 200 };
|
|
33
|
+
const response = {
|
|
34
|
+
status(code: number) {
|
|
35
|
+
outcome.status = code;
|
|
36
|
+
return this;
|
|
37
|
+
},
|
|
38
|
+
send(body?: unknown) {
|
|
39
|
+
outcome.body = body;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
await executePasswordReset.onRequest({ body } as never, response as never);
|
|
43
|
+
return outcome;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const mintToken = () => new PasswordResetToken().mint();
|
|
47
|
+
|
|
48
|
+
const armResetToken = async (email: string, token: string | null, expiration: Moment | null): Promise<User> => {
|
|
49
|
+
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
50
|
+
await getDbAsSystem().update(tables.User, {
|
|
51
|
+
id: user.id,
|
|
52
|
+
passwordResetToken: token,
|
|
53
|
+
passwordResetTokenExpiration: expiration,
|
|
54
|
+
});
|
|
55
|
+
return user;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
|
|
59
|
+
|
|
60
|
+
/** Every password in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
61
|
+
const passwordsByEmail = async (): Promise<Record<string, string>> => {
|
|
62
|
+
const byEmail: Record<string, string> = {};
|
|
63
|
+
for (const user of await getDbAsSystem().query(tables.User, {})) {
|
|
64
|
+
byEmail[user.email] = user.password;
|
|
65
|
+
}
|
|
66
|
+
return byEmail;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type TokenInternals = {
|
|
70
|
+
matches(stored: string | null | undefined, presented: string): boolean;
|
|
71
|
+
isLive(expiration: Moment | null | undefined): boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe('executePasswordReset route', () => {
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
await testEnv.beforeAll();
|
|
77
|
+
// No pending reset — the account a `null` token's `IS NULL` filter would match.
|
|
78
|
+
await testEnv.createUser({ name: 'Idle User', email: 'reset-idle@test.local' });
|
|
79
|
+
// An emptied token column — the account an empty token would match.
|
|
80
|
+
await armResetToken('reset-emptied@test.local', '', moment().add(1, 'hour'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await testEnv.afterAll();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('a request without a well-formed token is refused before any lookup', () => {
|
|
88
|
+
it.each([
|
|
89
|
+
['absent', {}],
|
|
90
|
+
['null', { token: null }],
|
|
91
|
+
['empty', { token: '' }],
|
|
92
|
+
['not a token string', { token: 'tok-valid-1' }],
|
|
93
|
+
['a number', { token: 123 }],
|
|
94
|
+
['an array', { token: ['a', 'b'] }],
|
|
95
|
+
['an object', { token: { passwordResetToken: null } }],
|
|
96
|
+
])('%s: 400, and no password in the table changes', async (_label, tokenField) => {
|
|
97
|
+
const before = await passwordsByEmail();
|
|
98
|
+
|
|
99
|
+
const outcome = await invokeExecute({ ...tokenField, newPassword: 'hijacked' });
|
|
100
|
+
|
|
101
|
+
expect(outcome.status).toBe(400);
|
|
102
|
+
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
103
|
+
expect(await passwordsByEmail()).toEqual(before);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('a live token resets the password once: the row verifies it, the token clears, a re-presentation is refused', async () => {
|
|
108
|
+
const token = mintToken();
|
|
109
|
+
const user = await armResetToken('reset-live@test.local', token, moment().add(1, 'hour'));
|
|
110
|
+
|
|
111
|
+
const first = await invokeExecute({ token, newPassword: 'first new password' });
|
|
112
|
+
|
|
113
|
+
expect(first.status).toBe(200);
|
|
114
|
+
expect(first.body).toEqual({ message: 'Password has been successfully reset' });
|
|
115
|
+
const afterFirst = await userRow(user.id);
|
|
116
|
+
await expect(new PasswordHasher().verify(afterFirst.password, 'first new password')).resolves.toBe(true);
|
|
117
|
+
expect(afterFirst.passwordResetToken).toBeNull();
|
|
118
|
+
expect(afterFirst.passwordResetTokenExpiration).toBeNull();
|
|
119
|
+
|
|
120
|
+
const second = await invokeExecute({ token, newPassword: 'second new password' });
|
|
121
|
+
|
|
122
|
+
expect(second.status).toBe(400);
|
|
123
|
+
expect((await userRow(user.id)).password).toBe(afterFirst.password);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('an expired token is refused and changes nothing', async () => {
|
|
127
|
+
const token = mintToken();
|
|
128
|
+
const user = await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
|
|
129
|
+
const before = (await userRow(user.id)).password;
|
|
130
|
+
|
|
131
|
+
const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
|
|
132
|
+
|
|
133
|
+
expect(outcome.status).toBe(400);
|
|
134
|
+
expect(outcome.body).toEqual({ error: 'Reset token has expired' });
|
|
135
|
+
const row = await userRow(user.id);
|
|
136
|
+
expect(row.password).toBe(before);
|
|
137
|
+
expect(row.passwordResetToken).toBe(token);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('a token whose row carries no expiry is refused — never live by omission', async () => {
|
|
141
|
+
const token = mintToken();
|
|
142
|
+
const user = await armResetToken('reset-no-expiry@test.local', token, null);
|
|
143
|
+
const before = (await userRow(user.id)).password;
|
|
144
|
+
|
|
145
|
+
const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
|
|
146
|
+
|
|
147
|
+
expect(outcome.status).toBe(400);
|
|
148
|
+
expect((await userRow(user.id)).password).toBe(before);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('a blank new password is refused without consuming the token', async () => {
|
|
152
|
+
const token = mintToken();
|
|
153
|
+
const user = await armResetToken('reset-blank@test.local', token, moment().add(1, 'hour'));
|
|
154
|
+
const before = (await userRow(user.id)).password;
|
|
155
|
+
|
|
156
|
+
for (const newPassword of ['', undefined, 42]) {
|
|
157
|
+
const outcome = await invokeExecute({ token, newPassword });
|
|
158
|
+
expect(outcome.status).toBe(400);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const row = await userRow(user.id);
|
|
162
|
+
expect(row.password).toBe(before);
|
|
163
|
+
expect(row.passwordResetToken).toBe(token);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('the presented token never reaches the log', async () => {
|
|
167
|
+
const token = mintToken(); // well-formed, never issued
|
|
168
|
+
const info = jest.spyOn(Logger.prototype, 'info');
|
|
169
|
+
try {
|
|
170
|
+
await invokeExecute({ token, newPassword: 'hijacked' });
|
|
171
|
+
|
|
172
|
+
expect(info).toHaveBeenCalled();
|
|
173
|
+
for (const [entry] of info.mock.calls) {
|
|
174
|
+
expect(JSON.stringify(entry)).not.toContain(token);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
info.mockRestore();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('PasswordResetToken', () => {
|
|
182
|
+
const internals = new PasswordResetToken() as unknown as TokenInternals;
|
|
183
|
+
|
|
184
|
+
it('mints 64 lowercase hex characters, fresh each time', () => {
|
|
185
|
+
const token = mintToken();
|
|
186
|
+
|
|
187
|
+
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
188
|
+
expect(mintToken()).not.toBe(token);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('a stored token matches only the same string — never an absent or emptied one', () => {
|
|
192
|
+
const token = mintToken();
|
|
193
|
+
|
|
194
|
+
expect(internals.matches(token, token)).toBe(true);
|
|
195
|
+
expect(internals.matches(mintToken(), token)).toBe(false);
|
|
196
|
+
expect(internals.matches(null, token)).toBe(false);
|
|
197
|
+
expect(internals.matches(undefined, token)).toBe(false);
|
|
198
|
+
expect(internals.matches('', token)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('an expiry is live only as a valid timestamp in the future', () => {
|
|
202
|
+
expect(internals.isLive(moment().add(1, 'minute'))).toBe(true);
|
|
203
|
+
expect(internals.isLive(moment().subtract(1, 'minute'))).toBe(false);
|
|
204
|
+
expect(internals.isLive(null)).toBe(false);
|
|
205
|
+
expect(internals.isLive(undefined)).toBe(false);
|
|
206
|
+
expect(internals.isLive(moment.invalid())).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('redeem writes only while the row still carries the token', async () => {
|
|
210
|
+
const live = mintToken();
|
|
211
|
+
const user = await armResetToken('reset-redeem@test.local', live, moment().add(1, 'hour'));
|
|
212
|
+
const before = (await userRow(user.id)).password;
|
|
213
|
+
|
|
214
|
+
await expect(new PasswordResetToken().redeem(user, mintToken(), 'other-hash')).resolves.toBe(false);
|
|
215
|
+
expect((await userRow(user.id)).password).toBe(before);
|
|
216
|
+
|
|
217
|
+
await expect(new PasswordResetToken().redeem(user, live, 'new-hash')).resolves.toBe(true);
|
|
218
|
+
const row = await userRow(user.id);
|
|
219
|
+
expect(row.password).toBe('new-hash');
|
|
220
|
+
expect(row.passwordResetToken).toBeNull();
|
|
221
|
+
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -2,6 +2,7 @@ import moment from 'moment';
|
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
3
|
import { tables } from '@proteinjs/user';
|
|
4
4
|
import { validateResetPasswordToken } from '../src/routes/validateResetPasswordToken';
|
|
5
|
+
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
5
6
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
6
7
|
|
|
7
8
|
const testEnv = new UserServerTestEnvironment();
|
|
@@ -12,7 +13,9 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
12
13
|
* `autocomplete="username"` field so password managers can associate the updated password
|
|
13
14
|
* with the stored credential. The email only ever rides a VALID token's response — the
|
|
14
15
|
* token was delivered to that very inbox, so it reveals nothing the holder doesn't know —
|
|
15
|
-
* while invalid/expired verdicts stay email-free (no account-probing oracle).
|
|
16
|
+
* while invalid/expired verdicts stay email-free (no account-probing oracle). A query value
|
|
17
|
+
* that is not a well-formed token (a parsed object or array, a string of another shape) is
|
|
18
|
+
* invalid without a lookup.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
type RouteOutcome = { status?: number; body?: any };
|
|
@@ -32,6 +35,8 @@ const invokeValidate = async (query: Record<string, unknown>): Promise<RouteOutc
|
|
|
32
35
|
return outcome;
|
|
33
36
|
};
|
|
34
37
|
|
|
38
|
+
const mintToken = () => new PasswordResetToken().mint();
|
|
39
|
+
|
|
35
40
|
const armResetToken = async (email: string, token: string, expiration: moment.Moment) => {
|
|
36
41
|
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
37
42
|
await getDbAsSystem().update(tables.User, {
|
|
@@ -51,18 +56,20 @@ describe('validateResetPasswordToken route', () => {
|
|
|
51
56
|
});
|
|
52
57
|
|
|
53
58
|
it('a valid token resolves isValid WITH the account email (the reset form identifier)', async () => {
|
|
54
|
-
|
|
59
|
+
const token = mintToken();
|
|
60
|
+
await armResetToken('reset-valid@test.local', token, moment().add(1, 'hour'));
|
|
55
61
|
|
|
56
|
-
const outcome = await invokeValidate({ token
|
|
62
|
+
const outcome = await invokeValidate({ token });
|
|
57
63
|
|
|
58
64
|
expect(outcome.status).toBe(200);
|
|
59
65
|
expect(outcome.body).toEqual({ isValid: true, email: 'reset-valid@test.local' });
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
it('an expired token resolves invalid and leaks no email', async () => {
|
|
63
|
-
|
|
69
|
+
const token = mintToken();
|
|
70
|
+
await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
|
|
64
71
|
|
|
65
|
-
const outcome = await invokeValidate({ token
|
|
72
|
+
const outcome = await invokeValidate({ token });
|
|
66
73
|
|
|
67
74
|
expect(outcome.status).toBe(200);
|
|
68
75
|
expect(outcome.body.isValid).toBe(false);
|
|
@@ -70,13 +77,24 @@ describe('validateResetPasswordToken route', () => {
|
|
|
70
77
|
});
|
|
71
78
|
|
|
72
79
|
it('an unknown token resolves invalid and leaks no email', async () => {
|
|
73
|
-
const outcome = await invokeValidate({ token:
|
|
80
|
+
const outcome = await invokeValidate({ token: mintToken() });
|
|
74
81
|
|
|
75
82
|
expect(outcome.status).toBe(200);
|
|
76
83
|
expect(outcome.body.isValid).toBe(false);
|
|
77
84
|
expect(outcome.body.email).toBeUndefined();
|
|
78
85
|
});
|
|
79
86
|
|
|
87
|
+
it.each([
|
|
88
|
+
['a string of another shape', 'tok-never-issued'],
|
|
89
|
+
['a parsed array', ['a', 'b']],
|
|
90
|
+
['a parsed object', { passwordResetToken: null }],
|
|
91
|
+
])('%s resolves invalid without a lookup and leaks no email', async (_label, token) => {
|
|
92
|
+
const outcome = await invokeValidate({ token });
|
|
93
|
+
|
|
94
|
+
expect(outcome.status).toBe(200);
|
|
95
|
+
expect(outcome.body).toEqual({ isValid: false, message: 'Invalid token' });
|
|
96
|
+
});
|
|
97
|
+
|
|
80
98
|
it('a missing token is a 400', async () => {
|
|
81
99
|
const outcome = await invokeValidate({});
|
|
82
100
|
|