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: "/" }); - }} - > +