Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/features/central-auth-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<FLEET_OIDC_ISSUER>/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
Expand Down
63 changes: 59 additions & 4 deletions web/src/app/api/auth/logout/route.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, string> = {};
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[] {
Expand All @@ -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");
Expand All @@ -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"));
Expand Down
37 changes: 36 additions & 1 deletion web/src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (`<issuer>/logout?client_id=<ours>`), 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}`);
Expand All @@ -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;
}