feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@minecraft-account-manager/auth",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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 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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMagicLink,
|
||||
exchangeMagicLink,
|
||||
InvalidLoginCodeError,
|
||||
type AuthRepository,
|
||||
type PendingLoginCode,
|
||||
} from "../src/index";
|
||||
|
||||
class MemoryAuthRepository implements AuthRepository {
|
||||
loginCode: PendingLoginCode | undefined;
|
||||
consumedHash: string | undefined;
|
||||
|
||||
async saveLoginCode(code: PendingLoginCode) {
|
||||
this.loginCode = code;
|
||||
}
|
||||
|
||||
async exchangeLoginCode(input: Parameters<AuthRepository["exchangeLoginCode"]>[0]) {
|
||||
this.consumedHash = input.loginCodeHash;
|
||||
if (
|
||||
!this.loginCode ||
|
||||
this.loginCode.tokenHash !== input.loginCodeHash ||
|
||||
this.loginCode.expiresAt <= input.now
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.loginCode = undefined;
|
||||
return {
|
||||
user: {
|
||||
id: "01JQ0000000000000000000000",
|
||||
discordUserId: "123456789012345678",
|
||||
discordUsername: "steve",
|
||||
firstName: null,
|
||||
},
|
||||
isNewUser: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const identity = {
|
||||
id: "123456789012345678",
|
||||
username: "steve",
|
||||
globalName: "Steve",
|
||||
};
|
||||
const now = new Date("2026-08-01T12:00:00.000Z");
|
||||
|
||||
function hash(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
describe("Discord magic-link authentication", () => {
|
||||
it("returns a link while persisting only the token hash", async () => {
|
||||
const repository = new MemoryAuthRepository();
|
||||
const result = await createMagicLink(identity, {
|
||||
repository,
|
||||
appUrl: "https://accounts.example.com",
|
||||
now: () => now,
|
||||
randomToken: () => "private-login-token",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://accounts.example.com/auth/discord?code=private-login-token");
|
||||
expect(result.expiresAt).toEqual(new Date("2026-08-01T12:10:00.000Z"));
|
||||
expect(repository.loginCode).toMatchObject({
|
||||
tokenHash: hash("private-login-token"),
|
||||
discordUserId: identity.id,
|
||||
discordUsername: identity.username,
|
||||
});
|
||||
expect(JSON.stringify(repository.loginCode)).not.toContain("private-login-token");
|
||||
});
|
||||
|
||||
it("exchanges a valid one-time code for a session", async () => {
|
||||
const repository = new MemoryAuthRepository();
|
||||
await createMagicLink(identity, {
|
||||
repository,
|
||||
appUrl: "https://accounts.example.com",
|
||||
now: () => now,
|
||||
randomToken: () => "private-login-token",
|
||||
});
|
||||
|
||||
const result = await exchangeMagicLink("private-login-token", {
|
||||
repository,
|
||||
now: () => new Date("2026-08-01T12:01:00.000Z"),
|
||||
randomToken: () => "private-session-token",
|
||||
});
|
||||
|
||||
expect(result.sessionToken).toBe("private-session-token");
|
||||
expect(result.user.discordUserId).toBe(identity.id);
|
||||
expect(repository.consumedHash).toBe(hash("private-login-token"));
|
||||
});
|
||||
|
||||
it("rejects an expired or already-consumed code", async () => {
|
||||
const repository = new MemoryAuthRepository();
|
||||
|
||||
await expect(
|
||||
exchangeMagicLink("missing-token", {
|
||||
repository,
|
||||
now: () => now,
|
||||
randomToken: () => "private-session-token",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidLoginCodeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractOidcRoles } from "../src/index";
|
||||
|
||||
function unsignedToken(payload: Record<string, unknown>) {
|
||||
return `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.signature`;
|
||||
}
|
||||
|
||||
describe("OIDC role extraction", () => {
|
||||
it("combines realm and configured-client roles from Keycloak tokens", () => {
|
||||
const roles = extractOidcRoles({
|
||||
clientId: "minecraft-account-manager-admin",
|
||||
profile: { groups: ["support"] },
|
||||
accessToken: unsignedToken({
|
||||
realm_access: { roles: ["minecraft-account-manager-admin"] },
|
||||
resource_access: {
|
||||
"minecraft-account-manager-admin": { roles: ["settings-editor"] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(roles).toEqual([
|
||||
"support",
|
||||
"minecraft-account-manager-admin",
|
||||
"settings-editor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats malformed token payloads as having no roles", () => {
|
||||
expect(extractOidcRoles({ clientId: "admin", accessToken: "invalid" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hashToken, isRequestTimestampFresh, verifyHashedToken } from "../src/index";
|
||||
|
||||
describe("plugin request authentication", () => {
|
||||
it("compares an opaque token with its stored hash", () => {
|
||||
const storedHash = hashToken("correct-high-entropy-token");
|
||||
expect(verifyHashedToken("correct-high-entropy-token", storedHash)).toBe(true);
|
||||
expect(verifyHashedToken("wrong-token", storedHash)).toBe(false);
|
||||
expect(verifyHashedToken("correct-high-entropy-token", "malformed")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects stale and excessively future-dated requests", () => {
|
||||
const now = new Date("2026-08-01T12:00:00.000Z");
|
||||
expect(isRequestTimestampFresh(new Date("2026-08-01T11:59:30.000Z"), now, 45_000)).toBe(true);
|
||||
expect(isRequestTimestampFresh(new Date("2026-08-01T11:59:14.000Z"), now, 45_000)).toBe(false);
|
||||
expect(isRequestTimestampFresh(new Date("2026-08-01T12:00:46.000Z"), now, 45_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user