diff --git a/server/middleware/1.auth.ts b/server/middleware/1.auth.ts new file mode 100644 index 0000000..9bcc265 --- /dev/null +++ b/server/middleware/1.auth.ts @@ -0,0 +1,18 @@ +// Gates every /api/** route. Static SPA assets stay public. +export default defineEventHandler(async (event) => { + const path = getRequestURL(event).pathname; + if (!path.startsWith("/api/")) return; + + // CORS preflight is answered (and short-circuited) by 0.cors.ts before this runs. + const header = getHeader(event, "authorization") || ""; + const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + if (!token) { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + } + + try { + event.context.user = await getVerifier()(token); + } catch { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + } +}); diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 0000000..be339f6 --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,59 @@ +import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from "jose"; + +type KeyInput = Parameters[1]; + +export interface VerifierConfig { + issuer: string; + /** Token is accepted if its `aud` includes at least one of these. */ + audiences: string[]; + /** Token `sub` must be one of these (the single owner). */ + allowedSubs: string[]; + /** Test seam: resolve the verification key directly instead of fetching JWKS. */ + keyResolver?: (token: string) => Promise; +} + +/** + * Build a Zitadel access-token verifier. Verifies signature + issuer + expiry via JWKS, + * then enforces audience and owner-sub allowlists. Throws on any failure. + */ +export function makeVerifier(cfg: VerifierConfig) { + const jwks: JWTVerifyGetKey | null = cfg.keyResolver + ? null + : createRemoteJWKSet(new URL(`${cfg.issuer}/oauth/v2/keys`)); + + return async function verify(token: string): Promise { + const key: KeyInput = cfg.keyResolver ? await cfg.keyResolver(token) : (jwks as JWTVerifyGetKey); + const { payload } = await jwtVerify(token, key, { issuer: cfg.issuer }); + + const aud = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []; + if (!cfg.audiences.some((a) => aud.includes(a))) { + throw new Error("token audience not accepted"); + } + if (!cfg.allowedSubs.includes(String(payload.sub))) { + throw new Error("subject not allowed"); + } + return payload; + }; +} + +function splitCsv(v: unknown): string[] { + return String(v ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +let _cached: ReturnType | null = null; + +/** Process-wide verifier built from runtime config (Nitro server context). */ +export function getVerifier() { + if (!_cached) { + const c = useRuntimeConfig(); + _cached = makeVerifier({ + issuer: c.zitadelIssuer, + audiences: splitCsv(c.zitadelAudience), + allowedSubs: splitCsv(c.allowedUserIds), + }); + } + return _cached; +} diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..0250f53 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { SignJWT, generateKeyPair } from "jose"; +import { makeVerifier } from "../server/utils/auth"; + +const ISS = "https://auth.kuns.dev"; + +async function setup() { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const verify = makeVerifier({ + issuer: ISS, + audiences: ["aud-web", "proj-1"], + allowedSubs: ["owner-1"], + keyResolver: async () => publicKey, + }); + const sign = (claims: Record) => + new SignJWT(claims) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(ISS) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + return { verify, sign }; +} + +describe("token verification", () => { + it("accepts an owner token with a valid audience", async () => { + const { verify, sign } = await setup(); + const t = await sign({ sub: "owner-1", aud: ["aud-web"] }); + await expect(verify(t)).resolves.toMatchObject({ sub: "owner-1" }); + }); + + it("accepts when aud is a single string in the allowed set", async () => { + const { verify, sign } = await setup(); + const t = await sign({ sub: "owner-1", aud: "proj-1" }); + await expect(verify(t)).resolves.toMatchObject({ sub: "owner-1" }); + }); + + it("rejects a non-owner sub", async () => { + const { verify, sign } = await setup(); + const t = await sign({ sub: "intruder", aud: ["aud-web"] }); + await expect(verify(t)).rejects.toThrow(); + }); + + it("rejects a token with no accepted audience", async () => { + const { verify, sign } = await setup(); + const t = await sign({ sub: "owner-1", aud: ["other"] }); + await expect(verify(t)).rejects.toThrow(); + }); + + it("rejects a wrong issuer", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const verify = makeVerifier({ + issuer: ISS, + audiences: ["aud-web"], + allowedSubs: ["owner-1"], + keyResolver: async () => publicKey, + }); + const t = await new SignJWT({ sub: "owner-1", aud: ["aud-web"] }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("https://evil.example") + .setExpirationTime("5m") + .sign(privateKey); + await expect(verify(t)).rejects.toThrow(); + }); +});