fix(security): ship isolated JWT decorator hotfix

* fix(security): isolate dependency hotfix from github main

* fix(security): expose hotfix jwt decorators across routes

* test(e2e): restore stable app header selectors

* test(e2e): align planner and app shell checks

* test(e2e): add legacy settings page selectors

* test(e2e): align settings page contracts
This commit is contained in:
Daniel Volz
2026-04-05 14:49:50 +02:00
committed by GitHub
parent 6bba006e64
commit eec1653ff4
21 changed files with 229 additions and 248 deletions
+5 -170
View File
@@ -11,7 +11,6 @@
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
@@ -23,6 +22,8 @@
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.4",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.2",
"nodemailer": "^8.0.4",
"openid-client": "^6.8.2",
"sharp": "^0.34.5",
@@ -1361,29 +1362,6 @@
"helmet": "^8.0.0"
}
},
"node_modules/@fastify/jwt": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
"integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.2.0",
"@lukeed/ms": "^2.0.2",
"fast-jwt": "^6.0.2",
"fastify-plugin": "^5.0.1",
"steed": "^1.1.3"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -2907,18 +2885,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2979,12 +2945,6 @@
"node": "20 || >=22"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -3930,15 +3890,6 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -4116,21 +4067,6 @@
"rfdc": "^1.2.0"
}
},
"node_modules/fast-jwt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
@@ -4163,18 +4099,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastfall": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
"license": "MIT",
"dependencies": {
"reusify": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fastify": {
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
@@ -4224,16 +4148,6 @@
],
"license": "MIT"
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -4243,16 +4157,6 @@
"reusify": "^1.0.4"
}
},
"node_modules/fastseries": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.0",
"xtend": "^4.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4665,9 +4569,9 @@
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -5191,12 +5095,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -5231,15 +5129,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mnemonist": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
"integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5354,12 +5243,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -5704,26 +5587,6 @@
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
@@ -5752,12 +5615,6 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -6027,19 +5884,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
"license": "MIT",
"dependencies": {
"fastfall": "^1.5.0",
"fastparallel": "^2.2.0",
"fastq": "^1.3.0",
"fastseries": "^1.7.0",
"reusify": "^1.0.0"
}
},
"node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
@@ -6507,15 +6351,6 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+2 -1
View File
@@ -20,7 +20,6 @@
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
@@ -32,6 +31,8 @@
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.4",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.2",
"nodemailer": "^8.0.4",
"openid-client": "^6.8.2",
"sharp": "^0.34.5",
+3 -3
View File
@@ -5,7 +5,6 @@ import { resolve } from "node:path";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
@@ -16,6 +15,7 @@ import Fastify, { type FastifyInstance } from "fastify";
import { migrationsReady } from "./db/client.js";
import { getDataDir } from "./db/db-utils.js";
import { env } from "./plugins/env.js";
import { jwtPlugin } from "./plugins/jwt.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
@@ -189,7 +189,7 @@ export async function createApp(options?: {
// JWT plugin
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
await app.register(jwt, jwtConfig);
await app.register(jwtPlugin, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await registerApiDocs(app, opts.openApiDocsEnabled);
@@ -276,7 +276,7 @@ await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" })
// JWT plugin - only register with valid secret if auth is enabled
const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
await app.register(jwt, jwtConfig);
await app.register(jwtPlugin, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
await registerApiDocs(app, env.OPENAPI_DOCS_ENABLED);
+86
View File
@@ -0,0 +1,86 @@
import { TextEncoder } from "node:util";
import type { FastifyPluginAsync, FastifyRequest } from "fastify";
import fastifyPlugin from "fastify-plugin";
import { SignJWT, jwtVerify as verifyJwt } from "jose";
const JWT_ALGORITHM = "HS256";
const encoder = new TextEncoder();
export interface JwtPluginOptions {
secret: string;
cookie: {
cookieName: string;
signed: boolean;
};
}
export interface JwtSignOptions {
expiresIn?: string | number;
key?: string;
}
export interface JwtVerifyOptions {
key?: string;
}
function getKey(secret: string): Uint8Array {
return encoder.encode(secret);
}
function getTokenFromRequest(request: FastifyRequest, cookieName: string): string {
const authorization = request.headers.authorization;
if (authorization) {
const [scheme, rawToken] = authorization.split(" ");
if (scheme?.toLowerCase() === "bearer" && rawToken?.trim()) {
return rawToken.trim();
}
}
const token = request.cookies?.[cookieName];
if (typeof token === "string" && token.length > 0) {
return token;
}
throw new Error("JWT token missing");
}
const jwtPluginImpl: FastifyPluginAsync<JwtPluginOptions> = async (app, options) => {
const defaultKey = getKey(options.secret);
app.decorate("jwt", {
sign(payload: Record<string, unknown>, signOptions?: JwtSignOptions) {
const tokenBuilder = new SignJWT(payload).setProtectedHeader({ alg: JWT_ALGORITHM, typ: "JWT" }).setIssuedAt();
if (signOptions?.expiresIn != null) {
tokenBuilder.setExpirationTime(signOptions.expiresIn);
}
return tokenBuilder.sign(getKey(signOptions?.key ?? options.secret));
},
async verify<T extends Record<string, unknown>>(token: string, verifyOptions?: JwtVerifyOptions): Promise<T> {
const { payload } = await verifyJwt(token, getKey(verifyOptions?.key ?? options.secret), {
algorithms: [JWT_ALGORITHM],
typ: "JWT",
});
return payload as T;
},
});
app.decorateRequest("jwtVerify", async function jwtVerify<
T extends Record<string, unknown>,
>(this: FastifyRequest, verifyOptions?: JwtVerifyOptions): Promise<T> {
const token = getTokenFromRequest(this, options.cookie.cookieName);
const { payload } = await verifyJwt(token, verifyOptions?.key ? getKey(verifyOptions.key) : defaultKey, {
algorithms: [JWT_ALGORITHM],
typ: "JWT",
});
return payload as T;
});
};
export const jwtPlugin = fastifyPlugin(jwtPluginImpl, {
name: "medassist-jwt-plugin",
});
+8 -6
View File
@@ -357,7 +357,7 @@ export async function authRoutes(app: FastifyInstance) {
await db.update(users).set({ lastLoginAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
// Generate tokens
const accessToken = app.jwt.sign(
const accessToken = await app.jwt.sign(
{ sub: user.id, username: user.username },
{ expiresIn: `${accessTtlMinutes}m` }
);
@@ -371,7 +371,7 @@ export async function authRoutes(app: FastifyInstance) {
expiresAt: refreshExp,
});
const refreshToken = app.jwt.sign(
const refreshToken = await app.jwt.sign(
{ sub: user.id, jti: tokenId },
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
@@ -425,7 +425,7 @@ export async function authRoutes(app: FastifyInstance) {
try {
// Verify refresh token
const decoded = app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
const decoded = await app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
key: app.config.refreshSecret,
});
@@ -458,12 +458,12 @@ export async function authRoutes(app: FastifyInstance) {
});
// Generate new tokens
const newAccessToken = app.jwt.sign(
const newAccessToken = await app.jwt.sign(
{ sub: user.id, username: user.username },
{ expiresIn: `${accessTtlMinutes}m` }
);
const newRefreshToken = app.jwt.sign(
const newRefreshToken = await app.jwt.sign(
{ sub: user.id, jti: newTokenId },
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
@@ -498,7 +498,9 @@ export async function authRoutes(app: FastifyInstance) {
if (refreshTokenCookie) {
try {
const decoded = app.jwt.verify<{ jti: string }>(refreshTokenCookie, { key: app.config.refreshSecret });
const decoded = await app.jwt.verify<{ jti: string }>(refreshTokenCookie, {
key: app.config.refreshSecret,
});
// Revoke the refresh token
await db.update(refreshTokens).set({ revoked: true }).where(eq(refreshTokens.tokenId, decoded.jti));
+2 -2
View File
@@ -312,7 +312,7 @@ async function findOrCreateOIDCUser(
// JWT Token Generation (reused from auth.ts logic)
// =============================================================================
async function generateAccessToken(app: FastifyInstance, userId: number, username: string): Promise<string> {
return app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
return await app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
}
async function generateRefreshToken(
@@ -322,7 +322,7 @@ async function generateRefreshToken(
const tokenId = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
const refreshToken = app.jwt.sign(
const refreshToken = await app.jwt.sign(
{ sub: userId, jti: tokenId, type: "refresh" },
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
);
+2 -2
View File
@@ -3,11 +3,11 @@
*/
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
@@ -102,7 +102,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret-12345" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret-12345",
cookie: { cookieName: "access_token", signed: false },
});
+10 -10
View File
@@ -1,12 +1,12 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
@@ -77,8 +77,8 @@ async function createUser(username: string) {
return Number(result.rows[0].id);
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
@@ -230,7 +230,7 @@ describe("Real business route authz contracts", () => {
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
@@ -277,7 +277,7 @@ describe("Real business route authz contracts", () => {
it("scopes medication listing and export output to the authenticated user", async () => {
const ownerId = await createUser("owner-medications");
const otherId = await createUser("other-medications");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-medications");
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-medications");
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
await seedMedication({ userId: otherId, name: "Other User Med" });
@@ -306,7 +306,7 @@ describe("Real business route authz contracts", () => {
it("returns 404 when a user updates or deletes another user's medication", async () => {
const ownerId = await createUser("owner-update");
const otherId = await createUser("other-update");
const otherCookie = buildSessionCookie(app, otherId, "other-update");
const otherCookie = await buildSessionCookie(app, otherId, "other-update");
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
const updateResponse = await app.inject({
@@ -336,8 +336,8 @@ describe("Real business route authz contracts", () => {
it("scopes dose reads and writes to the authenticated user", async () => {
const ownerId = await createUser("owner-dose");
const otherId = await createUser("other-dose");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-dose");
const otherCookie = buildSessionCookie(app, otherId, "other-dose");
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-dose");
const otherCookie = await buildSessionCookie(app, otherId, "other-dose");
await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" });
await seedDose({ userId: otherId, doseId: "202-0-1760000000000" });
@@ -370,7 +370,7 @@ describe("Real business route authz contracts", () => {
it("enforces medication ownership on refill history and report generation", async () => {
const ownerId = await createUser("owner-refill");
const otherId = await createUser("other-refill");
const otherCookie = buildSessionCookie(app, otherId, "other-refill");
const otherCookie = await buildSessionCookie(app, otherId, "other-refill");
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
await seedRefill({ userId: ownerId, medicationId });
@@ -405,7 +405,7 @@ describe("Real business route authz contracts", () => {
it("scopes share people to the authenticated user's medications", async () => {
const ownerId = await createUser("owner-share");
const otherId = await createUser("other-share");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-share");
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-share");
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
+5 -5
View File
@@ -1,11 +1,11 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
@@ -110,8 +110,8 @@ async function _insertShareToken(userId: number, token: string, takenBy: string)
});
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
@@ -148,7 +148,7 @@ describe("Dose Tracking API", () => {
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
@@ -164,7 +164,7 @@ describe("Dose Tracking API", () => {
beforeEach(async () => {
await clearTables();
userId = await createUser("dose-test-user");
cookieHeader = buildSessionCookie(app, userId, "dose-test-user");
cookieHeader = await buildSessionCookie(app, userId, "dose-test-user");
});
describe("POST /doses/taken", () => {
+2 -2
View File
@@ -4,12 +4,12 @@
*/
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
@@ -253,7 +253,7 @@ describe("E2E Tests with Real Routes", () => {
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
+2 -2
View File
@@ -4,12 +4,12 @@
*/
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
@@ -208,7 +208,7 @@ describe("Integration Tests", () => {
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
@@ -1,12 +1,12 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => {
@@ -78,8 +78,8 @@ async function createUser(username: string) {
return Number(result.rows[0].id);
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
@@ -119,7 +119,7 @@ describe("Settings and API key security contracts", () => {
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
@@ -157,7 +157,7 @@ describe("Settings and API key security contracts", () => {
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { cookie: buildSessionCookie(app, userId, "settings-session-user") },
headers: { cookie: await buildSessionCookie(app, userId, "settings-session-user") },
});
expect(response.statusCode).toBe(200);
@@ -267,7 +267,7 @@ describe("Settings and API key security contracts", () => {
it("rotates API keys and does not leak raw tokens from the list endpoint", async () => {
const userId = await createUser("api-key-session-user");
const cookieHeader = buildSessionCookie(app, userId, "api-key-session-user");
const cookieHeader = await buildSessionCookie(app, userId, "api-key-session-user");
const firstCreate = await app.inject({
method: "POST",
@@ -331,7 +331,7 @@ describe("Settings and API key security contracts", () => {
it("returns 404 when deleting an API key owned by a different user", async () => {
const ownerUserId = await createUser("api-key-owner");
const otherUserId = await createUser("api-key-other-user");
const otherCookieHeader = buildSessionCookie(app, otherUserId, "api-key-other-user");
const otherCookieHeader = await buildSessionCookie(app, otherUserId, "api-key-other-user");
const keyId = await insertApiKey({
userId: ownerUserId,
@@ -363,7 +363,7 @@ describe("Settings and API key security contracts", () => {
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
headers: { cookie: buildSessionCookie(app, userId, "settings-email-recipient-user") },
headers: { cookie: await buildSessionCookie(app, userId, "settings-email-recipient-user") },
payload: { email: "missing@example.com" },
});
@@ -385,7 +385,7 @@ describe("Settings and API key security contracts", () => {
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
headers: { cookie: buildSessionCookie(app, userId, "settings-email-unconfirmed-user") },
headers: { cookie: await buildSessionCookie(app, userId, "settings-email-unconfirmed-user") },
payload: { email: "person@example.com" },
});
+2 -2
View File
@@ -6,7 +6,6 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
import { type Client, createClient } from "@libsql/client";
@@ -14,6 +13,7 @@ import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach } from "vitest";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Get migrations folder path
@@ -50,7 +50,7 @@ export async function buildTestApp(): Promise<TestContext> {
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
+6 -9
View File
@@ -1,5 +1,5 @@
import "fastify";
import "@fastify/jwt";
import type { JwtSignOptions, JwtVerifyOptions } from "../plugins/jwt.js";
// User type for authenticated requests
export interface AuthUser {
@@ -23,19 +23,16 @@ declare module "fastify" {
cookieOptions: import("@fastify/cookie").CookieSerializeOptions;
refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions;
};
jwt: {
sign(payload: Record<string, unknown>, options?: JwtSignOptions): Promise<string>;
verify<T extends Record<string, unknown>>(token: string, options?: JwtVerifyOptions): Promise<T>;
};
}
interface FastifyRequest {
user?: AuthUser | null;
authContext?: AuthContext;
correlationId?: string;
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
// Allow flexible payload for access and refresh tokens
payload: Record<string, unknown>;
user: Record<string, unknown>;
jwtVerify<T extends Record<string, unknown>>(options?: JwtVerifyOptions): Promise<T>;
}
}