feat: zitadel token auth middleware
This commit is contained in:
18
server/middleware/1.auth.ts
Normal file
18
server/middleware/1.auth.ts
Normal file
@@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
59
server/utils/auth.ts
Normal file
59
server/utils/auth.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from "jose";
|
||||||
|
|
||||||
|
type KeyInput = Parameters<typeof jwtVerify>[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<KeyInput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<JWTPayload> {
|
||||||
|
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<typeof makeVerifier> | 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;
|
||||||
|
}
|
||||||
65
tests/auth.test.ts
Normal file
65
tests/auth.test.ts
Normal file
@@ -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<string, unknown>) =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user