diff --git a/docs/features/central-auth-integration.md b/docs/features/central-auth-integration.md index 0d35a3e2..ab9985a4 100644 --- a/docs/features/central-auth-integration.md +++ b/docs/features/central-auth-integration.md @@ -41,6 +41,15 @@ error) never auto-starts, so the local admin password stays one URL away when Auth is unreachable. Auth supports `prompt=none` from `1521760`+; an older Auth ignores it and shows its own login form instead. +Logging out of Fleet ends the central session too. `POST /api/auth/logout` +clears Fleet's cookies as before and, when the session was minted through +central OIDC, sends the browser to `/logout?client_id=…` +(Auth's RP-initiated logout). Auth revokes every central session of the +account, fans a back-channel logout out to every registered application, and +lands on its own login page. Without that step the very next visit would sign +the user back in silently. Password-sourced Fleet sessions keep landing on +`/login`. + Register the exact callback and signed logout endpoint on Auth: ```text diff --git a/web/src/app/api/auth/logout/route.test.ts b/web/src/app/api/auth/logout/route.test.ts index 7e205bf6..2d7c100c 100644 --- a/web/src/app/api/auth/logout/route.test.ts +++ b/web/src/app/api/auth/logout/route.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; +import { createOidcSessionToken, createSessionToken } from "@/app/lib/auth"; // Logout clears BOTH session cookies (elcano_session + the shared elcano_auth) // and returns the user to chat's own /login — not the auth service. Deletions @@ -9,10 +10,12 @@ import { NextRequest } from "next/server"; import { POST } from "./route"; -function postReq(origin: string | null) { +function postReq(origin: string | null, sessionCookie?: string) { const headers: Record = {}; if (origin) headers["origin"] = origin; - return new NextRequest("https://chat.elcanotek.com/api/auth/logout", { method: "POST", headers }); + const req = new NextRequest("https://chat.elcanotek.com/api/auth/logout", { method: "POST", headers }); + if (sessionCookie) req.cookies.set("elcano_session", sessionCookie); + return req; } function cleared(res: Response, name: string): string[] { @@ -32,12 +35,19 @@ describe("POST /api/auth/logout", () => { process.env = originalEnv; }); - it("clears both cookies and redirects to chat's /login", async () => { + it("clears both cookies and redirects to chat's manual /login", async () => { process.env.AUTH_COOKIE_DOMAIN = "elcanotek.com"; const res = await POST(postReq("https://chat.elcanotek.com")); expect(res.status).toBe(303); - expect(res.headers.get("location")).toBe("https://chat.elcanotek.com/login"); + // ?manual=1: the card with both options and no silent SSO attempt, so a + // logout cannot be undone by auto-start while an Auth cookie exists. + expect(res.headers.get("location")).toBe("https://chat.elcanotek.com/login?manual=1"); + expect(res.headers.get("cache-control")).toBe("no-store"); + // An in-flight SSO transaction is abandoned too. + for (const name of ["fleet_oidc_state", "fleet_oidc_nonce", "fleet_oidc_verifier"]) { + expect(cleared(res, name)).toHaveLength(1); + } // chat's own HMAC cookie, host-only (no domain). const session = cleared(res, "elcano_session"); @@ -56,6 +66,51 @@ describe("POST /api/auth/logout", () => { expect(elcano.filter((c) => !/Domain=/i.test(c))).toHaveLength(1); }); + // A central (OIDC) session has an identity-provider session behind it; with + // silent auto-start the next visit would sign the user straight back in + // unless that session ends too. So logout hands the browser to the + // provider's RP-initiated logout, after clearing Fleet's own cookie. + it("sends an OIDC session to the provider's logout endpoint after clearing the cookie", async () => { + process.env.APP_SESSION_SECRET = "test-session-secret-please-ignore"; + process.env.FLEET_OIDC_ISSUER = "https://auth.example.com"; + process.env.FLEET_OIDC_CLIENT_ID = "fleet"; + process.env.FLEET_OIDC_CLIENT_SECRET = "secret-xyz"; + const token = await createOidcSessionToken("alice@example.com", "epoch-1", "https://auth.example.com", "account-1"); + + const res = await POST(postReq("https://chat.elcanotek.com", token)); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("https://auth.example.com/logout?client_id=fleet"); + expect(cleared(res, "elcano_session")).toHaveLength(1); + }); + + it("still clears the cookies and lands locally when the OIDC issuer is malformed", async () => { + process.env.APP_SESSION_SECRET = "test-session-secret-please-ignore"; + process.env.FLEET_OIDC_ISSUER = "not a url"; + process.env.FLEET_OIDC_CLIENT_ID = "fleet"; + process.env.FLEET_OIDC_CLIENT_SECRET = "secret-xyz"; + const token = await createOidcSessionToken("alice@example.com", "epoch-1", "https://auth.example.com", "account-1"); + + const res = await POST(postReq("https://chat.elcanotek.com", token)); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("https://chat.elcanotek.com/login?manual=1"); + expect(cleared(res, "elcano_session")).toHaveLength(1); + }); + + it("keeps a password session on Fleet's own /login, even with OIDC configured", async () => { + process.env.APP_SESSION_SECRET = "test-session-secret-please-ignore"; + process.env.FLEET_OIDC_ISSUER = "https://auth.example.com"; + process.env.FLEET_OIDC_CLIENT_ID = "fleet"; + process.env.FLEET_OIDC_CLIENT_SECRET = "secret-xyz"; + const token = await createSessionToken("alice@example.com", "epoch-1"); + + const res = await POST(postReq("https://chat.elcanotek.com", token)); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("https://chat.elcanotek.com/login?manual=1"); + }); + it("sends only the host-only elcano_auth deletion when AUTH_COOKIE_DOMAIN is unset (dev)", async () => { delete process.env.AUTH_COOKIE_DOMAIN; const res = await POST(postReq("https://chat.elcanotek.com")); diff --git a/web/src/app/api/auth/logout/route.ts b/web/src/app/api/auth/logout/route.ts index 87e8cfbe..8d7c14c7 100644 --- a/web/src/app/api/auth/logout/route.ts +++ b/web/src/app/api/auth/logout/route.ts @@ -5,8 +5,10 @@ import { getRedirectUrl, getSessionCookieName, isSecureRequest, + verifySessionToken, } from "@/app/lib/auth"; import { verifyOrigin } from "@/app/lib/csrf"; +import { getOidcConfig, OIDC_NONCE_COOKIE, OIDC_STATE_COOKIE, OIDC_VERIFIER_COOKIE } from "@/app/lib/oidc"; /** * POST /api/auth/logout @@ -22,13 +24,43 @@ import { verifyOrigin } from "@/app/lib/csrf"; * cookie lives on the shared parent domain (AUTH_COOKIE_DOMAIN) that chat's * host belongs to — and deleting the shared cookie signs the user out of the * other Elcano services too, which is the expected meaning of "log out". + * + * A session minted through central OIDC (source "oidc") also has a live + * session on the identity provider behind it. Clearing Fleet's cookie alone + * would leave that in place, and with FLEET_OIDC_AUTO_START the next visit + * would silently sign the user straight back in, so "log out" would appear to + * do nothing. For those sessions the browser is sent to the provider's + * RP-initiated logout (`/logout?client_id=`), which on Elcano + * Auth ends the central session, fans a back-channel logout out to every + * application, and lands on Auth's login page. Every other case (password + * session, no or unverifiable cookie) lands on /login?manual=1: the card with + * both options, and no silent SSO attempt, so a logout can never be undone by + * auto-start while an Auth cookie happens to exist. The in-progress OIDC + * transaction cookies are cleared too, so a callback still in flight cannot + * mint a fresh session after the user asked to leave. */ export async function POST(request: NextRequest) { const csrf = verifyOrigin(request); if (!csrf.ok) return csrf.response; + const session = await verifySessionToken(request.cookies.get(getSessionCookieName())?.value); + const oidc = getOidcConfig(); + let landing: URL | string = getRedirectUrl(request, "/login?manual=1"); + if (session?.source === "oidc" && oidc) { + // getOidcConfig only checks the issuer is non-empty; a malformed value + // must not turn logout into a 500 that leaves every cookie in place. + try { + const endSession = new URL("/logout", oidc.issuer); + endSession.searchParams.set("client_id", oidc.clientId); + landing = endSession.toString(); + } catch { + landing = getRedirectUrl(request, "/login?manual=1"); + } + } + const secure = isSecureRequest(request); - const res = NextResponse.redirect(getRedirectUrl(request, "/login"), { status: 303 }); + const res = NextResponse.redirect(landing, { status: 303 }); + res.headers.set("Cache-Control", "no-store"); const attrs = `Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; res.headers.append("Set-Cookie", `${getSessionCookieName()}=; ${attrs}`); @@ -49,6 +81,9 @@ export async function POST(request: NextRequest) { ); } res.headers.append("Set-Cookie", `${getElcanoCookieName()}=; ${attrs}`); + for (const name of [OIDC_STATE_COOKIE, OIDC_NONCE_COOKIE, OIDC_VERIFIER_COOKIE]) { + res.headers.append("Set-Cookie", `${name}=; ${attrs}`); + } return res; }