Files
EpicNext-Cms/src/actions/sessions.ts
T
openhands 7f39ba4257
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 54s
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

85 lines
2.4 KiB
TypeScript

"use server";
import { and, eq, sql } from "drizzle-orm";
import { auth, signOut } from "@/lib/auth";
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
import { db, PersonalAccessTokens, User } from "@/lib/db";
import { logger } from "@/lib/logger";
/**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
* revoke personal access tokens, revoke the game SSO ticket, then end the
* current browser session too.
*/
export async function signOutEverywhere(): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
await signOut({ redirectTo: "/login" });
return;
}
try {
await db
.update(User)
.set({
websiteJwtVersion: sql`${User.websiteJwtVersion} + 1`,
authTicket: "",
})
.where(eq(User.id, userId));
await invalidateJwtVersionCache(userId);
} catch (err) {
logger.warn("Failed to bump JWT version during sign-out-everywhere", {
userId,
error: err instanceof Error ? err.message : "Unknown",
});
}
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
try {
const scope = personalTokenScope(userId);
await db
.delete(PersonalAccessTokens)
.where(
and(
eq(PersonalAccessTokens.tokenableId, scope.tokenableId),
eq(PersonalAccessTokens.tokenableType, scope.tokenableType),
),
);
} catch (err) {
logger.warn(
"Failed to revoke personal access tokens during sign-out-everywhere",
{
userId,
error: err instanceof Error ? err.message : "Unknown",
},
);
}
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: "/" });
}