From 7f39ba425758a5388f358c317e21b8449aa87ed6 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 29 Aug 2026 20:54:06 +0200 Subject: [PATCH] fix: harden SSO ticket flow and revoke tickets on logout Reuse the outstanding auth_ticket instead of minting a fresh one on every /client load, so reloading the page or opening a second tab no longer invalidates a game session that is still connecting. New tickets are minted with a guard against the previously-read value so concurrent launches converge on the same ticket. Revoke the auth_ticket when signing out (toolbar, header and sign-out everywhere) so a leaked ticket can no longer be replayed against the emulator, and prevent SSO leakage via referral by setting no-referrer on the client iframe. Strip all whitespace from the ticket prefix and build the launch URL through a tested helper that handles query strings, existing sso params and URL fragments correctly. --- src/actions/sessions.ts | 27 +++++++++- src/app/client/client-view.tsx | 10 ++-- src/components/top-header.tsx | 9 +--- src/lib/auth/sso-ticket.test.ts | 92 +++++++++++++++++++++++++++++---- src/lib/auth/sso-ticket.ts | 52 +++++++++++++++++-- src/lib/client-url.test.ts | 49 ++++++++++++++++++ src/lib/client-url.ts | 20 +++++++ 7 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 src/lib/client-url.test.ts create mode 100644 src/lib/client-url.ts diff --git a/src/actions/sessions.ts b/src/actions/sessions.ts index 33b5fcee..a43f7aca 100644 --- a/src/actions/sessions.ts +++ b/src/actions/sessions.ts @@ -9,7 +9,8 @@ import { logger } from "@/lib/logger"; /** * Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version, - * revoke personal access tokens, then end the current browser session too. + * revoke personal access tokens, revoke the game SSO ticket, then end the + * current browser session too. */ export async function signOutEverywhere(): Promise { const session = await auth(); @@ -24,6 +25,7 @@ export async function signOutEverywhere(): Promise { .update(User) .set({ websiteJwtVersion: sql`${User.websiteJwtVersion} + 1`, + authTicket: "", }) .where(eq(User.id, userId)); await invalidateJwtVersionCache(userId); @@ -57,3 +59,26 @@ export async function signOutEverywhere(): Promise { await signOut({ redirectTo: "/login?signedOutAll=1" }); } + +/** + * Log the current user out of the website AND revoke their game SSO ticket. + * + * Without revoking it, a ticket leaked via logs/history/referrers stays valid + * for the emulator after logout. Clearing the ticket makes any future client + * connection with it invalid. + */ +export async function signOutAndRevokeTicket(): Promise { + const session = await auth(); + const userId = Number(session?.user?.id); + if (Number.isInteger(userId) && userId > 0) { + try { + await db.update(User).set({ authTicket: "" }).where(eq(User.id, userId)); + } catch (err) { + logger.warn("Failed to revoke SSO ticket during sign out", { + userId, + error: err instanceof Error ? err.message : "Unknown", + }); + } + } + await signOut({ redirectTo: "/" }); +} diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx index 99cd0453..d84ffa09 100644 --- a/src/app/client/client-view.tsx +++ b/src/app/client/client-view.tsx @@ -1,7 +1,6 @@ "use client"; import { LogOut } from "lucide-react"; -import { signOut } from "next-auth/react"; import { type ReactNode, useCallback, @@ -10,7 +9,9 @@ import { useRef, useState, } from "react"; +import { signOutAndRevokeTicket } from "@/actions/sessions"; import Link from "@/components/link"; +import { buildClientLoginUrl } from "@/lib/client-url"; function ToolbarBtn({ onClick, @@ -94,9 +95,7 @@ export function ClientView({ return () => document.removeEventListener("fullscreenchange", onChange); }, []); - const base = clientUrl.replace(/(\?|&)sso=[^&]*/, ""); - const sep = base.includes("?") ? "&" : "?"; - const clientSrc = `${base}${sep}sso=${encodeURIComponent(ticket)}`; + const clientSrc = buildClientLoginUrl(clientUrl, ticket); const toolbarRef = useRef(null); const dragRef = useRef({ startX: 0, startY: 0, startTop: 0, startLeft: 0 }); @@ -361,7 +360,7 @@ export function ClientView({ signOut({ callbackUrl: "/" })} + onClick={() => void signOutAndRevokeTicket()} title="Logout" > @@ -382,6 +381,7 @@ export function ClientView({ }`} title={hotelName} allow="autoplay; gamepad; fullscreen" + referrerPolicy="no-referrer" loading="eager" /> diff --git a/src/components/top-header.tsx b/src/components/top-header.tsx index 6fb3ceb9..240014af 100644 --- a/src/components/top-header.tsx +++ b/src/components/top-header.tsx @@ -2,11 +2,11 @@ import { count, eq, inArray } from "drizzle-orm"; import Image from "next/image"; import type { Session } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { signOutAndRevokeTicket } from "@/actions/sessions"; import { HeaderUserIdentity } from "@/components/header-user-identity"; import Link from "@/components/link"; import { RoomQuickEntry } from "@/components/room-quick-entry"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; -import { signOut } from "@/lib/auth"; import { cached } from "@/lib/cache"; import { db, @@ -405,12 +405,7 @@ export async function TopHeader({ session }: { session: Session | null }) { "color-mix(in srgb, var(--color-text-muted) 12%, transparent)", }} /> -
{ - "use server"; - await signOut({ redirectTo: "/" }); - }} - > +