202 lines
5.6 KiB
TypeScript
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),
|
|
};
|
|
}
|