@remit/auth-service 0.0.16 → 0.1.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/package.json +10 -2
- package/src/auth.ts +158 -3
- package/src/config.ts +16 -4
- package/src/identity-store.ts +286 -0
- package/src/index.ts +17 -0
- package/src/kill-switch.test.ts +179 -0
- package/src/people.test.ts +309 -0
- package/src/people.ts +540 -0
- package/src/rate-limit-log.test.ts +14 -7
- package/src/rate-limit.test.ts +14 -7
- package/src/schema/auth-schema-sqlite.ts +45 -1
- package/src/signup.test.ts +29 -1
- package/src/verifier.ts +2 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { after, before, describe, it } from "node:test";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
8
|
+
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
9
|
+
import { type Auth, createAuth } from "./auth.js";
|
|
10
|
+
import { openIdentityStore } from "./identity-store.js";
|
|
11
|
+
import { createPeopleDirectory } from "./people.js";
|
|
12
|
+
|
|
13
|
+
const ORIGIN = "http://localhost:3000";
|
|
14
|
+
const MIGRATIONS = resolve(
|
|
15
|
+
import.meta.dirname,
|
|
16
|
+
"../../../deploy/vps/migrations-sqlite",
|
|
17
|
+
);
|
|
18
|
+
const EMAIL = "owner@remit.test";
|
|
19
|
+
const PASSWORD = "correct horse battery staple";
|
|
20
|
+
|
|
21
|
+
let dir: string;
|
|
22
|
+
let filename: string;
|
|
23
|
+
|
|
24
|
+
const build = async (signInEnabled: boolean): Promise<Auth> =>
|
|
25
|
+
createAuth(
|
|
26
|
+
{
|
|
27
|
+
connectionString: filename,
|
|
28
|
+
secret: "kill-switch-test-secret-value-32chars-min",
|
|
29
|
+
baseURL: ORIGIN,
|
|
30
|
+
selfSignUpEnabled: true,
|
|
31
|
+
signInEnabled,
|
|
32
|
+
},
|
|
33
|
+
await openIdentityStore(filename),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const boot = async (auth: Auth, signInEnabled: boolean): Promise<void> => {
|
|
37
|
+
const people = await createPeopleDirectory({
|
|
38
|
+
auth,
|
|
39
|
+
store: await openIdentityStore(filename),
|
|
40
|
+
baseURL: ORIGIN,
|
|
41
|
+
});
|
|
42
|
+
people.applySignInSwitch(signInEnabled);
|
|
43
|
+
people.close();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const post = (auth: Auth, path: string, body: unknown, cookie = "") =>
|
|
47
|
+
auth.handler(
|
|
48
|
+
new Request(`${ORIGIN}/api/auth${path}`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "content-type": "application/json", origin: ORIGIN, cookie },
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const get = (auth: Auth, path: string, cookie = "") =>
|
|
56
|
+
auth.handler(
|
|
57
|
+
new Request(`${ORIGIN}/api/auth${path}`, { headers: { cookie } }),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const cookiesOf = (response: Response): string =>
|
|
61
|
+
response.headers
|
|
62
|
+
.getSetCookie()
|
|
63
|
+
.map((value) => value.split(";")[0])
|
|
64
|
+
.join("; ");
|
|
65
|
+
|
|
66
|
+
const rows = (sql: string): unknown[] => {
|
|
67
|
+
const sqlite = new Database(filename, { readonly: true });
|
|
68
|
+
const found = sqlite.prepare(sql).all();
|
|
69
|
+
sqlite.close();
|
|
70
|
+
return found;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
before(() => {
|
|
74
|
+
dir = mkdtempSync(join(tmpdir(), "remit-kill-switch-"));
|
|
75
|
+
filename = join(dir, "app.db");
|
|
76
|
+
const sqlite = new Database(filename);
|
|
77
|
+
sqlite.pragma("journal_mode = WAL");
|
|
78
|
+
sqlite.pragma("foreign_keys = ON");
|
|
79
|
+
for (const set of ["app", "mcp"]) {
|
|
80
|
+
migrate(drizzle(sqlite), {
|
|
81
|
+
migrationsFolder: join(MIGRATIONS, set),
|
|
82
|
+
migrationsTable: `__drizzle_migrations_${set}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
sqlite.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
after(() => {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("the sign-in kill switch", () => {
|
|
93
|
+
let cookie: string;
|
|
94
|
+
|
|
95
|
+
it("refuses sign-in, sign-up, token and session reads while it is off, and keeps the JWKS up", async () => {
|
|
96
|
+
const on = await build(true);
|
|
97
|
+
const signedUp = await post(on, "/sign-up/email", {
|
|
98
|
+
email: EMAIL,
|
|
99
|
+
password: PASSWORD,
|
|
100
|
+
name: "Owner",
|
|
101
|
+
});
|
|
102
|
+
assert.equal(signedUp.status, 200);
|
|
103
|
+
cookie = cookiesOf(signedUp);
|
|
104
|
+
const userId = (rows("SELECT id FROM auth_user")[0] as { id: string }).id;
|
|
105
|
+
const sqlite = new Database(filename);
|
|
106
|
+
sqlite
|
|
107
|
+
.prepare(
|
|
108
|
+
"INSERT INTO mcp_oauth_client (id, client_id, redirect_uris) VALUES ('client-row', 'client-1', '[]')",
|
|
109
|
+
)
|
|
110
|
+
.run();
|
|
111
|
+
sqlite
|
|
112
|
+
.prepare(
|
|
113
|
+
"INSERT INTO mcp_oauth_consent (id, client_id, user_id, scopes) VALUES ('consent-1', 'client-1', ?, '[]')",
|
|
114
|
+
)
|
|
115
|
+
.run(userId);
|
|
116
|
+
sqlite.close();
|
|
117
|
+
|
|
118
|
+
const off = await build(false);
|
|
119
|
+
const refusals = [
|
|
120
|
+
await post(off, "/sign-in/email", { email: EMAIL, password: PASSWORD }),
|
|
121
|
+
await post(off, "/sign-up/email", {
|
|
122
|
+
email: "new@remit.test",
|
|
123
|
+
password: PASSWORD,
|
|
124
|
+
name: "New",
|
|
125
|
+
}),
|
|
126
|
+
await get(off, "/token", cookie),
|
|
127
|
+
await get(off, "/get-session", cookie),
|
|
128
|
+
];
|
|
129
|
+
for (const response of refusals) {
|
|
130
|
+
assert.equal(response.status, 403);
|
|
131
|
+
assert.equal(
|
|
132
|
+
((await response.json()) as { code: string }).code,
|
|
133
|
+
"SIGN_IN_DISABLED",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
assert.equal((await get(off, "/jwks")).status, 200);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("revokes every session and MCP consent at boot, and records it once across two boots", async () => {
|
|
140
|
+
const off = await build(false);
|
|
141
|
+
await boot(off, false);
|
|
142
|
+
await boot(off, false);
|
|
143
|
+
|
|
144
|
+
assert.deepEqual(rows("SELECT id FROM auth_session"), []);
|
|
145
|
+
assert.deepEqual(rows("SELECT id FROM mcp_oauth_consent"), []);
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
rows(
|
|
148
|
+
"SELECT actor, target, action, client_ip AS clientIp FROM identity_audit_event WHERE action LIKE 'KillSwitch%'",
|
|
149
|
+
),
|
|
150
|
+
[
|
|
151
|
+
{
|
|
152
|
+
actor: "env",
|
|
153
|
+
target: "all",
|
|
154
|
+
action: "KillSwitchOn",
|
|
155
|
+
clientIp: "host",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("lets a user sign in again once it is back on, with the old session still gone", async () => {
|
|
162
|
+
const on = await build(true);
|
|
163
|
+
await boot(on, true);
|
|
164
|
+
|
|
165
|
+
assert.equal((await get(on, "/token", cookie)).status, 401);
|
|
166
|
+
const signedIn = await post(on, "/sign-in/email", {
|
|
167
|
+
email: EMAIL,
|
|
168
|
+
password: PASSWORD,
|
|
169
|
+
});
|
|
170
|
+
assert.equal(signedIn.status, 200);
|
|
171
|
+
assert.equal((await get(on, "/token", cookiesOf(signedIn))).status, 200);
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
rows(
|
|
174
|
+
"SELECT action FROM identity_audit_event WHERE action LIKE 'KillSwitch%' ORDER BY occurred_at",
|
|
175
|
+
),
|
|
176
|
+
[{ action: "KillSwitchOn" }, { action: "KillSwitchOff" }],
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { after, before, describe, it } from "node:test";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
8
|
+
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
9
|
+
import { type Auth, CLIENT_IP_HEADER, createAuth } from "./auth.js";
|
|
10
|
+
import { openIdentityStore } from "./identity-store.js";
|
|
11
|
+
import {
|
|
12
|
+
createPeopleDirectory,
|
|
13
|
+
type PeopleAdmin,
|
|
14
|
+
resetIdentifierOf,
|
|
15
|
+
type StepUpAction,
|
|
16
|
+
} from "./people.js";
|
|
17
|
+
|
|
18
|
+
const ORIGIN = "http://localhost:3000";
|
|
19
|
+
const MIGRATIONS = resolve(
|
|
20
|
+
import.meta.dirname,
|
|
21
|
+
"../../../deploy/vps/migrations-sqlite",
|
|
22
|
+
);
|
|
23
|
+
const OPERATOR = "operator@remit.test";
|
|
24
|
+
const PASSWORD = "correct horse battery staple";
|
|
25
|
+
const PEER = "198.51.100.23";
|
|
26
|
+
|
|
27
|
+
let dir: string;
|
|
28
|
+
let filename: string;
|
|
29
|
+
let auth: Auth;
|
|
30
|
+
let people: PeopleAdmin;
|
|
31
|
+
let operatorId: string;
|
|
32
|
+
|
|
33
|
+
const post = (path: string, body: unknown, headers = {}) =>
|
|
34
|
+
auth.handler(
|
|
35
|
+
new Request(`${ORIGIN}/api/auth${path}`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
origin: ORIGIN,
|
|
40
|
+
...headers,
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const tokenStatus = async (cookie: string): Promise<number> =>
|
|
47
|
+
(
|
|
48
|
+
await auth.handler(
|
|
49
|
+
new Request(`${ORIGIN}/api/auth/token`, { headers: { cookie } }),
|
|
50
|
+
)
|
|
51
|
+
).status;
|
|
52
|
+
|
|
53
|
+
const cookiesOf = (response: Response): string =>
|
|
54
|
+
response.headers
|
|
55
|
+
.getSetCookie()
|
|
56
|
+
.map((value) => value.split(";")[0])
|
|
57
|
+
.join("; ");
|
|
58
|
+
|
|
59
|
+
// Each group signs in from its own address: the sign-in limit is five a minute
|
|
60
|
+
// per address, and the groups together sign in more often than that.
|
|
61
|
+
const signInFrom =
|
|
62
|
+
(clientIp: string) =>
|
|
63
|
+
(email: string, password = PASSWORD) =>
|
|
64
|
+
post(
|
|
65
|
+
"/sign-in/email",
|
|
66
|
+
{ email, password },
|
|
67
|
+
{ [CLIENT_IP_HEADER]: clientIp },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const signUp = async (email: string): Promise<string> => {
|
|
71
|
+
const response = await post("/sign-up/email", {
|
|
72
|
+
email,
|
|
73
|
+
password: PASSWORD,
|
|
74
|
+
name: email,
|
|
75
|
+
});
|
|
76
|
+
assert.equal(response.status, 200);
|
|
77
|
+
return ((await response.json()) as { user: { id: string } }).user.id;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const sql = (statement: string, ...params: unknown[]): unknown[] => {
|
|
81
|
+
const sqlite = new Database(filename);
|
|
82
|
+
const prepared = sqlite.prepare(statement);
|
|
83
|
+
const found = prepared.reader ? prepared.all(...params) : [];
|
|
84
|
+
if (!prepared.reader) prepared.run(...params);
|
|
85
|
+
sqlite.close();
|
|
86
|
+
return found;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const stepUp = async (
|
|
90
|
+
targetId: string,
|
|
91
|
+
action: StepUpAction,
|
|
92
|
+
): Promise<string> => {
|
|
93
|
+
const granted = await people.createStepUp({
|
|
94
|
+
callerId: operatorId,
|
|
95
|
+
clientIp: PEER,
|
|
96
|
+
targetId,
|
|
97
|
+
action,
|
|
98
|
+
password: PASSWORD,
|
|
99
|
+
});
|
|
100
|
+
assert.ok(granted.ok, JSON.stringify(granted));
|
|
101
|
+
return granted.value.stepUpToken;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const codeOf = async (response: Response): Promise<string | undefined> =>
|
|
105
|
+
((await response.json()) as { code?: string }).code;
|
|
106
|
+
|
|
107
|
+
before(async () => {
|
|
108
|
+
dir = mkdtempSync(join(tmpdir(), "remit-people-"));
|
|
109
|
+
filename = join(dir, "app.db");
|
|
110
|
+
const sqlite = new Database(filename);
|
|
111
|
+
sqlite.pragma("journal_mode = WAL");
|
|
112
|
+
sqlite.pragma("foreign_keys = ON");
|
|
113
|
+
for (const set of ["app", "mcp"]) {
|
|
114
|
+
migrate(drizzle(sqlite), {
|
|
115
|
+
migrationsFolder: join(MIGRATIONS, set),
|
|
116
|
+
migrationsTable: `__drizzle_migrations_${set}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
sqlite.close();
|
|
120
|
+
|
|
121
|
+
const store = await openIdentityStore(filename);
|
|
122
|
+
auth = await createAuth(
|
|
123
|
+
{
|
|
124
|
+
connectionString: filename,
|
|
125
|
+
secret: "people-test-secret-value-of-32-chars-min",
|
|
126
|
+
baseURL: ORIGIN,
|
|
127
|
+
selfSignUpEnabled: true,
|
|
128
|
+
signInEnabled: true,
|
|
129
|
+
},
|
|
130
|
+
store,
|
|
131
|
+
);
|
|
132
|
+
people = await createPeopleDirectory({
|
|
133
|
+
auth,
|
|
134
|
+
store,
|
|
135
|
+
baseURL: ORIGIN,
|
|
136
|
+
});
|
|
137
|
+
operatorId = await signUp(OPERATOR);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
after(() => {
|
|
141
|
+
people.close();
|
|
142
|
+
rmSync(dir, { recursive: true, force: true });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("a disabled account", () => {
|
|
146
|
+
const signIn = signInFrom("198.51.100.31");
|
|
147
|
+
const EMAIL = "disabled@remit.test";
|
|
148
|
+
let userId: string;
|
|
149
|
+
|
|
150
|
+
before(async () => {
|
|
151
|
+
userId = await signUp(EMAIL);
|
|
152
|
+
const disabled = await people.updatePerson({
|
|
153
|
+
callerId: operatorId,
|
|
154
|
+
clientIp: PEER,
|
|
155
|
+
targetId: userId,
|
|
156
|
+
status: "Disabled",
|
|
157
|
+
stepUpToken: await stepUp(userId, "Disable"),
|
|
158
|
+
});
|
|
159
|
+
assert.ok(disabled.ok);
|
|
160
|
+
assert.equal(disabled.value.status, "Disabled");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("is refused after a right password, with a record, and answers 401 to a wrong one", async () => {
|
|
164
|
+
const right = await signIn(EMAIL);
|
|
165
|
+
assert.equal(right.status, 403);
|
|
166
|
+
assert.equal(await codeOf(right), "ACCOUNT_DISABLED");
|
|
167
|
+
assert.equal(right.headers.getSetCookie().length, 0);
|
|
168
|
+
assert.ok(
|
|
169
|
+
sql(
|
|
170
|
+
"SELECT 1 FROM identity_audit_event WHERE action = 'SignInRefusedDisabled' AND outcome = 'RefusedDisabled' AND target = ?",
|
|
171
|
+
userId,
|
|
172
|
+
).length > 0,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
assert.equal((await signIn(EMAIL, "not the password")).status, 401);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("loses a session that raced the disable, at its next token mint", async () => {
|
|
179
|
+
assert.ok((await people.enableByEmail(EMAIL)).ok);
|
|
180
|
+
const signedIn = await signIn(EMAIL);
|
|
181
|
+
assert.equal(signedIn.status, 200);
|
|
182
|
+
const cookie = cookiesOf(signedIn);
|
|
183
|
+
sql("UPDATE auth_user SET disabled = 1 WHERE id = ?", userId);
|
|
184
|
+
|
|
185
|
+
const minted = await auth.handler(
|
|
186
|
+
new Request(`${ORIGIN}/api/auth/token`, { headers: { cookie } }),
|
|
187
|
+
);
|
|
188
|
+
assert.equal(minted.status, 403);
|
|
189
|
+
assert.equal(await codeOf(minted), "ACCOUNT_DISABLED");
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
sql("SELECT id FROM auth_session WHERE user_id = ?", userId),
|
|
192
|
+
[],
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("signs in again once enabled", async () => {
|
|
197
|
+
assert.ok((await people.enableByEmail(EMAIL)).ok);
|
|
198
|
+
assert.equal((await signIn(EMAIL)).status, 200);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("a password reset link", () => {
|
|
203
|
+
const signIn = signInFrom("198.51.100.32");
|
|
204
|
+
const EMAIL = "reset@remit.test";
|
|
205
|
+
const NEW_PASSWORD = "a different long passphrase";
|
|
206
|
+
let userId: string;
|
|
207
|
+
let token: string;
|
|
208
|
+
|
|
209
|
+
const tokenOf = (resetUrl: string): string => {
|
|
210
|
+
const found = resetUrl.split("/").at(-1);
|
|
211
|
+
assert.ok(found);
|
|
212
|
+
return found;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const redeem = (resetToken: string, newPassword = NEW_PASSWORD) =>
|
|
216
|
+
post(
|
|
217
|
+
"/reset-password",
|
|
218
|
+
{ token: resetToken, newPassword },
|
|
219
|
+
{ [CLIENT_IP_HEADER]: PEER },
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
before(async () => {
|
|
223
|
+
userId = await signUp(EMAIL);
|
|
224
|
+
const issued = await people.createPasswordReset({
|
|
225
|
+
callerId: operatorId,
|
|
226
|
+
clientIp: PEER,
|
|
227
|
+
targetId: userId,
|
|
228
|
+
stepUpToken: await stepUp(userId, "ResetPassword"),
|
|
229
|
+
});
|
|
230
|
+
assert.ok(issued.ok);
|
|
231
|
+
assert.equal(
|
|
232
|
+
issued.value.resetUrl,
|
|
233
|
+
`${ORIGIN}/reset-password/${tokenOf(issued.value.resetUrl)}`,
|
|
234
|
+
);
|
|
235
|
+
token = tokenOf(issued.value.resetUrl);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("is stored only as a digest", () => {
|
|
239
|
+
const stored = JSON.stringify(sql("SELECT * FROM auth_verification"));
|
|
240
|
+
assert.ok(!stored.includes(token));
|
|
241
|
+
assert.equal(
|
|
242
|
+
sql(
|
|
243
|
+
"SELECT 1 FROM auth_verification WHERE identifier = ? AND value = ?",
|
|
244
|
+
resetIdentifierOf(token),
|
|
245
|
+
userId,
|
|
246
|
+
).length,
|
|
247
|
+
1,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("stops the old password and every session at once", async () => {
|
|
252
|
+
assert.equal((await signIn(EMAIL)).status, 401);
|
|
253
|
+
const listed = await people.listPeople({
|
|
254
|
+
callerId: operatorId,
|
|
255
|
+
clientIp: PEER,
|
|
256
|
+
});
|
|
257
|
+
assert.ok(listed.ok);
|
|
258
|
+
assert.equal(
|
|
259
|
+
listed.value.find((person) => person.userId === userId)?.sessionCount,
|
|
260
|
+
0,
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("works once, and a second redeem is refused with a record carrying the client address", async () => {
|
|
265
|
+
assert.equal((await redeem(token)).status, 200);
|
|
266
|
+
assert.equal((await signIn(EMAIL, NEW_PASSWORD)).status, 200);
|
|
267
|
+
|
|
268
|
+
const again = await redeem(token, "yet another long passphrase");
|
|
269
|
+
assert.equal(again.status, 400);
|
|
270
|
+
assert.deepEqual(
|
|
271
|
+
sql(
|
|
272
|
+
"SELECT actor, target, client_ip AS clientIp FROM identity_audit_event WHERE action = 'PasswordResetRefused'",
|
|
273
|
+
),
|
|
274
|
+
[{ actor: "unknown", target: "unknown", clientIp: PEER }],
|
|
275
|
+
);
|
|
276
|
+
assert.equal(
|
|
277
|
+
sql(
|
|
278
|
+
"SELECT 1 FROM identity_audit_event WHERE action = 'PasswordResetRedeemed' AND target = ? AND client_ip = ?",
|
|
279
|
+
userId,
|
|
280
|
+
PEER,
|
|
281
|
+
).length,
|
|
282
|
+
1,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("is refused past its expiry", async () => {
|
|
287
|
+
const issued = await people.resetByEmail(EMAIL);
|
|
288
|
+
assert.ok(issued.ok);
|
|
289
|
+
sql(
|
|
290
|
+
"UPDATE auth_verification SET expires_at = ? WHERE value = ?",
|
|
291
|
+
Math.floor(Date.now() / 1000) - 60,
|
|
292
|
+
userId,
|
|
293
|
+
);
|
|
294
|
+
assert.equal((await redeem(tokenOf(issued.value.resetUrl))).status, 400);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("dies when a newer one is issued, and the newer one survives", async () => {
|
|
298
|
+
const older = await people.resetByEmail(EMAIL);
|
|
299
|
+
const newer = await people.resetByEmail(EMAIL);
|
|
300
|
+
assert.ok(older.ok && newer.ok);
|
|
301
|
+
const newerToken = tokenOf(newer.value.resetUrl);
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
sql("SELECT identifier FROM auth_verification WHERE value = ?", userId),
|
|
304
|
+
[{ identifier: resetIdentifierOf(newerToken) }],
|
|
305
|
+
);
|
|
306
|
+
assert.equal((await redeem(tokenOf(older.value.resetUrl))).status, 400);
|
|
307
|
+
assert.equal((await redeem(newerToken)).status, 200);
|
|
308
|
+
});
|
|
309
|
+
});
|