@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.
@@ -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
+ }
@@ -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
- const parsed = jwtUserPayload.safeParse(result);
111
- if (parsed.success) {
112
- req.user = parsed.data.user;
113
- if (parsed.data.exp !== undefined) {
114
- req.exp = parsed.data.exp;
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);