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
57 changes: 57 additions & 0 deletions src/auth/logout-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vite-plus/test";
import { readLogoutStep, redirectLogoutPage } from "./logout-page";

function pluginPage(body: string, cookies: string[] = []) {
const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
for (const cookie of cookies) headers.append("set-cookie", cookie);
return new Response(`<!doctype html><html><body>${body}</body></html>`, { headers });
}

describe("readLogoutStep", () => {
it("recognises each page the plugin renders", () => {
expect(readLogoutStep('<form method="post" data-oidc-logout-confirmation>')).toEqual({
status: "confirm",
});
expect(readLogoutStep('<p data-oidc-logout-state="logged-out">Logged out.</p>')).toEqual({
status: "done",
});
expect(
readLogoutStep(
'<p data-oidc-logout-state="logged-out">Logged out. The requested post-logout redirect was not registered.</p>',
),
).toEqual({ status: "done", redirect: "unregistered" });
expect(
readLogoutStep(
'<p data-oidc-logout-state="error">The logout confirmation is invalid or expired</p>',
),
).toEqual({ status: "error", reason: "expired" });
expect(readLogoutStep('<p data-oidc-logout-state="error">Something new</p>')).toEqual({
status: "error",
reason: "unknown",
});
expect(readLogoutStep("<p>Other</p>")).toBeNull();
});
});

describe("redirectLogoutPage", () => {
it("redirects to /logout and keeps every cookie", async () => {
const response = await redirectLogoutPage(
pluginPage("<form data-oidc-logout-confirmation></form>", ["a=1; Path=/", "b=2; Path=/x"]),
);
expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe("/logout");
expect(response.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/x"]);
});

it("carries the step in the query", async () => {
const response = await redirectLogoutPage(
pluginPage('<p data-oidc-logout-state="error">The id_token_hint is invalid</p>'),
);
expect(response.headers.get("location")).toBe("/logout?status=error&reason=token");
});

it("leaves other responses alone", async () => {
const json = Response.json({ ok: true });
expect(await redirectLogoutPage(json)).toBe(json);
});
});
51 changes: 51 additions & 0 deletions src/auth/logout-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* The OAuth provider answers browser logout requests with bare HTML pages it has no option to
* restyle. These helpers recognise those pages by their data attributes and send the browser to
* `/logout`, which shows the same step with the app's design. Every cookie the plugin set,
* including the signed confirmation state, travels with the redirect.
*/

export type LogoutStep =
| { status: "confirm" }
| { status: "done"; redirect?: "unregistered" }
| { status: "error"; reason: LogoutErrorReason };

export type LogoutErrorReason = "expired" | "client" | "token" | "unknown";

/** The form on `/logout` posts here; the plugin reads the confirmation from its signed cookie. */
export const LOGOUT_CONFIRM_PATH = "/api/auth/oauth2/end-session/confirm";

const errorReasons: [string, LogoutErrorReason][] = [
["confirmation is invalid or expired", "expired"],
["No active session", "expired"],
["logout client", "client"],
["client is not allowed", "client"],
["id_token_hint is invalid", "token"],
];

export function readLogoutStep(html: string): LogoutStep | null {
if (html.includes("data-oidc-logout-confirmation")) return { status: "confirm" };
if (html.includes('data-oidc-logout-state="logged-out"'))
return html.includes("not registered")
? { status: "done", redirect: "unregistered" }
: { status: "done" };
if (html.includes('data-oidc-logout-state="error"'))
return {
status: "error",
reason: errorReasons.find(([text]) => html.includes(text))?.[1] ?? "unknown",
};
return null;
}

export async function redirectLogoutPage(response: Response): Promise<Response> {
if (!response.headers.get("content-type")?.startsWith("text/html")) return response;
const step = readLogoutStep(await response.clone().text());
if (!step) return response;

const search = new URLSearchParams(step);
if (step.status === "confirm") search.delete("status");
const headers = new Headers({ "cache-control": "no-store" });
for (const cookie of response.headers.getSetCookie()) headers.append("set-cookie", cookie);
headers.set("location", search.size ? `/logout?${search}` : "/logout");
return new Response(null, { status: 303, headers });
}
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AccessRouteRouteImport } from './routes/access/route'
import { Route as ApplicationsRouteRouteImport } from './routes/applications/route'
import { Route as ConsentRouteImport } from './routes/consent'
import { Route as LogoutRouteImport } from './routes/logout'
import { Route as UsersRouteRouteImport } from './routes/users/route'
import { Route as AccessIndexRouteImport } from './routes/access/index'
import { Route as ApiIdentityRouteImport } from './routes/api/identity'
Expand Down Expand Up @@ -50,6 +51,11 @@ const ConsentRoute = ConsentRouteImport.update({
path: '/consent',
getParentRoute: () => rootRouteImport,
} as any)
const LogoutRoute = LogoutRouteImport.update({
id: '/logout',
path: '/logout',
getParentRoute: () => rootRouteImport,
} as any)
const UsersRouteRoute = UsersRouteRouteImport.update({
id: '/users',
path: '/users',
Expand Down Expand Up @@ -139,6 +145,7 @@ export interface FileRoutesByFullPath {
'/applications': typeof ApplicationsRouteRouteWithChildren
'/users': typeof UsersRouteRouteWithChildren
'/consent': typeof ConsentRoute
'/logout': typeof LogoutRoute
'/api/identity': typeof ApiIdentityRoute
'/applications/$clientId': typeof ApplicationsClientIdRoute
'/applications/new': typeof ApplicationsNewRoute
Expand All @@ -158,6 +165,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/consent': typeof ConsentRoute
'/logout': typeof LogoutRoute
'/api/identity': typeof ApiIdentityRoute
'/applications/$clientId': typeof ApplicationsClientIdRoute
'/applications/new': typeof ApplicationsNewRoute
Expand All @@ -181,6 +189,7 @@ export interface FileRoutesById {
'/applications': typeof ApplicationsRouteRouteWithChildren
'/users': typeof UsersRouteRouteWithChildren
'/consent': typeof ConsentRoute
'/logout': typeof LogoutRoute
'/api/identity': typeof ApiIdentityRoute
'/applications/$clientId': typeof ApplicationsClientIdRoute
'/applications/new': typeof ApplicationsNewRoute
Expand All @@ -205,6 +214,7 @@ export interface FileRouteTypes {
| '/applications'
| '/users'
| '/consent'
| '/logout'
| '/api/identity'
| '/applications/$clientId'
| '/applications/new'
Expand All @@ -224,6 +234,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/consent'
| '/logout'
| '/api/identity'
| '/applications/$clientId'
| '/applications/new'
Expand All @@ -246,6 +257,7 @@ export interface FileRouteTypes {
| '/applications'
| '/users'
| '/consent'
| '/logout'
| '/api/identity'
| '/applications/$clientId'
| '/applications/new'
Expand All @@ -269,6 +281,7 @@ export interface RootRouteChildren {
ApplicationsRouteRoute: typeof ApplicationsRouteRouteWithChildren
UsersRouteRoute: typeof UsersRouteRouteWithChildren
ConsentRoute: typeof ConsentRoute
LogoutRoute: typeof LogoutRoute
ApiIdentityRoute: typeof ApiIdentityRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiInternalAccessSnapshotRoute: typeof ApiInternalAccessSnapshotRoute
Expand Down Expand Up @@ -304,6 +317,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ConsentRouteImport
parentRoute: typeof rootRouteImport
}
'/logout': {
id: '/logout'
path: '/logout'
fullPath: '/logout'
preLoaderRoute: typeof LogoutRouteImport
parentRoute: typeof rootRouteImport
}
'/users': {
id: '/users'
path: '/users'
Expand Down Expand Up @@ -478,6 +498,7 @@ const rootRouteChildren: RootRouteChildren = {
ApplicationsRouteRoute: ApplicationsRouteRouteWithChildren,
UsersRouteRoute: UsersRouteRouteWithChildren,
ConsentRoute: ConsentRoute,
LogoutRoute: LogoutRoute,
ApiIdentityRoute: ApiIdentityRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiInternalAccessSnapshotRoute: ApiInternalAccessSnapshotRoute,
Expand Down
12 changes: 10 additions & 2 deletions src/routes/api/auth/$.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@/auth";
import { redirectLogoutPage } from "@/auth/logout-page";

async function handle(request: Request) {
const response = await auth.handler(request);
return new URL(request.url).pathname.includes("/oauth2/end-session")
? redirectLogoutPage(response)
: response;
}

export const Route = createFileRoute("/api/auth/$")({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
GET: ({ request }) => handle(request),
POST: ({ request }) => handle(request),
},
},
});
165 changes: 165 additions & 0 deletions src/routes/logout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { CircleCheck, Clock, LoaderCircle, LogOut, ShieldOff, TriangleAlert } from "lucide-react";
import { z } from "zod";
import { useViewer } from "@/components/access";
import { LoginLayout } from "@/components/login-page";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { UserAvatar } from "@/components/user-avatar";
import { LOGOUT_CONFIRM_PATH, type LogoutErrorReason } from "@/auth/logout-page";

const searchSchema = z.object({
status: z.enum(["confirm", "done", "error"]).catch("confirm").default("confirm"),
redirect: z.literal("unregistered").optional().catch(undefined),
reason: z.enum(["expired", "client", "token", "unknown"]).optional().catch(undefined),
});

export const Route = createFileRoute("/logout")({
head: () => ({ meta: [{ title: "Sign out · PoliNetwork Auth" }] }),
validateSearch: searchSchema,
component: Logout,
});

const errors: Record<LogoutErrorReason, { icon: typeof LogOut; title: string; body: string }> = {
expired: {
icon: Clock,
title: "This sign-out request has expired",
body: "Sign-out requests are only valid for a few minutes. Start again from the application you were using.",
},
client: {
icon: ShieldOff,
title: "This application can't sign you out",
body: "It isn't registered with PoliNetwork or isn't allowed to end sessions. You're still signed in.",
},
token: {
icon: TriangleAlert,
title: "We couldn't verify this request",
body: "The application sent a sign-in token we couldn't check. Start again from the application.",
},
unknown: {
icon: TriangleAlert,
title: "We couldn't sign you out",
body: "Something went wrong with this request. Start again from the application you were using.",
},
};

function Notice({
icon: Icon,
title,
children,
action,
}: {
icon: typeof LogOut;
title: string;
children: React.ReactNode;
action: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 px-6 py-10 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl border bg-background text-muted-foreground">
<Icon className="size-5" aria-hidden="true" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight text-balance">{title}</h1>
<p className="mt-2 text-sm leading-6 text-pretty text-muted-foreground">{children}</p>
</div>
{action}
</CardContent>
</Card>
);
}

function Logout() {
const viewer = useViewer();
const { status, redirect, reason } = Route.useSearch();
const [busy, setBusy] = useState(false);

if (status === "done") {
return (
<LoginLayout>
<Notice
icon={CircleCheck}
title="You're signed out"
action={
<Button asChild variant="outline" className="h-11 w-full bg-card">
<a href="/">Sign in again</a>
</Button>
}
>
{redirect === "unregistered"
? "The application's return address isn't registered, so we couldn't send you back. You can close this tab."
: "Your PoliNetwork session on this browser has ended. You can close this tab."}
</Notice>
</LoginLayout>
);
}

if (status === "error") {
const error = errors[reason ?? "unknown"];
return (
<LoginLayout>
<Notice
icon={error.icon}
title={error.title}
action={
<Button asChild variant="outline" className="h-11 w-full bg-card">
<a href="/">Go to your account</a>
</Button>
}
>
{error.body}
</Notice>
</LoginLayout>
);
}

return (
<LoginLayout>
<Card>
<CardContent className="space-y-6 px-6 pt-8 pb-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl border bg-background text-muted-foreground">
<LogOut className="size-5" aria-hidden="true" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Sign out?</h1>
<p className="mt-2 text-sm leading-6 text-pretty text-muted-foreground">
An application asked to end your PoliNetwork session on this browser.
</p>
</div>
</div>

{viewer && (
<div className="flex items-center gap-3 rounded-xl border bg-muted/40 p-3">
<div className="size-9 shrink-0 overflow-hidden rounded-full border bg-background text-xs font-semibold text-muted-foreground">
<UserAvatar name={viewer.user.name} image={viewer.user.image} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{viewer.user.name}</p>
<p className="truncate text-xs text-muted-foreground">{viewer.user.email}</p>
</div>
</div>
)}

{/* A native post, so the plugin's signed confirmation cookie goes with it. */}
<form
method="post"
action={LOGOUT_CONFIRM_PATH}
className="flex flex-col-reverse gap-3 sm:grid sm:grid-cols-2"
onSubmit={() => setBusy(true)}
>
<Button asChild variant="outline" className="h-11 bg-card">
<a href="/">Stay signed in</a>
</Button>
<Button type="submit" name="action" value="confirm" className="h-11" disabled={busy}>
{busy && <LoaderCircle className="animate-spin" aria-hidden="true" />}
Sign out
</Button>
</form>
</CardContent>
</Card>
</LoginLayout>
);
}
Loading