Files

93 lines
3.9 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The CSRF wall (spec §6.4): a state-changing call made with the session cookie carries the
// page's token, and a bearer or API-key caller is not asked for one, because no browser attaches
// those by itself.
import type { Principal } from "@conusai-kit/auth"
import { AppError } from "@conusai-kit/core"
import { beforeAll, describe, expect, it } from "vitest"
const session = (sessionId: string): Principal => ({
actor: { userId: "11111111-1111-1111-1111-111111111111" },
method: "session",
sessionId,
})
let csrfToken: (sessionId: string) => string
let requireCsrf: (request: Request, principal: Principal | null) => void
beforeAll(async () => {
// loadAuthConfig reads the environment at call time, so the secret is set before the import
// that uses it.
process.env.AUTH_SESSION_SECRET = "0123456789abcdef0123456789abcdef"
process.env.CONUSAI_PUBLIC_URL = "https://app.example.test"
process.env.OIDC_ISSUER = "https://id.example.test"
process.env.OIDC_WEB_CLIENT_ID = "web"
process.env.OIDC_AUDIENCE = "api"
const module = await import("../apps/web/src/server/csrf")
csrfToken = module.csrfToken
requireCsrf = module.requireCsrf
})
const post = (headers: Record<string, string>): Request =>
new Request("https://app.example.test/api/v1/tenancy.org.create", { method: "POST", headers })
describe("requireCsrf", () => {
it("accepts a POST carrying this session's token", () => {
const principal = session("session-1")
expect(() =>
requireCsrf(post({ "x-csrf-token": csrfToken("session-1"), "sec-fetch-site": "same-origin" }), principal),
).not.toThrow()
})
it("refuses a POST with no token", () => {
expect(() => requireCsrf(post({}), session("session-1"))).toThrow(AppError)
})
it("refuses another session's token", () => {
expect(() => requireCsrf(post({ "x-csrf-token": csrfToken("session-2") }), session("session-1"))).toThrow(
/x-csrf-token/,
)
})
it("refuses a cross-site POST even with a token", () => {
const headers = { "x-csrf-token": csrfToken("session-1"), "sec-fetch-site": "cross-site" }
expect(() => requireCsrf(post(headers), session("session-1"))).toThrow(/cross-site/)
})
it("asks nothing of a bearer or API-key caller", () => {
const bearer: Principal = { actor: { userId: "u" }, method: "bearer" }
const key: Principal = { actor: { userId: "u" }, method: "api-key", orgId: "o" }
expect(() => requireCsrf(post({}), bearer)).not.toThrow()
expect(() => requireCsrf(post({}), key)).not.toThrow()
})
it("asks every browser session method for its token, including dev-login and an external provider session", () => {
const dev: Principal = { actor: { userId: "u" }, method: "dev-login", sessionId: "session-1" }
const external: Principal = {
actor: { userId: "u" },
method: "external-session",
sessionRef: { provider: "convex-better-auth", opaqueId: "session-1" },
}
expect(() => requireCsrf(post({}), dev)).toThrow(/x-csrf-token/)
expect(() => requireCsrf(post({}), external)).toThrow(/x-csrf-token/)
// An external session's token is bound to its provider reference, never to a kit row of the same ID.
expect(() => requireCsrf(post({ "x-csrf-token": csrfToken("session-1") }), external)).toThrow(/x-csrf-token/)
})
it("fails closed on an external session disguised with a kit session row", () => {
const disguised: Principal = {
actor: { userId: "u" },
method: "external-session",
sessionId: "session-1",
sessionRef: { provider: "convex-better-auth", opaqueId: "session-1" },
}
expect(() => requireCsrf(post({ "x-csrf-token": csrfToken("session-1") }), disguised)).toThrow(AppError)
})
it("asks nothing of a GET", () => {
const request = new Request("https://app.example.test/api/health", { headers: {} })
expect(() => requireCsrf(request, session("session-1"))).not.toThrow()
})
})