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
+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>;
}
}