Files
dmg c131465ff5
CI / validate (push) Successful in 6m15s
Release / release (push) Successful in 7m49s
feat(admission): add scheduled group access
2026-08-02 14:04:48 -04:00

202 lines
5.6 KiB
TypeScript

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<string, unknown>;
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<void>;
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<T>(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),
};
}