Files
Epicnabbo-Catalogus-Updated…/src/lib/auth/sso-ticket.ts
T
openhands 7f39ba4257 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.
2026-08-29 20:54:06 +02:00

77 lines
2.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
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():
* $hotelName = Str::replace(' ', '', setting('hotel_name'));
* sprintf('%s-%s', $hotelName, Str::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.
*/
export function generateSsoTicket(hotelName: string): string {
const normalized = hotelName.replace(/[\s]+/g, "");
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
* 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(
userId: number,
hotelName: string,
ip: 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);
await db
.update(User)
.set({ authTicket: ticket, ipCurrent: ip })
.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;
}