@proteinjs/user-server 1.8.0 → 1.9.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.
- package/CHANGELOG.md +18 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/authentication/DbSessionStore.d.ts +3 -2
- package/dist/src/authentication/DbSessionStore.d.ts.map +1 -1
- package/dist/src/authentication/DbSessionStore.js +3 -2
- package/dist/src/authentication/DbSessionStore.js.map +1 -1
- package/dist/src/authentication/establishSession.d.ts +30 -0
- package/dist/src/authentication/establishSession.d.ts.map +1 -0
- package/dist/src/authentication/establishSession.js +83 -0
- package/dist/src/authentication/establishSession.js.map +1 -0
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +6 -12
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/routes/login.d.ts.map +1 -1
- package/dist/src/routes/login.js +2 -3
- package/dist/src/routes/login.js.map +1 -1
- package/dist/src/routes/logout.d.ts +10 -0
- package/dist/src/routes/logout.d.ts.map +1 -1
- package/dist/src/routes/logout.js +13 -32
- package/dist/src/routes/logout.js.map +1 -1
- package/dist/src/routes/signup.d.ts +19 -0
- package/dist/src/routes/signup.d.ts.map +1 -0
- package/dist/src/routes/signup.js +102 -0
- package/dist/src/routes/signup.js.map +1 -0
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +5 -1
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/src/services/Signup.d.ts +18 -1
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +8 -2
- package/dist/src/services/Signup.js.map +1 -1
- package/dist/test/AccountDeletion.integration.test.js +24 -16
- package/dist/test/AccountDeletion.integration.test.js.map +1 -1
- package/dist/test/DevLogin.test.js +15 -20
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/LogoutRoute.test.d.ts +2 -0
- package/dist/test/LogoutRoute.test.d.ts.map +1 -0
- package/dist/test/LogoutRoute.test.js +81 -0
- package/dist/test/LogoutRoute.test.js.map +1 -0
- package/dist/test/Signup.test.js +4 -2
- package/dist/test/Signup.test.js.map +1 -1
- package/dist/test/SignupRoute.test.d.ts +2 -0
- package/dist/test/SignupRoute.test.d.ts.map +1 -0
- package/dist/test/SignupRoute.test.js +338 -0
- package/dist/test/SignupRoute.test.js.map +1 -0
- package/dist/test/ValidateResetToken.test.d.ts +2 -0
- package/dist/test/ValidateResetToken.test.d.ts.map +1 -0
- package/dist/test/ValidateResetToken.test.js +170 -0
- package/dist/test/ValidateResetToken.test.js.map +1 -0
- package/dist/test/passportSessionHarness.d.ts +31 -0
- package/dist/test/passportSessionHarness.d.ts.map +1 -0
- package/dist/test/passportSessionHarness.js +107 -0
- package/dist/test/passportSessionHarness.js.map +1 -0
- package/generated/index.ts +3 -1
- package/package.json +6 -4
- package/src/authentication/DbSessionStore.ts +3 -2
- package/src/authentication/establishSession.ts +33 -0
- package/src/routes/devLogin.ts +4 -6
- package/src/routes/login.ts +2 -3
- package/src/routes/logout.ts +13 -18
- package/src/routes/signup.ts +55 -0
- package/src/routes/validateResetPasswordToken.ts +5 -1
- package/src/services/Signup.ts +21 -2
- package/test/AccountDeletion.integration.test.ts +15 -11
- package/test/DevLogin.test.ts +11 -14
- package/test/LogoutRoute.test.ts +33 -0
- package/test/Signup.test.ts +4 -2
- package/test/SignupRoute.test.ts +219 -0
- package/test/ValidateResetToken.test.ts +86 -0
- package/test/passportSessionHarness.ts +58 -0
package/src/routes/logout.ts
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { routes } from '@proteinjs/user';
|
|
3
|
-
import { destroySession } from '../authentication/destroySession';
|
|
4
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Logout rides passport's own `request.logout` (functional for store-backed sessions since
|
|
6
|
+
* passport 0.6, which added the callback form): it clears the user from the session and SAVES —
|
|
7
|
+
* a replayed old session id is logged out even before the id rotates — then REGENERATES the
|
|
8
|
+
* session id (fixation hygiene on the privilege change, mirroring login's rotation). The
|
|
9
|
+
* regenerate destroys the old session row through the store (`DbSessionStore.destroy`), whose
|
|
10
|
+
* row delete also disconnects the session's sockets (SocketIOSessionWatcher). The pre-0.6
|
|
11
|
+
* manual compensation — calling `destroySession` and nulling `session.passport.user` by hand
|
|
12
|
+
* because `request.logout` was broken — is retired with the workaround era that spawned it.
|
|
13
|
+
*/
|
|
5
14
|
export const logout: Route = {
|
|
6
15
|
path: routes.logout.path,
|
|
7
16
|
method: routes.logout.method,
|
|
8
17
|
onRequest: async (request: any, response): Promise<void> => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// request.session?.destroy((error: any) => {
|
|
13
|
-
// if (error) {
|
|
14
|
-
// reject(error);
|
|
15
|
-
// return;
|
|
16
|
-
// }
|
|
17
|
-
|
|
18
|
-
// resolve();
|
|
19
|
-
// });
|
|
20
|
-
// });
|
|
21
|
-
// delete request._passport?.session?.user;
|
|
22
|
-
// console.info(`request.sessionID: ${request.sessionID}`);
|
|
23
|
-
// console.info(`request.session?.id: ${request.session?.id}`);
|
|
24
|
-
await destroySession(request.sessionID);
|
|
25
|
-
request.session.passport.user = null;
|
|
18
|
+
await new Promise<void>((resolve, reject) =>
|
|
19
|
+
request.logout((error: unknown) => (error ? reject(error) : resolve()))
|
|
20
|
+
);
|
|
26
21
|
response.redirect('/login');
|
|
27
22
|
},
|
|
28
23
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Route } from '@proteinjs/server-api';
|
|
2
|
+
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { UserSignup, routes } from '@proteinjs/user';
|
|
4
|
+
import { establishSession } from '../authentication/establishSession';
|
|
5
|
+
import { Signup } from '../services/Signup';
|
|
6
|
+
|
|
7
|
+
const logger = new Logger({ name: 'signup' });
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `POST /user/signup` — create the account AND establish its session in the same request
|
|
11
|
+
* (auto-login), so a new user lands in the app instead of being bounced to the login form to
|
|
12
|
+
* re-type the credentials they just submitted. Signup lives at the route layer beside login
|
|
13
|
+
* because session establishment is a request-level concern services never see; the domain flow
|
|
14
|
+
* (invite validation, account creation, notification emails) stays owned by `Signup.createUser`.
|
|
15
|
+
*
|
|
16
|
+
* Body: `UserSignup` (+ optional invite `token`; the invite carries the email on that path).
|
|
17
|
+
* Response mirrors the login route's contract: 200 with `{}` on success, `{ error }` with
|
|
18
|
+
* user-readable copy otherwise.
|
|
19
|
+
*
|
|
20
|
+
* When the email is already registered the response is byte-identical to success but NO session
|
|
21
|
+
* is minted — auto-login must never hand out a session for an account the caller didn't just
|
|
22
|
+
* create; the caller falls through to the login screen exactly as before. Existence is reported
|
|
23
|
+
* to the mailbox owner by email, never to the caller.
|
|
24
|
+
*/
|
|
25
|
+
export const signup: Route = {
|
|
26
|
+
path: routes.signup.path,
|
|
27
|
+
method: routes.signup.method,
|
|
28
|
+
onRequest: async (request: any, response): Promise<void> => {
|
|
29
|
+
const body: Partial<UserSignup> & { token?: string } = request.body ?? {};
|
|
30
|
+
if (!body.name || !body.password) {
|
|
31
|
+
response.send({ error: 'Name and password cannot be blank' });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let result;
|
|
36
|
+
try {
|
|
37
|
+
result = await new Signup().createUser(
|
|
38
|
+
{ name: body.name, email: body.email, password: body.password },
|
|
39
|
+
body.token
|
|
40
|
+
);
|
|
41
|
+
} catch (error: any) {
|
|
42
|
+
// createUser throws plain-words errors deliberately (invite expired / invite required /
|
|
43
|
+
// email missing); the message is the user-facing contract, same as the RPC layer's.
|
|
44
|
+
logger.error({ message: 'Signup failed', error });
|
|
45
|
+
response.send({ error: error instanceof Error ? error.message : 'Sign up failed.' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (result.outcome === 'created') {
|
|
50
|
+
await establishSession(request, result.email);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
response.send({});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -34,6 +34,10 @@ export const validateResetPasswordToken: Route = {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
response
|
|
37
|
+
// The account email rides the VALID response only: the reset page renders it as the
|
|
38
|
+
// read-only `autocomplete="username"` field so password managers associate the updated
|
|
39
|
+
// password with the stored credential. The token was delivered to this very inbox, so a
|
|
40
|
+
// valid-token holder learns nothing new; invalid/expired verdicts stay email-free.
|
|
41
|
+
response.status(200).send({ isValid: true, email: user.email });
|
|
38
42
|
},
|
|
39
43
|
};
|
package/src/services/Signup.ts
CHANGED
|
@@ -55,6 +55,18 @@ export const getDefaultInviteConfigFactory = (): DefaultInviteConfigFactory => {
|
|
|
55
55
|
return factory || defaultFactory;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* `createUser`'s outcome, for the signup route's eyes only (it never crosses the wire): the
|
|
60
|
+
* route establishes a session on 'created' and must NOT on 'exists' — auto-login must never
|
|
61
|
+
* hand out a session for an account the caller didn't just create. The response body stays
|
|
62
|
+
* identical either way (existence is reported to the mailbox owner by email, not the caller).
|
|
63
|
+
*/
|
|
64
|
+
export type CreateUserResult = {
|
|
65
|
+
outcome: 'created' | 'exists';
|
|
66
|
+
/** The account email, lowercased — resolved from the invite when a token was provided. */
|
|
67
|
+
email: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
58
70
|
export class Signup implements SignupService {
|
|
59
71
|
public serviceMetadata = {
|
|
60
72
|
auth: {
|
|
@@ -72,7 +84,13 @@ export class Signup implements SignupService {
|
|
|
72
84
|
},
|
|
73
85
|
};
|
|
74
86
|
|
|
75
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The signup flow's account creation + notification emails. Not RPC-reachable (deliberately
|
|
89
|
+
* absent from the SignupService interface): its caller is the `routes.signup` ROUTE, which
|
|
90
|
+
* establishes the session (auto-login) from this result — a request-level concern services
|
|
91
|
+
* never see.
|
|
92
|
+
*/
|
|
93
|
+
async createUser(user: UserSignup, token?: string): Promise<CreateUserResult> {
|
|
76
94
|
const logger = new Logger({ name: 'Signup.createUser' });
|
|
77
95
|
const db = getDbAsSystem();
|
|
78
96
|
|
|
@@ -116,7 +134,7 @@ export class Signup implements SignupService {
|
|
|
116
134
|
...config.options,
|
|
117
135
|
});
|
|
118
136
|
}
|
|
119
|
-
return;
|
|
137
|
+
return { outcome: 'exists', email };
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
const { text, html } = config.getNewUserEmailContent();
|
|
@@ -128,6 +146,7 @@ export class Signup implements SignupService {
|
|
|
128
146
|
...config.options,
|
|
129
147
|
});
|
|
130
148
|
logger.info({ message: `Created user`, obj: { email } });
|
|
149
|
+
return { outcome: 'created', email };
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
async sendInvite(email: string): Promise<SendInviteResponse> {
|
|
@@ -9,6 +9,7 @@ import { AccessGrant, ManifestGrant, User, UserRepo, guestUser, tables } from '@
|
|
|
9
9
|
import { AccountDeletion } from '../src/services/AccountDeletion';
|
|
10
10
|
import { authenticate } from '../src/authentication/authenticate';
|
|
11
11
|
import { login } from '../src/routes/login';
|
|
12
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
12
13
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
13
14
|
|
|
14
15
|
const testEnv = new UserServerTestEnvironment();
|
|
@@ -110,9 +111,13 @@ describe('AccountDeletion — deactivation, manifest, resume, cancel-by-login',
|
|
|
110
111
|
{ id: outbound2.id, principal: userB.id, resource: r2, resourceTable: RESOURCE_TABLE, accessLevel: 'write' },
|
|
111
112
|
];
|
|
112
113
|
|
|
113
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Drive the real login route over REAL passport login machinery (passportSessionHarness):
|
|
116
|
+
* establishSession regenerates the id (fixation) and commits the session row before the route
|
|
117
|
+
* responds — both live inside passport's `request.login` now; ordering is pinned by
|
|
118
|
+
* SignupRoute.test.ts.
|
|
119
|
+
*/
|
|
114
120
|
const invokeLogin = async (email: string, password: string) => {
|
|
115
|
-
let loggedInAs: string | undefined;
|
|
116
121
|
let sent: any;
|
|
117
122
|
const response: any = {
|
|
118
123
|
send: (body: any) => {
|
|
@@ -120,15 +125,13 @@ describe('AccountDeletion — deactivation, manifest, resume, cancel-by-login',
|
|
|
120
125
|
},
|
|
121
126
|
status: () => response,
|
|
122
127
|
};
|
|
123
|
-
const request
|
|
124
|
-
body: { email, password },
|
|
125
|
-
login: (loginEmail: string, callback: () => void) => {
|
|
126
|
-
loggedInAs = loginEmail;
|
|
127
|
-
callback();
|
|
128
|
-
},
|
|
129
|
-
};
|
|
128
|
+
const { request, events } = await createPassportRequest({ body: { email, password } });
|
|
130
129
|
await login.onRequest(request, response);
|
|
131
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
loggedInAs: request.session.passport?.user as string | undefined,
|
|
132
|
+
regenerated: events.includes('regenerate'),
|
|
133
|
+
sent,
|
|
134
|
+
};
|
|
132
135
|
};
|
|
133
136
|
|
|
134
137
|
it('the service door admits any signed-in account and refuses guests', () => {
|
|
@@ -358,10 +361,11 @@ describe('AccountDeletion — deactivation, manifest, resume, cancel-by-login',
|
|
|
358
361
|
testEnv.actAs(userA);
|
|
359
362
|
await new AccountDeletion().requestDeletion(PASSWORD_A);
|
|
360
363
|
|
|
361
|
-
const { loggedInAs, sent } = await invokeLogin(userA.email, PASSWORD_A);
|
|
364
|
+
const { loggedInAs, regenerated, sent } = await invokeLogin(userA.email, PASSWORD_A);
|
|
362
365
|
|
|
363
366
|
expect(sent).toEqual({});
|
|
364
367
|
expect(loggedInAs).toBe(userA.email);
|
|
368
|
+
expect(regenerated).toBe(true); // the login door inherits fresh-id-on-privilege-change
|
|
365
369
|
expect((await userRow(userA.id)).status).toBe('active');
|
|
366
370
|
expect(await grantRows()).toHaveLength(6);
|
|
367
371
|
expect(await deletionRow(userA.id)).toBeFalsy();
|
package/test/DevLogin.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { getDbAsSystem } from '@proteinjs/db';
|
|
|
2
2
|
import { tables } from '@proteinjs/user';
|
|
3
3
|
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
4
4
|
import { devLogin } from '../src/routes/devLogin';
|
|
5
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
5
6
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
6
7
|
|
|
7
8
|
const testEnv = new UserServerTestEnvironment();
|
|
@@ -20,6 +21,7 @@ const ENV_EMAIL = 'dev@test.local';
|
|
|
20
21
|
|
|
21
22
|
type RouteOutcome = {
|
|
22
23
|
loggedInAs?: string;
|
|
24
|
+
sessionRegenerated: boolean;
|
|
23
25
|
sessionSaved: boolean;
|
|
24
26
|
status?: number;
|
|
25
27
|
body?: unknown;
|
|
@@ -27,20 +29,11 @@ type RouteOutcome = {
|
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
const invokeDevLogin = async (query?: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
done();
|
|
36
|
-
},
|
|
37
|
-
session: {
|
|
38
|
-
save: (done: () => void) => {
|
|
39
|
-
outcome.sessionSaved = true;
|
|
40
|
-
done();
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
};
|
|
32
|
+
// REAL passport login machinery (see passportSessionHarness): the full contract
|
|
33
|
+
// (regenerate → login → save) is pinned by SignupRoute.test.ts — here we assert the dev door
|
|
34
|
+
// INHERITS it.
|
|
35
|
+
const { request, events } = await createPassportRequest({ query });
|
|
36
|
+
const outcome: RouteOutcome = { sessionRegenerated: false, sessionSaved: false };
|
|
44
37
|
const response = {
|
|
45
38
|
status(code: number) {
|
|
46
39
|
outcome.status = code;
|
|
@@ -54,6 +47,9 @@ const invokeDevLogin = async (query?: Record<string, unknown>): Promise<RouteOut
|
|
|
54
47
|
},
|
|
55
48
|
};
|
|
56
49
|
await devLogin.onRequest(request as never, response as never);
|
|
50
|
+
outcome.loggedInAs = request.session.passport?.user;
|
|
51
|
+
outcome.sessionRegenerated = events.includes('regenerate');
|
|
52
|
+
outcome.sessionSaved = events.includes('save');
|
|
57
53
|
return outcome;
|
|
58
54
|
};
|
|
59
55
|
|
|
@@ -94,6 +90,7 @@ describe('devLogin route', () => {
|
|
|
94
90
|
const outcome = await invokeDevLogin();
|
|
95
91
|
|
|
96
92
|
expect(outcome.loggedInAs).toBe(ENV_EMAIL);
|
|
93
|
+
expect(outcome.sessionRegenerated).toBe(true); // fresh id on privilege change (fixation)
|
|
97
94
|
expect(outcome.sessionSaved).toBe(true);
|
|
98
95
|
expect(outcome.redirect).toBe('/');
|
|
99
96
|
expect(outcome.status).toBeUndefined();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { logout } from '../src/routes/logout';
|
|
2
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `GET /user/logout` — logout through passport's own `request.logout` (functional for
|
|
6
|
+
* store-backed sessions since passport 0.6, which added the callback form): clear the user and
|
|
7
|
+
* SAVE (a replayed old session id is logged out even before the id rotates), then REGENERATE
|
|
8
|
+
* the session id (fixation hygiene on the privilege change, mirroring login's rotation; the
|
|
9
|
+
* regenerate is what tears down the old session row through the store). Driven over the REAL
|
|
10
|
+
* passport machinery (passportSessionHarness); outcomes asserted: the surviving session carries
|
|
11
|
+
* no user, the id was rotated, the request's user is cleared, and the client lands on /login.
|
|
12
|
+
*/
|
|
13
|
+
describe('logout route', () => {
|
|
14
|
+
it('clears the user, saves, regenerates the session id, and redirects to /login', async () => {
|
|
15
|
+
const { request, events } = await createPassportRequest();
|
|
16
|
+
await new Promise<void>((resolve, reject) =>
|
|
17
|
+
request.login('user@test.local', (error: unknown) => (error ? reject(error) : resolve()))
|
|
18
|
+
);
|
|
19
|
+
expect(request.session.passport.user).toBe('user@test.local'); // precondition: signed in
|
|
20
|
+
events.length = 0; // watch only the logout lifecycle
|
|
21
|
+
|
|
22
|
+
let redirect: string | undefined;
|
|
23
|
+
const response: any = { redirect: (path: string) => (redirect = path) };
|
|
24
|
+
await logout.onRequest(request as never, response as never);
|
|
25
|
+
|
|
26
|
+
// Save-then-regenerate: the old session id is deauthenticated even if its cookie is
|
|
27
|
+
// replayed, and the surviving session is a FRESH id with no user bound.
|
|
28
|
+
expect(events).toEqual(['save', 'regenerate']);
|
|
29
|
+
expect(request.session.passport?.user).toBeUndefined();
|
|
30
|
+
expect(request.user).toBeNull();
|
|
31
|
+
expect(redirect).toBe('/login');
|
|
32
|
+
});
|
|
33
|
+
});
|
package/test/Signup.test.ts
CHANGED
|
@@ -61,9 +61,11 @@ describe('Signup service auth', () => {
|
|
|
61
61
|
expect(canAccess('revokeInvite')).toBe(true);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it('keeps the signup flow
|
|
64
|
+
it('keeps the pre-submit signup flow open (initializeSignup)', () => {
|
|
65
|
+
// Account creation itself is no longer RPC-reachable — it moved to the signup ROUTE
|
|
66
|
+
// (src/routes/signup.ts, covered by SignupRoute.test.ts), which establishes the session
|
|
67
|
+
// in the same request (auto-login).
|
|
65
68
|
setUser([]);
|
|
66
|
-
expect(canAccess('createUser')).toBe(true);
|
|
67
69
|
expect(canAccess('initializeSignup')).toBe(true);
|
|
68
70
|
});
|
|
69
71
|
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `POST /user/signup` — signup with auto-login. Signup used to be an RPC service, and services
|
|
5
|
+
* never see the request, so the client was bounced to the login form to type the credentials it
|
|
6
|
+
* had JUST submitted. Session establishment is a request-level concern; signup now lives at the
|
|
7
|
+
* route layer beside its siblings (login, dev login) and mints the session through the same
|
|
8
|
+
* single owner (`establishSession`) in the same request.
|
|
9
|
+
*
|
|
10
|
+
* Covered here, outcomes only (rows written, session minted), against the Spanner emulator:
|
|
11
|
+
* - plain signup: account row created, session established in the same request;
|
|
12
|
+
* - invited signup: email resolved from the invite, invite consumed, session established;
|
|
13
|
+
* - already-registered email: response body indistinguishable from success, but NO session —
|
|
14
|
+
* auto-login must never hand out a session for an account the caller didn't just create;
|
|
15
|
+
* - invalid token / invite-only violations: honest error, no session, no account row.
|
|
16
|
+
*
|
|
17
|
+
* The email transport is stubbed at the module boundary (SignupInvite.test.ts pattern); the db
|
|
18
|
+
* stays real.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const sendEmail = jest.fn();
|
|
22
|
+
|
|
23
|
+
jest.mock('@proteinjs/email-server', () => ({
|
|
24
|
+
EmailSender: jest.fn().mockImplementation(() => ({ sendEmail })),
|
|
25
|
+
getDefaultInviteEmailConfigFactory: () => ({
|
|
26
|
+
getConfig: () => ({ getEmailContent: () => ({ text: 'invite', html: '<p>invite</p>' }) }),
|
|
27
|
+
}),
|
|
28
|
+
getDefaultSignupConfirmationEmailConfigFactory: () => ({
|
|
29
|
+
getConfig: () => ({
|
|
30
|
+
getNewUserEmailContent: () => ({ text: 'welcome', html: '<p>welcome</p>' }),
|
|
31
|
+
getExistingUserEmailContent: () => ({ text: 'exists', html: '<p>exists</p>' }),
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
37
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
38
|
+
import { tables } from '@proteinjs/user';
|
|
39
|
+
import { signup } from '../src/routes/signup';
|
|
40
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
41
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
42
|
+
|
|
43
|
+
const testEnv = new UserServerTestEnvironment();
|
|
44
|
+
|
|
45
|
+
const INVITE_CONFIG_KEY = '@proteinjs/user-server/DefaultInviteConfigFactory';
|
|
46
|
+
|
|
47
|
+
type ObjectCache = { objectCache: Record<string, unknown[]> };
|
|
48
|
+
|
|
49
|
+
/** Seed the invite-config lookup (tests don't load the generated source graph). */
|
|
50
|
+
const setInviteConfig = (factories: unknown[]) => {
|
|
51
|
+
(SourceRepository.get() as unknown as ObjectCache).objectCache[INVITE_CONFIG_KEY] = factories;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type RouteOutcome = {
|
|
55
|
+
loggedInAs?: string;
|
|
56
|
+
/**
|
|
57
|
+
* 'regenerate' / 'login' / 'save' in call order — recorded by the REAL passport 0.6 login
|
|
58
|
+
* machinery (see passportSessionHarness): regenerate FIRST (a fresh session id on privilege
|
|
59
|
+
* change, so an attacker-planted pre-auth sid never survives signup/login: session fixation),
|
|
60
|
+
* then the bind of the account onto the fresh session, then save (the row must commit before
|
|
61
|
+
* the response). Exactly one of each — a wrapper-level regenerate or save around
|
|
62
|
+
* `request.login` would double up and break the single-owner contract.
|
|
63
|
+
*/
|
|
64
|
+
sessionEvents: string[];
|
|
65
|
+
status?: number;
|
|
66
|
+
body?: { error?: string };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const invokeSignup = async (body: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
70
|
+
const { request, events } = await createPassportRequest({ body });
|
|
71
|
+
const outcome: RouteOutcome = { sessionEvents: events };
|
|
72
|
+
const response = {
|
|
73
|
+
status(code: number) {
|
|
74
|
+
outcome.status = code;
|
|
75
|
+
return this;
|
|
76
|
+
},
|
|
77
|
+
send(body?: unknown) {
|
|
78
|
+
outcome.body = body as RouteOutcome['body'];
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
await signup.onRequest(request as never, response as never);
|
|
82
|
+
// The OUTCOME of establishment: the account email bound onto the (post-regeneration) session.
|
|
83
|
+
outcome.loggedInAs = request.session.passport?.user;
|
|
84
|
+
return outcome;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const getUserRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
88
|
+
const userRowCount = async (email: string) => (await getDbAsSystem().query(tables.User, { email })).length;
|
|
89
|
+
|
|
90
|
+
describe('signup route — auto-login after signup', () => {
|
|
91
|
+
beforeAll(async () => {
|
|
92
|
+
await testEnv.beforeAll();
|
|
93
|
+
setInviteConfig([]); // default config: invite optional
|
|
94
|
+
}, 120000);
|
|
95
|
+
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
await testEnv.afterAll();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
jest.clearAllMocks();
|
|
102
|
+
setInviteConfig([]);
|
|
103
|
+
const db = getDbAsSystem();
|
|
104
|
+
await db.delete(tables.Invite, {});
|
|
105
|
+
await db.delete(tables.User, {});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('plain signup creates the account and establishes the session in the same request', async () => {
|
|
109
|
+
const outcome = await invokeSignup({
|
|
110
|
+
name: 'Plain Signup',
|
|
111
|
+
email: 'Signup.Plain@Test.local',
|
|
112
|
+
password: 'pw-plain-1',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(outcome.body).toEqual({});
|
|
116
|
+
expect(outcome.loggedInAs).toBe('signup.plain@test.local');
|
|
117
|
+
// Fresh id on privilege change (fixation), then login, then commit-before-response (the
|
|
118
|
+
// client's follow-up navigation must never race the store write and land on /login).
|
|
119
|
+
expect(outcome.sessionEvents).toEqual(['regenerate', 'login', 'save']);
|
|
120
|
+
|
|
121
|
+
const created = await getUserRow('signup.plain@test.local');
|
|
122
|
+
expect(created).toBeDefined();
|
|
123
|
+
// At-rest contract: argon2id from the first write (the per-login-migration KDF stores
|
|
124
|
+
// new credentials as argon2id directly — never a legacy sha256 row).
|
|
125
|
+
expect(created!.password).toMatch(/^\$argon2id\$/);
|
|
126
|
+
expect(created!.password).not.toContain('pw-plain-1');
|
|
127
|
+
expect(created!.emailVerified).toBe(false);
|
|
128
|
+
expect(created!.roles).toEqual([]);
|
|
129
|
+
expect(sendEmail).toHaveBeenCalledTimes(1); // welcome email still goes out
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('invited signup resolves the email from the invite, consumes it, and establishes the session', async () => {
|
|
133
|
+
const db = getDbAsSystem();
|
|
134
|
+
await db.insert(tables.Invite, {
|
|
135
|
+
email: 'invited@test.local',
|
|
136
|
+
token: 'invite-token-1',
|
|
137
|
+
tokenExpiresAt: moment().add(1, 'day'),
|
|
138
|
+
invitedBy: 'inviter-1',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const outcome = await invokeSignup({
|
|
142
|
+
name: 'Invited Signup',
|
|
143
|
+
password: 'pw-invited-1',
|
|
144
|
+
token: 'invite-token-1',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(outcome.body).toEqual({});
|
|
148
|
+
expect(outcome.loggedInAs).toBe('invited@test.local');
|
|
149
|
+
expect(outcome.sessionEvents).toEqual(['regenerate', 'login', 'save']);
|
|
150
|
+
|
|
151
|
+
const created = await getUserRow('invited@test.local');
|
|
152
|
+
expect(created).toBeDefined();
|
|
153
|
+
expect(created!.emailVerified).toBe(true); // the email came from the invite record
|
|
154
|
+
expect(created!.invitedBy).toBe('inviter-1');
|
|
155
|
+
expect(await db.get(tables.Invite, { token: 'invite-token-1' })).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('an already-registered email answers indistinguishably but mints NO session', async () => {
|
|
159
|
+
await testEnv.createUser({ name: 'Existing User', email: 'existing@test.local' });
|
|
160
|
+
|
|
161
|
+
const outcome = await invokeSignup({
|
|
162
|
+
name: 'Existing User',
|
|
163
|
+
email: 'existing@test.local',
|
|
164
|
+
password: 'pw-existing-1',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// The response body must be exactly the success shape (anti-enumeration: existence is
|
|
168
|
+
// reported to the mailbox owner by email, never to the caller)...
|
|
169
|
+
expect(outcome.body).toEqual({});
|
|
170
|
+
// ...but auto-login must never mint a session for an account the caller didn't just create.
|
|
171
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
172
|
+
expect(outcome.sessionEvents).toEqual([]);
|
|
173
|
+
|
|
174
|
+
expect(await userRowCount('existing@test.local')).toBe(1);
|
|
175
|
+
const existing = await getUserRow('existing@test.local');
|
|
176
|
+
expect(existing!.password).toBe('test'); // untouched — seeded by testEnv.createUser
|
|
177
|
+
expect(sendEmail).toHaveBeenCalledTimes(1); // "account already exists" email
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('an unknown invite token gets the honest error — no session, no account', async () => {
|
|
181
|
+
const outcome = await invokeSignup({
|
|
182
|
+
name: 'Token Guess',
|
|
183
|
+
password: 'pw-guess-1',
|
|
184
|
+
token: 'never-existed',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(outcome.body?.error).toMatch(/no longer valid/i);
|
|
188
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
189
|
+
expect(outcome.sessionEvents).toEqual([]);
|
|
190
|
+
expect((await getDbAsSystem().query(tables.User, {})).length).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('invite-only mode refuses a tokenless signup — no session, no account', async () => {
|
|
194
|
+
setInviteConfig([{ getConfig: () => ({ isInviteOnly: true }) }]);
|
|
195
|
+
|
|
196
|
+
const outcome = await invokeSignup({
|
|
197
|
+
name: 'No Invite',
|
|
198
|
+
email: 'no.invite@test.local',
|
|
199
|
+
password: 'pw-noinvite-1',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(outcome.body?.error).toMatch(/invite is required/i);
|
|
203
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
204
|
+
expect(outcome.sessionEvents).toEqual([]);
|
|
205
|
+
expect(await getUserRow('no.invite@test.local')).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('a blank name or password is refused before any write', async () => {
|
|
209
|
+
const missingName = await invokeSignup({ email: 'blank@test.local', password: 'pw-blank-1' });
|
|
210
|
+
expect(missingName.body?.error).toBeTruthy();
|
|
211
|
+
expect(missingName.sessionEvents).toEqual([]);
|
|
212
|
+
|
|
213
|
+
const missingPassword = await invokeSignup({ name: 'Blank', email: 'blank@test.local' });
|
|
214
|
+
expect(missingPassword.body?.error).toBeTruthy();
|
|
215
|
+
expect(missingPassword.sessionEvents).toEqual([]);
|
|
216
|
+
|
|
217
|
+
expect(await getUserRow('blank@test.local')).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import { tables } from '@proteinjs/user';
|
|
4
|
+
import { validateResetPasswordToken } from '../src/routes/validateResetPasswordToken';
|
|
5
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
6
|
+
|
|
7
|
+
const testEnv = new UserServerTestEnvironment();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `GET /user/validate-reset-token`. Beyond the valid/expired/unknown verdicts, the valid
|
|
11
|
+
* response carries the account EMAIL: the reset page renders it as the read-only
|
|
12
|
+
* `autocomplete="username"` field so password managers can associate the updated password
|
|
13
|
+
* with the stored credential. The email only ever rides a VALID token's response — the
|
|
14
|
+
* 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
|
+
*/
|
|
17
|
+
|
|
18
|
+
type RouteOutcome = { status?: number; body?: any };
|
|
19
|
+
|
|
20
|
+
const invokeValidate = async (query: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
21
|
+
const outcome: RouteOutcome = {};
|
|
22
|
+
const response = {
|
|
23
|
+
status(code: number) {
|
|
24
|
+
outcome.status = code;
|
|
25
|
+
return this;
|
|
26
|
+
},
|
|
27
|
+
send(body?: unknown) {
|
|
28
|
+
outcome.body = body;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
await validateResetPasswordToken.onRequest({ query } as never, response as never);
|
|
32
|
+
return outcome;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const armResetToken = async (email: string, token: string, expiration: moment.Moment) => {
|
|
36
|
+
const user = await testEnv.createUser({ name: 'Reset User', email });
|
|
37
|
+
await getDbAsSystem().update(tables.User, {
|
|
38
|
+
id: user.id,
|
|
39
|
+
passwordResetToken: token,
|
|
40
|
+
passwordResetTokenExpiration: expiration,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('validateResetPasswordToken route', () => {
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
await testEnv.beforeAll();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
await testEnv.afterAll();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('a valid token resolves isValid WITH the account email (the reset form identifier)', async () => {
|
|
54
|
+
await armResetToken('reset-valid@test.local', 'tok-valid-1', moment().add(1, 'hour'));
|
|
55
|
+
|
|
56
|
+
const outcome = await invokeValidate({ token: 'tok-valid-1' });
|
|
57
|
+
|
|
58
|
+
expect(outcome.status).toBe(200);
|
|
59
|
+
expect(outcome.body).toEqual({ isValid: true, email: 'reset-valid@test.local' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('an expired token resolves invalid and leaks no email', async () => {
|
|
63
|
+
await armResetToken('reset-expired@test.local', 'tok-expired-1', moment().subtract(1, 'minute'));
|
|
64
|
+
|
|
65
|
+
const outcome = await invokeValidate({ token: 'tok-expired-1' });
|
|
66
|
+
|
|
67
|
+
expect(outcome.status).toBe(200);
|
|
68
|
+
expect(outcome.body.isValid).toBe(false);
|
|
69
|
+
expect(outcome.body.email).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('an unknown token resolves invalid and leaks no email', async () => {
|
|
73
|
+
const outcome = await invokeValidate({ token: 'tok-never-issued' });
|
|
74
|
+
|
|
75
|
+
expect(outcome.status).toBe(200);
|
|
76
|
+
expect(outcome.body.isValid).toBe(false);
|
|
77
|
+
expect(outcome.body.email).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a missing token is a 400', async () => {
|
|
81
|
+
const outcome = await invokeValidate({});
|
|
82
|
+
|
|
83
|
+
expect(outcome.status).toBe(400);
|
|
84
|
+
expect(outcome.body.isValid).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|