@vex-chat/spire 4.0.0 → 4.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/dist/Database.d.ts +8 -0
- package/dist/Database.js +68 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.js +1 -1
- package/dist/Spire.js.map +1 -1
- package/dist/server/avatar.js +3 -2
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +580 -91
- package/dist/server/cliPasskeyPage.js.map +1 -1
- package/dist/server/index.js +163 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +326 -3
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- package/package.json +2 -2
- package/src/Database.ts +83 -0
- package/src/Spire.ts +1 -1
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +17 -1
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/server/avatar.ts +3 -2
- package/src/server/cliPasskeyPage.ts +580 -91
- package/src/server/index.ts +211 -31
- package/src/server/passkey.ts +480 -3
- package/src/server/serverIcon.ts +199 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serverIcon.js","sourceRoot":"","sources":["../../src/server/serverIcon.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vex-chat/spire",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.1",
|
|
4
4
|
"description": "Vex server implementation in NodeJS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"typescript-eslint": "8.58.1",
|
|
56
56
|
"vitest": "4.1.4",
|
|
57
57
|
"@vex-chat/eslint-config": "0.0.1",
|
|
58
|
-
"@vex-chat/libvex": "^9.
|
|
58
|
+
"@vex-chat/libvex": "^9.1.1"
|
|
59
59
|
},
|
|
60
60
|
"typeCoverage": {
|
|
61
61
|
"atLeast": 95,
|
package/src/Database.ts
CHANGED
|
@@ -478,6 +478,44 @@ export class Database extends EventEmitter {
|
|
|
478
478
|
.execute();
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
/** Delete a channel while preserving the invariant that a server has one. */
|
|
482
|
+
public async deleteChannelIfNotLast(channelID: string): Promise<boolean> {
|
|
483
|
+
return this.db.transaction().execute(async (trx) => {
|
|
484
|
+
const channel = await trx
|
|
485
|
+
.selectFrom("channels")
|
|
486
|
+
.selectAll()
|
|
487
|
+
.where("channelID", "=", channelID)
|
|
488
|
+
.executeTakeFirst();
|
|
489
|
+
if (!channel) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const siblings = await trx
|
|
494
|
+
.selectFrom("channels")
|
|
495
|
+
.select("channelID")
|
|
496
|
+
.where("serverID", "=", channel.serverID)
|
|
497
|
+
.limit(2)
|
|
498
|
+
.execute();
|
|
499
|
+
if (siblings.length <= 1) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await trx
|
|
504
|
+
.deleteFrom("permissions")
|
|
505
|
+
.where("resourceID", "=", channelID)
|
|
506
|
+
.execute();
|
|
507
|
+
await trx
|
|
508
|
+
.deleteFrom("mail")
|
|
509
|
+
.where("group", "=", channelID)
|
|
510
|
+
.execute();
|
|
511
|
+
await trx
|
|
512
|
+
.deleteFrom("channels")
|
|
513
|
+
.where("channelID", "=", channelID)
|
|
514
|
+
.execute();
|
|
515
|
+
return true;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
481
519
|
public async deleteDevice(deviceID: string): Promise<void> {
|
|
482
520
|
await this.db.transaction().execute(async (trx) => {
|
|
483
521
|
await trx
|
|
@@ -1704,6 +1742,51 @@ export class Database extends EventEmitter {
|
|
|
1704
1742
|
});
|
|
1705
1743
|
}
|
|
1706
1744
|
|
|
1745
|
+
public async updateChannel(
|
|
1746
|
+
channelID: string,
|
|
1747
|
+
name: string,
|
|
1748
|
+
): Promise<Channel | null> {
|
|
1749
|
+
const result = await this.db
|
|
1750
|
+
.updateTable("channels")
|
|
1751
|
+
.set({ name })
|
|
1752
|
+
.where("channelID", "=", channelID)
|
|
1753
|
+
.executeTakeFirst();
|
|
1754
|
+
if (Number(result.numUpdatedRows) === 0) {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
return this.retrieveChannel(channelID);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
public async updatePermissionPowerLevel(
|
|
1761
|
+
permissionID: string,
|
|
1762
|
+
powerLevel: number,
|
|
1763
|
+
): Promise<null | Permission> {
|
|
1764
|
+
const result = await this.db
|
|
1765
|
+
.updateTable("permissions")
|
|
1766
|
+
.set({ powerLevel })
|
|
1767
|
+
.where("permissionID", "=", permissionID)
|
|
1768
|
+
.executeTakeFirst();
|
|
1769
|
+
if (Number(result.numUpdatedRows) === 0) {
|
|
1770
|
+
return null;
|
|
1771
|
+
}
|
|
1772
|
+
return this.retrievePermission(permissionID);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
public async updateServer(
|
|
1776
|
+
serverID: string,
|
|
1777
|
+
update: { icon?: null | string; name?: string },
|
|
1778
|
+
): Promise<null | Server> {
|
|
1779
|
+
const result = await this.db
|
|
1780
|
+
.updateTable("servers")
|
|
1781
|
+
.set(update)
|
|
1782
|
+
.where("serverID", "=", serverID)
|
|
1783
|
+
.executeTakeFirst();
|
|
1784
|
+
if (Number(result.numUpdatedRows) === 0) {
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
return this.retrieveServer(serverID);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1707
1790
|
public async upsertStoreSubscription(
|
|
1708
1791
|
input: StoreSubscriptionUpsertInput,
|
|
1709
1792
|
): Promise<BillingSubscription> {
|
package/src/Spire.ts
CHANGED
|
@@ -188,7 +188,7 @@ const notificationSubscribePayload = z.object({
|
|
|
188
188
|
token: z.string().min(1).max(4096),
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
-
const directories = ["files", "avatars", "emoji"];
|
|
191
|
+
const directories = ["files", "avatars", "emoji", "server-icons"];
|
|
192
192
|
for (const dir of directories) {
|
|
193
193
|
if (!fs.existsSync(dir)) {
|
|
194
194
|
fs.mkdirSync(dir);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "../Database.ts";
|
|
8
|
+
import type { Passkey, User } from "@vex-chat/types";
|
|
9
|
+
import type { Server } from "node:http";
|
|
10
|
+
|
|
11
|
+
import express from "express";
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { getPasskeyRouter } from "../server/passkey.ts";
|
|
16
|
+
|
|
17
|
+
const user: User = {
|
|
18
|
+
lastSeen: new Date(0).toISOString(),
|
|
19
|
+
userID: "user-browser-auth",
|
|
20
|
+
username: "alice",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const passkey: Passkey = {
|
|
24
|
+
createdAt: new Date(0).toISOString(),
|
|
25
|
+
lastUsedAt: null,
|
|
26
|
+
name: "MacBook Touch ID",
|
|
27
|
+
passkeyID: "passkey-browser-auth",
|
|
28
|
+
transports: ["internal"],
|
|
29
|
+
userID: user.userID,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const originalRpID = process.env["SPIRE_PASSKEY_RP_ID"];
|
|
33
|
+
const originalOrigins = process.env["SPIRE_PASSKEY_ORIGINS"];
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
process.env["SPIRE_PASSKEY_RP_ID"] = "vex.example";
|
|
37
|
+
process.env["SPIRE_PASSKEY_ORIGINS"] = "https://vex.example";
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
restoreEnv("SPIRE_PASSKEY_RP_ID", originalRpID);
|
|
42
|
+
restoreEnv("SPIRE_PASSKEY_ORIGINS", originalOrigins);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("browser passkey authentication", () => {
|
|
46
|
+
it("returns an HTTPS assertion to the original authentication request", async () => {
|
|
47
|
+
const db = {
|
|
48
|
+
retrievePasskeyInternal: vi.fn(() =>
|
|
49
|
+
Promise.resolve({ credentialID: "credential-browser-auth" }),
|
|
50
|
+
),
|
|
51
|
+
retrievePasskeysByUser: vi.fn(() => Promise.resolve([passkey])),
|
|
52
|
+
retrieveUser: vi.fn(() => Promise.resolve(user)),
|
|
53
|
+
} as unknown as Database;
|
|
54
|
+
const app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
app.use(getPasskeyRouter(db));
|
|
57
|
+
const server = await listen(app);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const baseUrl = serverBaseUrl(server);
|
|
61
|
+
const authBeginResponse = await post(
|
|
62
|
+
`${baseUrl}/auth/passkey/begin?format=json`,
|
|
63
|
+
{ username: user.username },
|
|
64
|
+
);
|
|
65
|
+
const authBegin = (await authBeginResponse.json()) as {
|
|
66
|
+
options: {
|
|
67
|
+
challenge: string;
|
|
68
|
+
rpId: string;
|
|
69
|
+
vexBrowserHandoff: {
|
|
70
|
+
browserToken: string;
|
|
71
|
+
expiresAt: string;
|
|
72
|
+
requestID: string;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
requestID: string;
|
|
76
|
+
};
|
|
77
|
+
const handoff = authBegin.options.vexBrowserHandoff;
|
|
78
|
+
|
|
79
|
+
expect(authBeginResponse.status).toBe(200);
|
|
80
|
+
expect(authBegin.options.rpId).toBe("vex.example");
|
|
81
|
+
expect(handoff.browserToken.length).toBeGreaterThanOrEqual(32);
|
|
82
|
+
expect(handoff.requestID).not.toBe(authBegin.requestID);
|
|
83
|
+
|
|
84
|
+
const wrongTokenResponse = await post(
|
|
85
|
+
`${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/begin?format=json`,
|
|
86
|
+
{ token: "x".repeat(43) },
|
|
87
|
+
);
|
|
88
|
+
expect(wrongTokenResponse.status).toBe(401);
|
|
89
|
+
|
|
90
|
+
const pendingResponse = await post(
|
|
91
|
+
`${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/status?format=json`,
|
|
92
|
+
{ token: handoff.browserToken },
|
|
93
|
+
);
|
|
94
|
+
expect(pendingResponse.status).toBe(202);
|
|
95
|
+
|
|
96
|
+
const browserBeginResponse = await post(
|
|
97
|
+
`${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/begin?format=json`,
|
|
98
|
+
{ token: handoff.browserToken },
|
|
99
|
+
);
|
|
100
|
+
const browserBegin = (await browserBeginResponse.json()) as {
|
|
101
|
+
options: {
|
|
102
|
+
challenge: string;
|
|
103
|
+
vexBrowserHandoff?: unknown;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
expect(browserBeginResponse.status).toBe(200);
|
|
107
|
+
expect(browserBegin.options.challenge).toBe(
|
|
108
|
+
authBegin.options.challenge,
|
|
109
|
+
);
|
|
110
|
+
expect(browserBegin.options.vexBrowserHandoff).toBeUndefined();
|
|
111
|
+
|
|
112
|
+
const assertion = {
|
|
113
|
+
id: "credential-browser-auth",
|
|
114
|
+
response: { signature: "browser-assertion" },
|
|
115
|
+
type: "public-key",
|
|
116
|
+
};
|
|
117
|
+
const browserFinishResponse = await post(
|
|
118
|
+
`${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/finish?format=json`,
|
|
119
|
+
{ response: assertion, token: handoff.browserToken },
|
|
120
|
+
);
|
|
121
|
+
expect(browserFinishResponse.status).toBe(200);
|
|
122
|
+
|
|
123
|
+
const readyResponse = await post(
|
|
124
|
+
`${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/status?format=json`,
|
|
125
|
+
{ token: handoff.browserToken },
|
|
126
|
+
);
|
|
127
|
+
expect(readyResponse.status).toBe(200);
|
|
128
|
+
await expect(readyResponse.json()).resolves.toEqual({
|
|
129
|
+
response: assertion,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const consumeResponse = await post(
|
|
133
|
+
`${baseUrl}/auth/passkey/finish?format=json`,
|
|
134
|
+
{ requestID: authBegin.requestID, response: {} },
|
|
135
|
+
);
|
|
136
|
+
expect(consumeResponse.status).toBe(400);
|
|
137
|
+
|
|
138
|
+
const replayResponse = await post(
|
|
139
|
+
`${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/status?format=json`,
|
|
140
|
+
{ token: handoff.browserToken },
|
|
141
|
+
);
|
|
142
|
+
expect(replayResponse.status).toBe(401);
|
|
143
|
+
} finally {
|
|
144
|
+
await close(server);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function close(server: Server): Promise<void> {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
server.close((error) => {
|
|
152
|
+
if (error) {
|
|
153
|
+
reject(error);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function listen(app: express.Application): Promise<Server> {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const server = app.listen(0, "127.0.0.1", () => {
|
|
164
|
+
resolve(server);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function post(url: string, body: unknown): Promise<Response> {
|
|
170
|
+
return fetch(url, {
|
|
171
|
+
body: JSON.stringify(body),
|
|
172
|
+
headers: { "Content-Type": "application/json" },
|
|
173
|
+
method: "POST",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function restoreEnv(name: string, value: string | undefined): void {
|
|
178
|
+
if (value === undefined) {
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- restores the process environment exactly
|
|
180
|
+
delete process.env[name];
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
process.env[name] = value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function serverBaseUrl(server: Server): string {
|
|
187
|
+
const address = server.address();
|
|
188
|
+
if (!address || typeof address === "string") {
|
|
189
|
+
throw new Error("Expected TCP listener.");
|
|
190
|
+
}
|
|
191
|
+
return `http://127.0.0.1:${String(address.port)}`;
|
|
192
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "../Database.ts";
|
|
8
|
+
import type { Device, User } from "@vex-chat/types";
|
|
9
|
+
import type { Server } from "node:http";
|
|
10
|
+
|
|
11
|
+
import express from "express";
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { getPasskeyRouter } from "../server/passkey.ts";
|
|
16
|
+
|
|
17
|
+
const user: User = {
|
|
18
|
+
lastSeen: new Date(0).toISOString(),
|
|
19
|
+
userID: "user-a",
|
|
20
|
+
username: "alice",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const device: Device = {
|
|
24
|
+
deleted: false,
|
|
25
|
+
deviceID: "device-a",
|
|
26
|
+
lastLogin: new Date(0).toISOString(),
|
|
27
|
+
name: "desktop",
|
|
28
|
+
owner: user.userID,
|
|
29
|
+
signKey: "a".repeat(64),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const originalRpID = process.env["SPIRE_PASSKEY_RP_ID"];
|
|
33
|
+
const originalOrigins = process.env["SPIRE_PASSKEY_ORIGINS"];
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
process.env["SPIRE_PASSKEY_RP_ID"] = "vex.example";
|
|
37
|
+
process.env["SPIRE_PASSKEY_ORIGINS"] = "https://vex.example";
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
restoreEnv("SPIRE_PASSKEY_RP_ID", originalRpID);
|
|
42
|
+
restoreEnv("SPIRE_PASSKEY_ORIGINS", originalOrigins);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("browser passkey registration", () => {
|
|
46
|
+
it("issues a scoped browser handoff from an authenticated registration", async () => {
|
|
47
|
+
let approvedDevice: Device | null = device;
|
|
48
|
+
const db = {
|
|
49
|
+
retrieveDevice: vi.fn(() => Promise.resolve(approvedDevice)),
|
|
50
|
+
retrievePasskeysByUser: vi.fn(() => Promise.resolve([])),
|
|
51
|
+
} as unknown as Database;
|
|
52
|
+
const app = express();
|
|
53
|
+
app.use(express.json());
|
|
54
|
+
app.use((req, _res, next) => {
|
|
55
|
+
req.device = device;
|
|
56
|
+
req.user = user;
|
|
57
|
+
next();
|
|
58
|
+
});
|
|
59
|
+
app.use(getPasskeyRouter(db));
|
|
60
|
+
const server = await listen(app);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const baseUrl = serverBaseUrl(server);
|
|
64
|
+
const createResponse = await fetch(
|
|
65
|
+
`${baseUrl}/user/${user.userID}/passkeys/register/begin?format=json`,
|
|
66
|
+
{
|
|
67
|
+
body: JSON.stringify({ name: "MacBook Touch ID" }),
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
method: "POST",
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
const created = (await createResponse.json()) as {
|
|
73
|
+
options: {
|
|
74
|
+
rp: { id: string };
|
|
75
|
+
vexBrowserHandoff: {
|
|
76
|
+
browserToken: string;
|
|
77
|
+
expiresAt: string;
|
|
78
|
+
requestID: string;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
requestID: string;
|
|
82
|
+
};
|
|
83
|
+
const handoff = created.options.vexBrowserHandoff;
|
|
84
|
+
|
|
85
|
+
expect(createResponse.status).toBe(200);
|
|
86
|
+
expect(created.options.rp.id).toBe("vex.example");
|
|
87
|
+
expect(handoff.browserToken.length).toBeGreaterThanOrEqual(32);
|
|
88
|
+
expect(new Date(handoff.expiresAt).getTime()).toBeGreaterThan(
|
|
89
|
+
Date.now(),
|
|
90
|
+
);
|
|
91
|
+
expect(handoff.requestID).not.toBe(created.requestID);
|
|
92
|
+
|
|
93
|
+
const wrongTokenResponse = await fetch(
|
|
94
|
+
`${baseUrl}/auth/passkey/browser-registration/${handoff.requestID}/begin?format=json`,
|
|
95
|
+
{
|
|
96
|
+
body: JSON.stringify({ token: "x".repeat(43) }),
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
method: "POST",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
expect(wrongTokenResponse.status).toBe(401);
|
|
102
|
+
|
|
103
|
+
const beginResponse = await fetch(
|
|
104
|
+
`${baseUrl}/auth/passkey/browser-registration/${handoff.requestID}/begin?format=json`,
|
|
105
|
+
{
|
|
106
|
+
body: JSON.stringify({ token: handoff.browserToken }),
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
method: "POST",
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
const begin = (await beginResponse.json()) as {
|
|
112
|
+
options: { rp: { id: string } };
|
|
113
|
+
};
|
|
114
|
+
expect(beginResponse.status).toBe(200);
|
|
115
|
+
expect(begin.options.rp.id).toBe("vex.example");
|
|
116
|
+
|
|
117
|
+
const nativeFinishResponse = await fetch(
|
|
118
|
+
`${baseUrl}/user/${user.userID}/passkeys/register/finish?format=json`,
|
|
119
|
+
{
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
name: "MacBook Touch ID",
|
|
122
|
+
requestID: created.requestID,
|
|
123
|
+
response: {},
|
|
124
|
+
}),
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
method: "POST",
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
expect(nativeFinishResponse.status).toBe(404);
|
|
130
|
+
|
|
131
|
+
approvedDevice = null;
|
|
132
|
+
const revokedDeviceResponse = await fetch(
|
|
133
|
+
`${baseUrl}/auth/passkey/browser-registration/${handoff.requestID}/begin?format=json`,
|
|
134
|
+
{
|
|
135
|
+
body: JSON.stringify({ token: handoff.browserToken }),
|
|
136
|
+
headers: { "Content-Type": "application/json" },
|
|
137
|
+
method: "POST",
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
expect(revokedDeviceResponse.status).toBe(401);
|
|
141
|
+
} finally {
|
|
142
|
+
await close(server);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function close(server: Server): Promise<void> {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
server.close((error) => {
|
|
150
|
+
if (error) {
|
|
151
|
+
reject(error);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
resolve();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function listen(app: express.Application): Promise<Server> {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const server = app.listen(0, "127.0.0.1", () => {
|
|
162
|
+
resolve(server);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function restoreEnv(name: string, value: string | undefined): void {
|
|
168
|
+
if (value === undefined) {
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- restores the process environment exactly
|
|
170
|
+
delete process.env[name];
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
process.env[name] = value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function serverBaseUrl(server: Server): string {
|
|
177
|
+
const address = server.address();
|
|
178
|
+
if (!address || typeof address === "string") {
|
|
179
|
+
throw new Error("Expected TCP listener.");
|
|
180
|
+
}
|
|
181
|
+
return `http://127.0.0.1:${String(address.port)}`;
|
|
182
|
+
}
|
|
@@ -44,9 +44,25 @@ describe("CLI passkey page", () => {
|
|
|
44
44
|
expect(res.headers.get("permissions-policy")).toContain(
|
|
45
45
|
"publickey-credentials-create=(self)",
|
|
46
46
|
);
|
|
47
|
-
expect(html).toContain("Continue with your passkey
|
|
47
|
+
expect(html).toContain("Continue with your passkey");
|
|
48
|
+
expect(html).toContain("--accent: #e5484d");
|
|
49
|
+
expect(html).not.toContain("radial-gradient");
|
|
50
|
+
expect(html).toContain('role="status" aria-live="polite"');
|
|
48
51
|
expect(html).toContain("resolveTrustedApiBase");
|
|
49
52
|
expect(html).toContain("Passkey link API origin is not trusted.");
|
|
53
|
+
expect(html).toContain("resolveCompletionCallback");
|
|
54
|
+
expect(html).toContain('callback.protocol !== "vex:" ||');
|
|
55
|
+
expect(html).toContain('callback.pathname !== "/complete" ||');
|
|
56
|
+
expect(html).toContain("window.history.replaceState");
|
|
57
|
+
expect(html).toContain('mode === "register-handoff"');
|
|
58
|
+
expect(html).toContain('mode === "authenticate-handoff"');
|
|
59
|
+
expect(html).toContain('"/auth/passkey/browser-registration/"');
|
|
60
|
+
expect(html).toContain('"/auth/passkey/browser-authentication/"');
|
|
61
|
+
expect(html).toContain("action.disabled = nextBusy || completed");
|
|
62
|
+
expect(html).toContain("showCompletion(");
|
|
63
|
+
expect(html).toContain("action.hidden = true");
|
|
64
|
+
expect(html).toContain('err.name === "NotAllowedError"');
|
|
65
|
+
expect(html).toContain("apiRequestError(response.status, payload)");
|
|
50
66
|
expect(html).toContain("navigator.credentials.create");
|
|
51
67
|
expect(html).toContain("navigator.credentials.get");
|
|
52
68
|
});
|