fix: harden SSO ticket flow and revoke tickets on logout
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 54s

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.
This commit is contained in:
openhands committed 2026-08-29 20:54:06 +02:00
1 parent ca59a1065f
commit 7f39ba4257
7 files changed
+231 -28

No files matched your search

+26 -1
View File
@@ -9,7 +9,8 @@ import { logger } from "@/lib/logger";
/** /**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version, * 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<void> { export async function signOutEverywhere(): Promise<void> {
const session = await auth(); const session = await auth();
@@ -24,6 +25,7 @@ export async function signOutEverywhere(): Promise<void> {
.update(User) .update(User)
.set({ .set({
websiteJwtVersion: sql`${User.websiteJwtVersion} + 1`, websiteJwtVersion: sql`${User.websiteJwtVersion} + 1`,
authTicket: "",
}) })
.where(eq(User.id, userId)); .where(eq(User.id, userId));
await invalidateJwtVersionCache(userId); await invalidateJwtVersionCache(userId);
@@ -57,3 +59,26 @@ export async function signOutEverywhere(): Promise<void> {
await signOut({ redirectTo: "/login?signedOutAll=1" }); 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<void> {
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: "/" });
}
+5 -5
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { signOut } from "next-auth/react";
import { import {
type ReactNode, type ReactNode,
useCallback, useCallback,
@@ -10,7 +9,9 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { signOutAndRevokeTicket } from "@/actions/sessions";
import Link from "@/components/link"; import Link from "@/components/link";
import { buildClientLoginUrl } from "@/lib/client-url";
function ToolbarBtn({ function ToolbarBtn({
onClick, onClick,
@@ -94,9 +95,7 @@ export function ClientView({
return () => document.removeEventListener("fullscreenchange", onChange); return () => document.removeEventListener("fullscreenchange", onChange);
}, []); }, []);
const base = clientUrl.replace(/(\?|&)sso=[^&]*/, ""); const clientSrc = buildClientLoginUrl(clientUrl, ticket);
const sep = base.includes("?") ? "&" : "?";
const clientSrc = `${base}${sep}sso=${encodeURIComponent(ticket)}`;
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ startX: 0, startY: 0, startTop: 0, startLeft: 0 }); const dragRef = useRef({ startX: 0, startY: 0, startTop: 0, startLeft: 0 });
@@ -361,7 +360,7 @@ export function ClientView({
</svg> </svg>
</div> </div>
<ToolbarBtn <ToolbarBtn
onClick={() => signOut({ callbackUrl: "/" })} onClick={() => void signOutAndRevokeTicket()}
title="Logout" title="Logout"
> >
<LogOut size={14} /> <LogOut size={14} />
@@ -382,6 +381,7 @@ export function ClientView({
}`} }`}
title={hotelName} title={hotelName}
allow="autoplay; gamepad; fullscreen" allow="autoplay; gamepad; fullscreen"
referrerPolicy="no-referrer"
loading="eager" loading="eager"
/> />
</div> </div>
+2 -7
View File
@@ -2,11 +2,11 @@ import { count, eq, inArray } from "drizzle-orm";
import Image from "next/image"; import Image from "next/image";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { signOutAndRevokeTicket } from "@/actions/sessions";
import { HeaderUserIdentity } from "@/components/header-user-identity"; import { HeaderUserIdentity } from "@/components/header-user-identity";
import Link from "@/components/link"; import Link from "@/components/link";
import { RoomQuickEntry } from "@/components/room-quick-entry"; import { RoomQuickEntry } from "@/components/room-quick-entry";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { signOut } from "@/lib/auth";
import { cached } from "@/lib/cache"; import { cached } from "@/lib/cache";
import { import {
db, db,
@@ -405,12 +405,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)", "color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}} }}
/> />
<form <form action={signOutAndRevokeTicket}>
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<button <button
type="submit" type="submit"
role="menuitem" role="menuitem"
File diff suppressed because it is too large. Load diff
+48 -4
View File
@@ -1,32 +1,76 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
/** /**
* Build the SSO ticket exactly like AtomCMS's User::ssoTicket(): * Build the SSO ticket exactly like AtomCMS's User::ssoTicket():
* $hotelName = Str::replace(' ', '', setting('hotel_name')); * $hotelName = Str::replace(' ', '', setting('hotel_name'));
* sprintf('%s-%s', $hotelName, Str::uuid()); * sprintf('%s-%s', $hotelName, Str::uuid());
* i.e. the hotel name with ALL spaces removed, a dash, then a v4 UUID. * i.e. the hotel name with ALL whitespace removed, a dash, then a v4 UUID.
* The emulator validates this exact value when the Nitro/Flash client connects. * The emulator validates this exact value when the Nitro/Flash client connects.
*/ */
export function generateSsoTicket(hotelName: string): string { export function generateSsoTicket(hotelName: string): string {
const normalized = hotelName.replace(/ /g, ""); const normalized = hotelName.replace(/[\s]+/g, "");
return `${normalized}-${randomUUID()}`; return `${normalized}-${randomUUID()}`;
} }
/**
* True when the ticket is exactly '{hotelName-no-spaces}-{v4 UUID}', i.e. the
* shape the emulator accepts for this hotel. Anything else is treated as stale
* and replaced with a fresh ticket.
*/
export function isValidTicketShape(hotelName: string, ticket: string): boolean {
const normalized = hotelName.replace(/[\s]+/g, "");
return (
ticket.length > normalized.length + 1 &&
ticket.startsWith(`${normalized}-`) &&
UUID_RE.test(ticket.slice(normalized.length + 1))
);
}
async function readTicket(userId: number): Promise<string | null> {
const rows = await db
.select({ authTicket: User.authTicket })
.from(User)
.where(eq(User.id, userId))
.limit(1);
return rows[0]?.authTicket ?? null;
}
/** /**
* Generate a ticket and persist it like AtomCMS: writes auth_ticket AND * Generate a ticket and persist it like AtomCMS: writes auth_ticket AND
* ip_current on the user, then returns the ticket for the client launcher. * ip_current on the user, then returns the ticket for the client launcher.
*
* The outstanding ticket is REUSED instead of being regenerated on every page
* load, so reloading /client or opening a second tab no longer invalidates a
* game session that is still connecting. When a new ticket is minted, the
* write is guarded against the previously-read value so concurrent launches
* all receive the same ticket rather than clobbering each other.
*/ */
export async function issueSsoTicket( export async function issueSsoTicket(
userId: number, userId: number,
hotelName: string, hotelName: string,
ip: string, ip: string,
): Promise<string> { ): Promise<string> {
const current = await readTicket(userId);
if (current && isValidTicketShape(hotelName, current)) {
await db.update(User).set({ ipCurrent: ip }).where(eq(User.id, userId));
return current;
}
const ticket = generateSsoTicket(hotelName); const ticket = generateSsoTicket(hotelName);
await db await db
.update(User) .update(User)
.set({ authTicket: ticket, ipCurrent: ip }) .set({ authTicket: ticket, ipCurrent: ip })
.where(eq(User.id, userId)); .where(and(eq(User.id, userId), eq(User.authTicket, current ?? "")));
// Return whatever actually landed in the DB so every concurrent launcher
// sends the exact same ticket to the emulator.
const stored = await readTicket(userId);
if (stored && isValidTicketShape(hotelName, stored)) return stored;
return ticket; return ticket;
} }
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { buildClientLoginUrl } from "./client-url";
describe("buildClientLoginUrl", () => {
it("appends sso to a clean URL", () => {
expect(buildClientLoginUrl("https://game.hotel.nl", "Hotel-uuid")).toBe(
"https://game.hotel.nl?sso=Hotel-uuid",
);
});
it("uses & when a query string already exists", () => {
expect(
buildClientLoginUrl(
"https://game.hotel.nl/nitro?mode=nostrip&debug=1",
"Hotel-uuid",
),
).toBe("https://game.hotel.nl/nitro?mode=nostrip&debug=1&sso=Hotel-uuid");
});
it("replaces an existing sso param", () => {
expect(
buildClientLoginUrl("https://game.hotel.nl/nitro?sso=old&mode=1", "new"),
).toBe("https://game.hotel.nl/nitro?mode=1&sso=new");
expect(
buildClientLoginUrl("https://game.hotel.nl/nitro?sso=old", "new"),
).toBe("https://game.hotel.nl/nitro?sso=new");
});
it("keeps a fragment after the sso param", () => {
expect(
buildClientLoginUrl("https://game.hotel.nl/nitro#entry", "h-u"),
).toBe("https://game.hotel.nl/nitro?sso=h-u#entry");
expect(
buildClientLoginUrl("https://game.hotel.nl/nitro?sso=old#entry", "h-u"),
).toBe("https://game.hotel.nl/nitro?sso=h-u#entry");
});
it("leaves any existing non-sso fragment intact", () => {
expect(
buildClientLoginUrl("https://game.hotel.nl/nitro?tok=1#frag", "th"),
).toBe("https://game.hotel.nl/nitro?tok=1&sso=th#frag");
});
it("encodes the ticket value", () => {
expect(buildClientLoginUrl("https://game.hotel.nl/", "Ḟancy-ü")).toBe(
"https://game.hotel.nl/?sso=%E1%B8%9Eancy-%C3%BC",
);
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Build the Nitro launch URL with the SSO ticket as the final query param.
*
* Any existing `sso` param is removed first (both from the query string and
* from a `#fragment`), the ticket is appended as the last query param, and a
* `#fragment` is kept after it so the ticket always reaches the server.
*/
export function buildClientLoginUrl(clientUrl: string, ticket: string): string {
const [pathPart = "", ...fragments] = clientUrl.split("#");
const fragment = fragments.join("#");
const path = pathPart
.replace(/([?&])sso=[^&#]*/gi, "$1")
.replace(/([?&])&+/g, "$1")
.replace(/[?&]$/, "");
const sep = path.includes("?") ? "&" : "?";
const query = `${path}${sep}sso=${encodeURIComponent(ticket)}`;
return fragment ? `${query}#${fragment}` : query;
}