@vex-chat/spire 1.3.7 → 1.4.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/dist/Database.d.ts +36 -1
- package/dist/Database.js +109 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +7 -0
- package/dist/Spire.js +7 -0
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-03_passkeys.d.ts +8 -0
- package/dist/migrations/2026-05-03_passkeys.js +44 -0
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +52 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.d.ts +7 -0
- package/dist/server/passkey.js +449 -0
- package/dist/server/passkey.js.map +1 -0
- package/dist/server/passkeyDevices.d.ts +25 -0
- package/dist/server/passkeyDevices.js +118 -0
- package/dist/server/passkeyDevices.js.map +1 -0
- package/dist/server/user.d.ts +26 -1
- package/dist/server/user.js +84 -0
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +10 -0
- package/dist/types/express.js.map +1 -1
- package/package.json +6 -5
- package/src/Database.ts +134 -1
- package/src/Spire.ts +7 -0
- package/src/__tests__/passkeys.spec.ts +217 -0
- package/src/db/schema.ts +18 -1
- package/src/migrations/2026-05-03_passkeys.ts +52 -0
- package/src/server/index.ts +55 -6
- package/src/server/passkey.ts +589 -0
- package/src/server/passkeyDevices.ts +154 -0
- package/src/server/user.ts +105 -1
- package/src/types/express.ts +8 -0
|
@@ -0,0 +1,52 @@
|
|
|
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 { Kysely } from "kysely";
|
|
8
|
+
|
|
9
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
10
|
+
await db.schema.dropTable("passkeys").ifExists().execute();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Passkeys are an administrative second-class credential alongside
|
|
14
|
+
// `devices`. They cannot send/decrypt mail (no ratchet keys), but they
|
|
15
|
+
// can authenticate the owning user, list devices, delete devices, and
|
|
16
|
+
// approve/reject pending device-enrollment requests — i.e. account
|
|
17
|
+
// recovery and provisioning.
|
|
18
|
+
//
|
|
19
|
+
// `credentialID` is the WebAuthn credential id (base64url, opaque), and
|
|
20
|
+
// is unique across all passkeys. `publicKey` is the COSE_Key bytes
|
|
21
|
+
// returned by the authenticator, hex-encoded for storage. `signCount`
|
|
22
|
+
// is the WebAuthn signature counter (monotonic) used to detect cloned
|
|
23
|
+
// authenticators. `transports` is a comma-separated list of hints
|
|
24
|
+
// ("usb,nfc,ble,internal,hybrid") so subsequent assertions can request
|
|
25
|
+
// the right transport.
|
|
26
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
27
|
+
await db.schema
|
|
28
|
+
.createTable("passkeys")
|
|
29
|
+
.ifNotExists()
|
|
30
|
+
.addColumn("passkeyID", "varchar(255)", (cb) => cb.primaryKey())
|
|
31
|
+
.addColumn("userID", "varchar(255)", (cb) => cb.notNull())
|
|
32
|
+
.addColumn("name", "varchar(255)", (cb) => cb.notNull())
|
|
33
|
+
.addColumn("credentialID", "varchar(512)", (cb) =>
|
|
34
|
+
cb.unique().notNull(),
|
|
35
|
+
)
|
|
36
|
+
.addColumn("publicKey", "text", (cb) => cb.notNull())
|
|
37
|
+
.addColumn("algorithm", "integer", (cb) => cb.notNull())
|
|
38
|
+
.addColumn("signCount", "integer", (cb) => cb.notNull().defaultTo(0))
|
|
39
|
+
.addColumn("transports", "varchar(255)", (cb) =>
|
|
40
|
+
cb.notNull().defaultTo(""),
|
|
41
|
+
)
|
|
42
|
+
.addColumn("createdAt", "text", (cb) => cb.notNull())
|
|
43
|
+
.addColumn("lastUsedAt", "text")
|
|
44
|
+
.execute();
|
|
45
|
+
|
|
46
|
+
await db.schema
|
|
47
|
+
.createIndex("passkeys_userID_idx")
|
|
48
|
+
.ifNotExists()
|
|
49
|
+
.on("passkeys")
|
|
50
|
+
.column("userID")
|
|
51
|
+
.execute();
|
|
52
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ import { errorHandler } from "./errors.ts";
|
|
|
35
35
|
import { getFileRouter } from "./file.ts";
|
|
36
36
|
import { getInviteRouter } from "./invite.ts";
|
|
37
37
|
import { setupDocs } from "./openapi.ts";
|
|
38
|
+
import { getPasskeyRouter } from "./passkey.ts";
|
|
39
|
+
import { getPasskeyDeviceRouter } from "./passkeyDevices.ts";
|
|
38
40
|
import {
|
|
39
41
|
hasAnyPermission,
|
|
40
42
|
hasPermission,
|
|
@@ -95,6 +97,14 @@ const jwtDevicePayload = z.object({
|
|
|
95
97
|
}),
|
|
96
98
|
});
|
|
97
99
|
|
|
100
|
+
const jwtPasskeyPayload = z.object({
|
|
101
|
+
passkey: z.object({
|
|
102
|
+
passkeyID: z.string(),
|
|
103
|
+
}),
|
|
104
|
+
scope: z.literal("passkey"),
|
|
105
|
+
user: UserSchema,
|
|
106
|
+
});
|
|
107
|
+
|
|
98
108
|
/** Extract Bearer token from Authorization header. */
|
|
99
109
|
function extractBearer(req: express.Request): null | string {
|
|
100
110
|
const header = req.headers.authorization;
|
|
@@ -107,13 +117,27 @@ const checkAuth: express.RequestHandler = (req, _res, next) => {
|
|
|
107
117
|
if (token) {
|
|
108
118
|
try {
|
|
109
119
|
const result = jwt.verify(token, getJwtSecret());
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
// Passkey-scoped JWTs share the bearer slot with regular
|
|
121
|
+
// user JWTs but carry a `scope: "passkey"` discriminator
|
|
122
|
+
// and a `passkey` claim. Populate `req.passkey` and the
|
|
123
|
+
// user (so route guards using `req.user` still pass) but
|
|
124
|
+
// never `req.device`. The actual privilege gate lives at
|
|
125
|
+
// the route level: passkey-only routes require
|
|
126
|
+
// `req.passkey`, device-only routes require `req.device`.
|
|
127
|
+
const passkeyParsed = jwtPasskeyPayload.safeParse(result);
|
|
128
|
+
if (passkeyParsed.success) {
|
|
129
|
+
req.user = passkeyParsed.data.user;
|
|
130
|
+
req.passkey = passkeyParsed.data.passkey;
|
|
116
131
|
req.bearerToken = token;
|
|
132
|
+
} else {
|
|
133
|
+
const parsed = jwtUserPayload.safeParse(result);
|
|
134
|
+
if (parsed.success) {
|
|
135
|
+
req.user = parsed.data.user;
|
|
136
|
+
if (parsed.data.exp !== undefined) {
|
|
137
|
+
req.exp = parsed.data.exp;
|
|
138
|
+
}
|
|
139
|
+
req.bearerToken = token;
|
|
140
|
+
}
|
|
117
141
|
}
|
|
118
142
|
} catch {
|
|
119
143
|
// Token verification failed — continue without auth
|
|
@@ -147,6 +171,21 @@ export const protect: express.RequestHandler = (req, res, next) => {
|
|
|
147
171
|
next();
|
|
148
172
|
};
|
|
149
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Restrict a route to passkey-scoped JWTs (used by the parallel
|
|
176
|
+
* `/user/:id/passkey/devices/...` admin/recovery routes). A
|
|
177
|
+
* passkey-authenticated caller can list devices, delete devices, and
|
|
178
|
+
* approve/reject pending enrollments — and nothing else.
|
|
179
|
+
*/
|
|
180
|
+
export const protectPasskey: express.RequestHandler = (req, res, next) => {
|
|
181
|
+
if (!req.user || !req.passkey) {
|
|
182
|
+
res.sendStatus(401);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
next();
|
|
187
|
+
};
|
|
188
|
+
|
|
150
189
|
export const msgpackParser: express.RequestHandler = (req, res, next) => {
|
|
151
190
|
if (req.is("application/msgpack")) {
|
|
152
191
|
try {
|
|
@@ -185,6 +224,8 @@ export const initApp = (
|
|
|
185
224
|
const fileRouter = getFileRouter(db);
|
|
186
225
|
const avatarRouter = getAvatarRouter();
|
|
187
226
|
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
227
|
+
const passkeyRouter = getPasskeyRouter(db);
|
|
228
|
+
const passkeyDeviceRouter = getPasskeyDeviceRouter(db, notify);
|
|
188
229
|
|
|
189
230
|
// MIDDLEWARE
|
|
190
231
|
// Global per-IP rate limit is the FIRST middleware so a flooded
|
|
@@ -854,6 +895,14 @@ export const initApp = (
|
|
|
854
895
|
);
|
|
855
896
|
|
|
856
897
|
// COMPLEX RESOURCES
|
|
898
|
+
// Passkey routes are mounted at the root since they live under
|
|
899
|
+
// both `/user/:id/passkeys/...` (registration / list / delete by
|
|
900
|
+
// an authenticated device) and `/auth/passkey/...` (public
|
|
901
|
+
// sign-in). The router itself defines the full path on each
|
|
902
|
+
// route handler.
|
|
903
|
+
api.use(passkeyRouter);
|
|
904
|
+
api.use(passkeyDeviceRouter);
|
|
905
|
+
|
|
857
906
|
api.use("/user", userRouter);
|
|
858
907
|
|
|
859
908
|
api.use("/file", fileRouter);
|