import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; const LOGIN_CODE_TTL_MS = 10 * 60 * 1_000; const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1_000; export const SESSION_COOKIE_NAME = "minecraft_account_session"; type ClaimMap = Record; function claimMap(value: unknown): ClaimMap { return value && typeof value === "object" && !Array.isArray(value) ? (value as ClaimMap) : {}; } function stringList(value: unknown) { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } function tokenClaims(token: string | undefined): ClaimMap { const payload = token?.split(".")[1]; if (!payload) return {}; try { return claimMap(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))); } catch { return {}; } } export function extractOidcRoles(input: { clientId: string; profile?: unknown; idToken?: string; accessToken?: string; }) { const sources = [claimMap(input.profile), tokenClaims(input.idToken), tokenClaims(input.accessToken)]; const roles: string[] = []; for (const source of sources) { roles.push(...stringList(source.roles), ...stringList(source.groups)); roles.push(...stringList(claimMap(source.realm_access).roles)); const clientRoles = claimMap(claimMap(source.resource_access)[input.clientId]); roles.push(...stringList(clientRoles.roles)); } return [...new Set(roles)]; } export interface DiscordIdentity { id: string; username: string; globalName?: string | null; } export interface PendingLoginCode { tokenHash: string; discordUserId: string; discordUsername: string; discordGlobalName: string | null; expiresAt: Date; createdAt: Date; } export interface AuthUser { id: string; discordUserId: string; discordUsername: string; firstName: string | null; } export interface AuthRepository { saveLoginCode(code: PendingLoginCode): Promise; exchangeLoginCode(input: { loginCodeHash: string; sessionTokenHash: string; sessionExpiresAt: Date; now: Date; }): Promise<{ user: AuthUser; isNewUser: boolean } | null>; } interface MagicLinkDependencies { repository: AuthRepository; appUrl: string; now?: () => Date; randomToken?: () => string; } interface ExchangeDependencies { repository: AuthRepository; now?: () => Date; randomToken?: () => string; } export class LoginRateLimitedError extends Error { constructor() { super("Please wait before requesting another login link."); this.name = "LoginRateLimitedError"; } } export class InvalidLoginCodeError extends Error { constructor() { super("The login link is invalid, expired, or has already been used."); this.name = "InvalidLoginCodeError"; } } export function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } export function resolveEffectiveGroup(explicitGroup: T | null, defaultGroup: T | null) { return explicitGroup ?? defaultGroup; } export function isGameNetworkAllowed( classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor", anonymizedNetworksAllowed: boolean, ) { return anonymizedNetworksAllowed || !["vpn", "proxy", "tor"].includes(classification); } export function gameAdmissionDenialReason( group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean } | null, classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor", scheduleAllowed = true, ) { if (!group?.accessEnabled) return "group_access_disabled" as const; if (!scheduleAllowed) return "schedule_disallowed" as const; if (!isGameNetworkAllowed(classification, group.anonymizedNetworksAllowed)) { return "anonymized_network_disallowed" as const; } return null; } export function verifyHashedToken(providedToken: string, expectedHash: string) { const provided = Buffer.from(hashToken(providedToken), "utf8"); const expected = Buffer.from(expectedHash, "utf8"); return provided.length === expected.length && timingSafeEqual(provided, expected); } export function isRequestTimestampFresh(occurredAt: Date, now: Date, maxClockSkewMs: number) { return ( Number.isFinite(occurredAt.getTime()) && Math.abs(now.getTime() - occurredAt.getTime()) <= maxClockSkewMs ); } function secureToken() { return randomBytes(32).toString("base64url"); } export async function createMagicLink( identity: DiscordIdentity, dependencies: MagicLinkDependencies, ) { const now = dependencies.now?.() ?? new Date(); const token = dependencies.randomToken?.() ?? secureToken(); const expiresAt = new Date(now.getTime() + LOGIN_CODE_TTL_MS); await dependencies.repository.saveLoginCode({ tokenHash: hashToken(token), discordUserId: identity.id, discordUsername: identity.username, discordGlobalName: identity.globalName ?? null, expiresAt, createdAt: now, }); const url = new URL("/auth/discord", dependencies.appUrl); url.searchParams.set("code", token); return { url: url.toString(), expiresAt }; } export async function exchangeMagicLink(code: string, dependencies: ExchangeDependencies) { if (!code) { throw new InvalidLoginCodeError(); } const now = dependencies.now?.() ?? new Date(); const sessionToken = dependencies.randomToken?.() ?? secureToken(); const result = await dependencies.repository.exchangeLoginCode({ loginCodeHash: hashToken(code), sessionTokenHash: hashToken(sessionToken), sessionExpiresAt: new Date(now.getTime() + SESSION_TTL_MS), now, }); if (!result) { throw new InvalidLoginCodeError(); } return { ...result, sessionToken, sessionExpiresAt: new Date(now.getTime() + SESSION_TTL_MS), }; }