feat(platform): add Discord onboarding and Velocity gate

This commit is contained in:
dmg
2026-08-01 13:45:18 -04:00
parent 9d305e5dc9
commit c5de0a1810
80 changed files with 4840 additions and 1572 deletions
+6
View File
@@ -3,6 +3,7 @@ APP_URL=http://localhost:3000
SESSION_SECRET=
# Admin OIDC / Keycloak
NEXTAUTH_URL=http://localhost:3000
AUTH_SECRET=
KEYCLOAK_ISSUER_URL=
KEYCLOAK_CLIENT_ID=minecraft-account-manager-admin
@@ -12,6 +13,11 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin
# Discord
DISCORD_BOT_TOKEN=
DISCORD_APPLICATION_ID=
DISCORD_GUILD_ID=
DISCORD_INVITE_URL=https://discord.gg/your-invite
# Trust forwarding headers only when your reverse proxy overwrites them
TRUST_PROXY=false
# Optional VPN intelligence provider (deferred for v1)
IP_INTELLIGENCE_PROVIDER=none
+14 -3
View File
@@ -8,7 +8,7 @@ A Discord-first account registry for a private Java Edition Minecraft network. P
- `apps/discord-bot` — discord.js slash-command bot
- `packages/contracts` — shared Zod contracts and CloudEvents types
- `packages/database` — PostgreSQL Drizzle schema and versioned migrations
- `plugins/velocity` — Velocity admission plugin (planned)
- `plugins/velocity` fail-closed Velocity admission plugin
## Requirements
@@ -26,6 +26,8 @@ npm run db:migrate
npm run dev
```
Set `DISCORD_GUILD_ID` and `DISCORD_INVITE_URL` in `.env.local` so unauthenticated visitors can reach the Discord server. The HTTPS invite is the most reliable way to open Discord or join; the landing page also offers a `discord://` app link.
Open `http://localhost:3000`.
## Validation
@@ -35,6 +37,7 @@ npm test
npm run typecheck
npm run lint
npm run build
npm run velocity:build
```
## Database workflow
@@ -48,14 +51,22 @@ npm run db:migrate
Do not use `drizzle push`; it bypasses the reviewed migration history and can cause destructive schema changes.
Provision or rotate a Velocity API token after migrating:
```bash
npm run plugin:create-credential --workspace @minecraft-account-manager/database -- velocity-main
```
The token is displayed once and stored only as a SHA-256 hash.
## Confirmed product decisions
- PostgreSQL and Drizzle ORM
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
- Admin-managed Discord guild configuration
- Deployment-managed Discord guild ID and invite URL
- discord.js bot with `/register` and `/account`
- Java Edition online-mode accounts only
- Velocity admission checks are fail closed
- VPN detection is represented in the schema but may remain disabled in the first release until a provider is selected
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities.
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements.
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts
APP_URL=http://localhost:3000
DISCORD_BOT_TOKEN=
DISCORD_APPLICATION_ID=
DISCORD_GUILD_ID=
+10 -2
View File
@@ -1,5 +1,13 @@
# Discord bot
The bot will provide ephemeral `/register` and `/account` responses containing short-lived one-time links. The target guild is read from admin-managed application settings rather than a deployment-only environment variable.
The bot provides ephemeral `/register` and `/account` responses containing ten-minute, single-use links. Commands only work in the guild configured by `DISCORD_GUILD_ID`.
Implementation begins in Phase 2 alongside the one-time login service.
## Setup
```bash
cp apps/discord-bot/.env.example apps/discord-bot/.env
npm run commands:deploy --workspace @minecraft-account-manager/discord-bot
npm run dev --workspace @minecraft-account-manager/discord-bot
```
The bot requires permission to use application commands. Nickname management will additionally require `Manage Nicknames`, with the bot role above managed members.
+9 -1
View File
@@ -4,13 +4,21 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"commands:deploy": "tsx src/deploy-commands.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@minecraft-account-manager/auth": "*",
"@minecraft-account-manager/contracts": "*",
"discord.js": "^14.25.1"
"@minecraft-account-manager/database": "*",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1"
},
"devDependencies": {
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { SlashCommandBuilder } from "discord.js";
export const commands = [
new SlashCommandBuilder()
.setName("register")
.setDescription("Register a Minecraft account through a private login link"),
new SlashCommandBuilder()
.setName("account")
.setDescription("Open your Minecraft account dashboard through a private login link"),
].map((command) => command.toJSON());
+7
View File
@@ -0,0 +1,7 @@
export function requiredEnvironment(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
export const commandNames = ["register", "account"] as const;
+11
View File
@@ -0,0 +1,11 @@
import "dotenv/config";
import { REST, Routes } from "discord.js";
import { commands } from "./commands";
import { requiredEnvironment } from "./config";
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
const rest = new REST({ version: "10" }).setToken(token);
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
console.log(`Deployed ${commands.length} global Discord commands.`);
+79 -2
View File
@@ -1,2 +1,79 @@
// Discord command handling is added in Phase 2 after the one-time login service exists.
export {};
import "dotenv/config";
import { createMagicLink, LoginRateLimitedError } from "@minecraft-account-manager/auth";
import {
createAuthRepository,
createDatabase,
recordEvent,
} from "@minecraft-account-manager/database";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
Client,
Events,
GatewayIntentBits,
} from "discord.js";
import { commandNames, requiredEnvironment } from "./config";
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
const appUrl = requiredEnvironment("APP_URL");
const databaseUrl = requiredEnvironment("DATABASE_URL");
const discordGuildId = requiredEnvironment("DISCORD_GUILD_ID");
const { db } = createDatabase(databaseUrl);
const authRepository = createAuthRepository(db);
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (readyClient) => {
console.log(`Discord bot ready as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (!commandNames.includes(interaction.commandName as (typeof commandNames)[number])) return;
await interaction.deferReply({ ephemeral: true });
try {
if (interaction.guildId !== discordGuildId) {
await interaction.editReply("This command is only available in the configured community server.");
return;
}
const magicLink = await createMagicLink(
{
id: interaction.user.id,
username: interaction.user.username,
globalName: interaction.user.globalName,
},
{ repository: authRepository, appUrl },
);
await recordEvent(db, {
type: "games.minecraft.account-manager.auth.magic-link.created",
source: "/discord-bot",
subject: `discord-user/${interaction.user.id}`,
data: { command: interaction.commandName, guildId: interaction.guildId },
});
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setLabel(interaction.commandName === "register" ? "Start registration" : "Open my account")
.setStyle(ButtonStyle.Link)
.setURL(magicLink.url),
);
await interaction.editReply({
content: "This private link works once and expires in 10 minutes. Do not share it.",
components: [row],
});
} catch (error) {
if (error instanceof LoginRateLimitedError) {
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
return;
}
console.error("Failed to create Discord account link", error);
await interaction.editReply("I could not create an account link. Please try again shortly.");
}
});
await client.login(token);
+28
View File
@@ -1,7 +1,35 @@
import type { NextConfig } from "next";
const contentSecurityPolicy = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ");
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Content-Security-Policy", value: contentSecurityPolicy },
{ key: "Referrer-Policy", value: "no-referrer" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
];
},
serverExternalPackages: ["postgres"],
transpilePackages: [
"@minecraft-account-manager/contracts",
+5
View File
@@ -10,9 +10,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@minecraft-account-manager/auth": "*",
"@minecraft-account-manager/contracts": "*",
"@minecraft-account-manager/database": "*",
"@minecraft-account-manager/minecraft": "*",
"@minecraft-account-manager/network": "*",
"drizzle-orm": "^0.45.1",
"next": "^16.2.1",
"next-auth": "^4.24.13",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
+132
View File
@@ -0,0 +1,132 @@
"use server";
import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, eq, isNull, ne } from "drizzle-orm";
import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
export async function updateFirstName(formData: FormData) {
const user = await requireCurrentUser();
const firstName = String(formData.get("firstName") ?? "").trim();
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
redirect("/account?error=invalid-name");
}
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
redirect("/account?confirmNickname=1");
}
export async function addMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const requestedUsername = String(formData.get("username") ?? "").trim();
const confirmed = formData.get("confirmUnverified") === "yes";
if (!USERNAME_PATTERN.test(requestedUsername)) redirect("/account?error=invalid-username");
const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
let failed = false;
try {
await db.insert(minecraftAccounts).values({
userId: user.id,
minecraftUuid: profile?.uuid ?? null,
username: profile?.username ?? requestedUsername,
validationStatus: profile ? "verified" : "user_confirmed",
lastVerifiedAt: profile ? new Date() : null,
isPrimary: !existing,
});
} catch {
failed = true;
}
if (failed) redirect("/account?error=already-registered");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
username: profile?.username ?? requestedUsername,
minecraftUuid: profile?.uuid ?? null,
validationStatus: profile ? "verified" : "user_confirmed",
});
redirect(existing ? "/account?added=1" : "/account?confirmNickname=1");
}
export async function setPrimaryAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const changed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) return false;
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
);
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
return true;
});
if (!changed) redirect("/account?error=unknown-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId });
redirect("/account?confirmNickname=1");
}
export async function removeMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const removed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) return false;
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
if (account.isPrimary) {
const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (replacement) {
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
}
}
return true;
});
if (!removed) redirect("/account?error=unknown-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
redirect("/account?removed=1&confirmNickname=1");
}
export async function confirmDashboardNickname() {
const user = await requireCurrentUser();
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured");
try {
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname: formatDiscordNickname(user.firstName, account.username),
botToken,
});
} catch {
redirect("/account?error=nickname-update-failed&confirmNickname=1");
}
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname: formatDiscordNickname(user.firstName, account.username),
});
redirect("/account?nicknameUpdated=1");
}
+148
View File
@@ -0,0 +1,148 @@
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
import { ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull } from "drizzle-orm";
import { logout } from "@/app/auth/actions";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
import {
addMinecraftAccount,
confirmDashboardNickname,
removeMinecraftAccount,
setPrimaryAccount,
updateFirstName,
} from "./actions";
const errorMessages: Record<string, string> = {
"invalid-name": "Enter a valid name between 1 and 50 characters.",
"invalid-username": "Java usernames use 316 letters, numbers, or underscores.",
"already-registered": "That Minecraft account is already registered.",
"unknown-account": "That account is no longer available.",
"nickname-not-configured": "Discord nickname updates are not configured.",
"nickname-update-failed": "Discord rejected the nickname update. An admin may need to adjust bot permissions.",
};
export default async function AccountPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const user = await requireCurrentUser("/account");
const query = await searchParams;
const [accounts, observations] = await Promise.all([
db
.select()
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
db
.select()
.from(ipObservations)
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(20),
]);
const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName && primary
? formatDiscordNickname(user.firstName, primary.username)
: null;
return (
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
<section className="mx-auto max-w-6xl">
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Account registry</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? user.discordUsername}</h1>
</div>
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
</header>
{query.error && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
{errorMessages[query.error] ?? "The requested change could not be completed."}
</p>
)}
{query.nicknameUpdated && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Discord nickname updated</p>}
{query.confirmNickname && desiredNickname && (
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
<div>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p>
<p className="mt-2 text-sm text-muted">Your community nickname will become</p>
<p className="mt-1 font-display text-2xl font-black">{desiredNickname}</p>
</div>
<form action={confirmDashboardNickname} className="mt-5 sm:mt-0">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm update</button>
</form>
</section>
)}
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
<div className="space-y-10">
<section>
<div className="flex items-end justify-between border-b border-line pb-4">
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Whitelist identities</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Minecraft accounts</h2></div>
<span className="font-mono text-xs text-muted">{accounts.length} active</span>
</div>
<div className="divide-y divide-line">
{accounts.map((account) => (
<article className="grid gap-4 py-6 sm:grid-cols-[1fr_auto] sm:items-center" key={account.id}>
<div>
<div className="flex flex-wrap items-center gap-3">
<h3 className="font-mono text-lg font-bold">{account.username}</h3>
{account.isPrimary && <span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase tracking-widest text-canvas">Primary</span>}
<span className="border border-line px-2 py-1 font-mono text-[9px] uppercase tracking-wider text-muted">{account.validationStatus === "verified" ? "UUID verified" : "User confirmed"}</span>
</div>
<p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "UUID will be learned at game login"}</p>
</div>
<div className="flex gap-4">
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form>
</div>
</article>
))}
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
</div>
{query.unverified ? (
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
<h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {query.unverified}</h3>
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
</form>
) : (
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
</form>
)}
</section>
<section>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
{observations.length ? (
<div className="divide-y divide-line font-mono text-xs">
{observations.map((observation) => <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><span>{observation.ipAddress}</span><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>)}
</div>
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
</section>
</div>
<aside>
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>}
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
</form>
</aside>
</div>
</section>
</main>
);
}
@@ -0,0 +1,35 @@
"use server";
import { appSettings } from "@minecraft-account-manager/database";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
import { db } from "@/lib/database";
export async function saveDiscordSettings(formData: FormData) {
const session = await getServerSession(adminAuthOptions);
const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? [];
if (!session || !roles.includes(requiredAdminRole)) redirect("/admin/login");
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
redirect("/admin?error=invalid-message");
}
await db
.insert(appSettings)
.values({
id: "default",
registrationMessage,
})
.onConflictDoUpdate({
target: appSettings.id,
set: {
registrationMessage,
updatedAt: new Date(),
},
});
redirect("/admin?saved=1");
}
@@ -0,0 +1,32 @@
import { events } from "@minecraft-account-manager/database";
import { desc } from "drizzle-orm";
import { db } from "@/lib/database";
export default async function EventsPage() {
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Recent events</h1>
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[760px] border-collapse text-left">
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th></tr>
</thead>
<tbody className="divide-y divide-line text-xs">
{recentEvents.map((event) => (
<tr key={event.id}>
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
<td className="p-4 font-mono font-bold">{event.type}</td>
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
</tr>
))}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={4}>No events have been recorded.</td></tr>}
</tbody>
</table>
</div>
</main>
);
}
@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getClientIp } from "@minecraft-account-manager/network";
import { recordEvent } from "@minecraft-account-manager/database";
import { headers } from "next/headers";
import { getServerSession } from "next-auth";
import { AdminSignOutButton } from "@/components/admin-sign-out-button";
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
import { db } from "@/lib/database";
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
const session = await getServerSession(adminAuthOptions);
if (!session) redirect("/admin/login");
const roles = (session.user as typeof session.user & { roles?: string[] })?.roles ?? [];
if (!roles.includes(requiredAdminRole)) redirect("/admin/login?error=forbidden");
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
await recordEvent(db, {
type: "games.minecraft.account-manager.ui.accessed",
source: "/web/admin",
subject: "admin-console",
ipAddress: ipAddress ?? undefined,
data: { adminEmail: session.user?.email ?? null },
});
return (
<div className="min-h-screen bg-canvas text-ink">
<header className="border-b border-line bg-panel">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / Ops</Link>
<nav className="ml-auto mr-8 flex gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
<Link className="hover:text-accent" href="/admin">Settings</Link>
<Link className="hover:text-accent" href="/admin/events">Events</Link>
</nav>
<AdminSignOutButton />
</div>
</header>
{children}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { eq } from "drizzle-orm";
import { appSettings } from "@minecraft-account-manager/database";
import { db } from "@/lib/database";
import { saveDiscordSettings } from "./actions";
export default async function AdminPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; error?: string }>;
}) {
const query = await searchParams;
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
<section>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
<dl className="mt-7 space-y-2 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="inline font-bold text-ink">Guild:</dt> <dd className="inline">{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}</dd></div>
<div><dt className="inline font-bold text-ink">Invite:</dt> <dd className="inline">{process.env.DISCORD_INVITE_URL ? "configured" : "missing"}</dd></div>
</dl>
</section>
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider">Settings saved</p>}
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm">Check the highlighted configuration values and try again.</p>}
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
<textarea
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
defaultValue={message}
id="registrationMessage"
maxLength={500}
minLength={10}
name="registrationMessage"
required
/>
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
</form>
</div>
</main>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { AdminSignInButton } from "@/components/admin-sign-in-button";
import { isAdminOidcConfigured } from "@/lib/auth/admin-auth";
export default async function AdminLoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return (
<main className="grid min-h-screen place-items-center bg-ink px-6 text-canvas">
<section className="w-full max-w-md border border-[#4a4d46] bg-[#20221e] p-8 shadow-[10px_10px_0_#bc3f24]">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-signal">Restricted console</p>
<h1 className="mt-5 font-display text-4xl font-black uppercase tracking-tight">Operator access</h1>
<p className="mb-8 mt-4 leading-7 text-[#b7b8ae]">Authenticate through Keycloak. The configured admin realm role is required.</p>
{error && <p className="mb-5 border-l-2 border-accent pl-4 text-sm text-[#efb5a8]">Your identity does not have access to this console.</p>}
{isAdminOidcConfigured ? (
<AdminSignInButton />
) : (
<p className="border border-[#5c5e56] p-4 font-mono text-xs leading-6 text-[#d8b46e]">OIDC is not configured. Add the Keycloak environment variables before signing in.</p>
)}
</section>
</main>
);
}
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { adminAuthOptions } from "@/lib/auth/admin-auth";
const handler = NextAuth(adminAuthOptions);
export { handler as GET, handler as POST };
@@ -0,0 +1,179 @@
import { randomUUID } from "node:crypto";
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
import { velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import {
appSettings,
events,
ipObservations,
minecraftAccounts,
pluginCredentials,
pluginRequests,
} from "@minecraft-account-manager/database";
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
const MAX_CLOCK_SKEW_MS = 45_000;
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
export async function POST(request: Request) {
const authorization = request.headers.get("authorization") ?? "";
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
if (!token) return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
const parsed = velocityAccessRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ allowed: false, message: "Invalid access request" }, { status: 400 });
}
const input = parsed.data;
const occurredAt = new Date(input.occurredAt);
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) {
return NextResponse.json({ allowed: false, message: "Expired access request" }, { status: 401 });
}
const [credential] = await db
.select({ secretHash: pluginCredentials.secretHash })
.from(pluginCredentials)
.where(and(eq(pluginCredentials.serverId, input.serverId), isNull(pluginCredentials.revokedAt)))
.limit(1);
if (!credential || !verifyHashedToken(token, credential.secretHash)) {
return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
}
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
try {
const decision = await db.transaction(async (tx) => {
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
await tx.insert(pluginRequests).values({
requestId: input.requestId,
serverId: input.serverId,
receivedAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60_000),
});
let [account] = await tx
.select({
id: minecraftAccounts.id,
userId: minecraftAccounts.userId,
minecraftUuid: minecraftAccounts.minecraftUuid,
username: minecraftAccounts.username,
})
.from(minecraftAccounts)
.where(
and(
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
isNull(minecraftAccounts.deletedAt),
),
)
.limit(1);
if (!account) {
[account] = await tx
.select({
id: minecraftAccounts.id,
userId: minecraftAccounts.userId,
minecraftUuid: minecraftAccounts.minecraftUuid,
username: minecraftAccounts.username,
})
.from(minecraftAccounts)
.where(
and(
isNull(minecraftAccounts.minecraftUuid),
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
isNull(minecraftAccounts.deletedAt),
),
)
.limit(1);
}
if (!account) {
await tx.insert(events).values({
id: randomUUID(),
source: `/velocity/${input.serverId}`,
type: "games.minecraft.account-manager.game.login.denied",
subject: `minecraft-account/${input.minecraftUuid}`,
time: occurredAt,
data: { username: input.username, reason: "not_registered" },
ipAddress: input.ipAddress,
correlationId: input.requestId,
});
await tx.insert(ipObservations).values({
source: "game",
ipAddress: input.ipAddress,
minecraftUuid: input.minecraftUuid,
username: input.username,
classification: "unknown",
observedAt: occurredAt,
});
return { allowed: false as const, message: denialMessage };
}
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
await tx
.update(minecraftAccounts)
.set({
minecraftUuid: input.minecraftUuid,
username: input.username,
lastVerifiedAt: occurredAt,
updatedAt: new Date(),
})
.where(eq(minecraftAccounts.id, account.id));
if (account.username !== input.username) {
await tx.insert(events).values({
id: randomUUID(),
source: `/velocity/${input.serverId}`,
type: "games.minecraft.account-manager.minecraft-account.username-changed",
subject: `minecraft-account/${account.id}`,
time: occurredAt,
actorUserId: account.userId,
data: { previousUsername: account.username, username: input.username },
ipAddress: input.ipAddress,
correlationId: input.requestId,
});
}
}
await tx.insert(ipObservations).values({
userId: account.userId,
minecraftAccountId: account.id,
source: "game",
ipAddress: input.ipAddress,
minecraftUuid: input.minecraftUuid,
username: input.username,
classification: "unknown",
observedAt: occurredAt,
});
await tx.insert(events).values({
id: randomUUID(),
source: `/velocity/${input.serverId}`,
type: "games.minecraft.account-manager.game.login.allowed",
subject: `minecraft-account/${account.id}`,
time: occurredAt,
actorUserId: account.userId,
data: {
username: input.username,
minecraftUuid: input.minecraftUuid,
previousUsername: account.username === input.username ? null : account.username,
uuidBackfilled: account.minecraftUuid === null,
},
ipAddress: input.ipAddress,
correlationId: input.requestId,
});
return { allowed: true as const, message: "Account approved." };
});
return NextResponse.json(decision);
} catch (error) {
console.error("Velocity access decision failed", error);
return NextResponse.json(
{ allowed: false, message: denialMessage },
{ status: 503 },
);
}
}
+23
View File
@@ -0,0 +1,23 @@
"use server";
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
import { sessions } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { db } from "@/lib/database";
export async function logout() {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (token) {
await db
.update(sessions)
.set({ revokedAt: new Date() })
.where(eq(sessions.tokenHash, hashToken(token)));
}
cookieStore.delete(SESSION_COOKIE_NAME);
redirect("/");
}
+51
View File
@@ -0,0 +1,51 @@
import { exchangeMagicLink, InvalidLoginCodeError, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code") ?? "";
try {
const result = await exchangeMagicLink(code, {
repository: createAuthRepository(db),
});
const ipAddress = getClientIp(request.headers, process.env.TRUST_PROXY === "true");
await Promise.all([
recordEvent(db, {
type: "games.minecraft.account-manager.auth.magic-link.consumed",
source: "/web/auth/discord",
subject: `user/${result.user.id}`,
actorUserId: result.user.id,
ipAddress: ipAddress ?? undefined,
data: { isNewUser: result.isNewUser },
}),
ipAddress
? db.insert(ipObservations).values({
userId: result.user.id,
source: "web",
ipAddress,
classification: "unknown",
})
: Promise.resolve(),
]);
const destination = result.user.firstName ? "/account" : "/welcome";
const response = NextResponse.redirect(new URL(destination, request.url));
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: result.sessionExpiresAt,
});
return response;
} catch (error) {
if (error instanceof InvalidLoginCodeError) {
return NextResponse.redirect(new URL("/auth/error", request.url));
}
throw error;
}
}
+14
View File
@@ -0,0 +1,14 @@
import Link from "next/link";
export default function LoginErrorPage() {
return (
<main className="grid min-h-screen place-items-center bg-canvas px-6 text-ink">
<section className="max-w-lg border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)]">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Link rejected</p>
<h1 className="mt-4 font-display text-4xl font-black uppercase tracking-tight">That gate key no longer works.</h1>
<p className="mt-5 leading-7 text-muted">Login links expire after ten minutes and can only be used once. Return to Discord and run <strong>/account</strong> for a fresh link.</p>
<Link className="mt-7 inline-block border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" href="/">Back home</Link>
</section>
</main>
);
}
+25 -1
View File
@@ -4,7 +4,15 @@ const steps = [
["03", "Join the server", "Add your Java account and connect once approved."],
] as const;
export default function HomePage() {
export default async function HomePage({
searchParams,
}: {
searchParams: Promise<{ portal?: string }>;
}) {
const { portal } = await searchParams;
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
const guildId = process.env.DISCORD_GUILD_ID?.trim();
return (
<main className="relative min-h-screen overflow-hidden bg-canvas text-ink">
<div className="terrain" aria-hidden="true" />
@@ -22,6 +30,12 @@ export default function HomePage() {
</span>
</header>
{portal && (
<div className="mt-8 border border-accent bg-panel px-5 py-4 font-mono text-xs leading-6 shadow-[5px_5px_0_var(--color-accent)]">
The account portal starts in Discord. Join the server, then run <strong>/register</strong> or <strong>/account</strong> to receive your private sign-in link.
</div>
)}
<section id="top" className="grid flex-1 items-center gap-14 py-20 lg:grid-cols-[1.15fr_0.85fr] lg:py-24">
<div>
<p className="mb-7 font-mono text-xs font-semibold uppercase tracking-[0.3em] text-accent">
@@ -57,6 +71,16 @@ export default function HomePage() {
<span className="text-signal">&gt;</span> Open Discord and type <strong>/register</strong>
<span className="cursor ml-1 inline-block h-4 w-2 bg-signal align-middle" />
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{inviteUrl ? (
<a className="border border-ink bg-accent px-4 py-3 text-center font-mono text-[10px] font-bold uppercase tracking-widest text-canvas" href={inviteUrl} rel="noreferrer" target="_blank">Join Discord server</a>
) : (
<span className="border border-line px-4 py-3 text-center font-mono text-[10px] uppercase tracking-widest text-muted">Invite not configured</span>
)}
{guildId && (
<a className="border border-ink px-4 py-3 text-center font-mono text-[10px] font-bold uppercase tracking-widest" href={`discord://-/channels/${guildId}`}>Open Discord app</a>
)}
</div>
</aside>
</section>
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { lookupJavaProfile, updateGuildNickname, formatDiscordNickname } from "@minecraft-account-manager/minecraft";
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, eq, isNull } from "drizzle-orm";
import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
export async function saveFirstName(formData: FormData) {
const user = await requireCurrentUser();
const firstName = String(formData.get("firstName") ?? "").trim();
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
redirect("/welcome?error=invalid-name");
}
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
redirect("/welcome/minecraft");
}
export async function addFirstMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const requestedUsername = String(formData.get("username") ?? "").trim();
const confirmed = formData.get("confirmUnverified") === "yes";
if (!USERNAME_PATTERN.test(requestedUsername)) {
redirect("/welcome/minecraft?error=invalid-format");
}
const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) {
redirect(`/welcome/minecraft?unverified=${encodeURIComponent(requestedUsername)}`);
}
const [existingAccount] = await db
.select({ id: minecraftAccounts.id })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
let failed = false;
try {
await db.insert(minecraftAccounts).values({
userId: user.id,
minecraftUuid: profile?.uuid ?? null,
username: profile?.username ?? requestedUsername,
validationStatus: profile ? "verified" : "user_confirmed",
lastVerifiedAt: profile ? new Date() : null,
isPrimary: !existingAccount,
});
} catch {
failed = true;
}
if (failed) redirect("/welcome/minecraft?error=already-registered");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
username: profile?.username ?? requestedUsername,
minecraftUuid: profile?.uuid ?? null,
validationStatus: profile ? "verified" : "user_confirmed",
});
redirect("/welcome/discord");
}
export async function confirmInitialNickname() {
const user = await requireCurrentUser();
const [account] = await db
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(
and(
eq(minecraftAccounts.userId, user.id),
eq(minecraftAccounts.isPrimary, true),
isNull(minecraftAccounts.deletedAt),
),
)
.limit(1);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!user.firstName || !account || !guildId || !botToken) {
redirect("/welcome/discord?error=not-configured");
}
try {
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname: formatDiscordNickname(user.firstName, account.username),
botToken,
});
} catch {
redirect("/welcome/discord?error=discord-update");
}
await db
.update(users)
.set({ onboardingCompletedAt: new Date(), updatedAt: new Date() })
.where(eq(users.id, user.id));
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname: formatDiscordNickname(user.firstName, account.username),
onboardingCompleted: true,
});
redirect("/account");
}
+34
View File
@@ -0,0 +1,34 @@
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
import { minecraftAccounts } from "@minecraft-account-manager/database";
import { and, eq, isNull } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { confirmInitialNickname } from "../actions";
export default async function DiscordStepPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const user = await requireCurrentUser("/welcome/discord");
const query = await searchParams;
if (!user.firstName) redirect("/welcome");
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) redirect("/welcome/minecraft");
const nickname = formatDiscordNickname(user.firstName, account.username);
return (
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 3 / 3 · Confirm identity</p>
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">One name everywhere.</h1>
<p className="mt-6 text-lg leading-8 text-muted">Your Discord nickname in the configured community server will become:</p>
<div className="mt-7 border border-line bg-canvas px-6 py-5 font-display text-2xl font-black">{nickname}</div>
{query.error && <p className="mt-5 border-l-2 border-accent pl-4 text-sm leading-6 text-accent">We couldnt update Discord. Ask an admin to check the guild and bot nickname permissions, then retry.</p>}
<form action={confirmInitialNickname} className="mt-8">
<button className="border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Confirm and update Discord</button>
</form>
</section>
</main>
);
}
@@ -0,0 +1,48 @@
import { redirect } from "next/navigation";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { addFirstMinecraftAccount } from "../actions";
const errors: Record<string, string> = {
"invalid-format": "Java usernames use 316 letters, numbers, or underscores.",
"already-registered": "That Minecraft account is already registered.",
};
export default async function MinecraftStepPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; unverified?: string }>;
}) {
const user = await requireCurrentUser("/welcome/minecraft");
if (!user.firstName) redirect("/welcome");
const query = await searchParams;
return (
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 2 / 3 · Java Edition</p>
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">Add your player.</h1>
<p className="mt-6 text-lg leading-8 text-muted">Well verify the username with Mojang and store its UUID for secure matching.</p>
{query.unverified ? (
<form action={addFirstMinecraftAccount} className="mt-9 border-l-2 border-accent pl-6">
<h2 className="font-display text-2xl font-black uppercase">We couldnt verify {query.unverified}.</h2>
<p className="mt-3 leading-7 text-muted">Check the spelling. If youre certain it is correct, continue without a UUID. The server can associate it after a successful online-mode login.</p>
<input name="username" type="hidden" value={query.unverified} />
<input name="confirmUnverified" type="hidden" value="yes" />
<div className="mt-6 flex flex-wrap gap-4">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Yes, continue</button>
<a className="border border-line px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider" href="/welcome/minecraft">Check spelling</a>
</div>
</form>
) : (
<form action={addFirstMinecraftAccount} className="mt-9">
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="username">Minecraft username</label>
<input className="mt-3 w-full border border-line bg-canvas px-4 py-4 font-mono text-lg outline-none focus:border-accent" id="username" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" required autoFocus />
{query.error && <p className="mt-3 text-sm text-accent">{errors[query.error] ?? "We could not add that account."}</p>}
<button className="mt-7 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Verify account</button>
</form>
)}
</section>
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { saveFirstName } from "./actions";
export default async function WelcomePage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const user = await requireCurrentUser("/welcome");
const query = await searchParams;
if (user.firstName) redirect("/welcome/minecraft");
return (
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 1 / 3 · Discord connected</p>
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">Welcome, {user.discordUsername}.</h1>
<p className="mt-6 max-w-xl text-lg leading-8 text-muted">Lets get started. What should we call you?</p>
<form action={saveFirstName} className="mt-9">
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">Your first name</label>
<input className="mt-3 w-full border border-line bg-canvas px-4 py-4 text-lg outline-none focus:border-accent" id="firstName" maxLength={50} name="firstName" required autoFocus />
{query.error && <p className="mt-3 text-sm text-accent">Enter a name between 1 and 50 characters.</p>}
<button className="mt-7 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Continue</button>
</form>
</section>
</main>
);
}
@@ -0,0 +1,15 @@
"use client";
import { signIn } from "next-auth/react";
export function AdminSignInButton() {
return (
<button
className="w-full border border-ink bg-ink px-5 py-4 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas transition-transform hover:-translate-y-0.5"
onClick={() => signIn("keycloak", { callbackUrl: "/admin" })}
type="button"
>
Continue with SSO
</button>
);
}
@@ -0,0 +1,15 @@
"use client";
import { signOut } from "next-auth/react";
export function AdminSignOutButton() {
return (
<button
className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline decoration-line underline-offset-4 hover:text-ink"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
type="button"
>
Sign out
</button>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { getClientIp } from "@minecraft-account-manager/network";
import { ipObservations, recordEvent } from "@minecraft-account-manager/database";
import { headers } from "next/headers";
import { db } from "@/lib/database";
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
export async function recordUserEvent(
user: { id: string },
type: string,
data: Record<string, unknown>,
) {
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
return recordEvent(db, {
type,
source: "/web",
subject: `user/${user.id}`,
actorUserId: user.id,
ipAddress: ipAddress ?? undefined,
data,
});
}
export async function recordAuthenticatedUiAccess(
user: { id: string },
path: string,
) {
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
await Promise.all([
recordEvent(db, {
type: UI_ACCESSED,
source: "/web",
subject: `user/${user.id}`,
actorUserId: user.id,
ipAddress: ipAddress ?? undefined,
data: { path },
}),
ipAddress
? db.insert(ipObservations).values({
userId: user.id,
source: "web",
ipAddress,
classification: "unknown",
})
: Promise.resolve(),
]);
}
+58
View File
@@ -0,0 +1,58 @@
import { extractOidcRoles } from "@minecraft-account-manager/auth";
import type { NextAuthOptions } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
const clientId = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET?.trim() ?? "";
export const requiredAdminRole =
process.env.KEYCLOAK_REQUIRED_ROLE?.trim() || "minecraft-account-manager-admin";
export const isAdminOidcConfigured = Boolean(issuer && clientId && clientSecret);
export const adminAuthOptions: NextAuthOptions = {
secret: process.env.AUTH_SECRET,
providers: isAdminOidcConfigured
? [
KeycloakProvider({
issuer,
clientId,
clientSecret,
authorization: { params: { scope: "openid email profile" } },
}),
]
: [],
pages: { signIn: "/admin/login" },
session: { strategy: "jwt" },
callbacks: {
async signIn({ profile, account }) {
if (!isAdminOidcConfigured) return false;
return extractOidcRoles({
clientId,
profile,
idToken: account?.id_token,
accessToken: account?.access_token,
}).includes(requiredAdminRole);
},
async jwt({ token, profile, account }) {
if (profile || account?.id_token || account?.access_token) {
token.roles = extractOidcRoles({
clientId,
profile,
idToken: account?.id_token,
accessToken: account?.access_token,
});
}
token.roles ??= [];
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as typeof session.user & { roles: string[] }).roles = Array.isArray(token.roles)
? token.roles.filter((role): role is string => typeof role === "string")
: [];
}
return session;
},
},
};
+20
View File
@@ -0,0 +1,20 @@
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
import { findUserBySessionToken } from "@minecraft-account-manager/database";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { recordAuthenticatedUiAccess } from "@/lib/audit";
import { db } from "@/lib/database";
export async function getCurrentUser() {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
return findUserBySessionToken(db, hashToken(token));
}
export async function requireCurrentUser(accessPath?: string) {
const user = await getCurrentUser();
if (!user) redirect("/?portal=1");
if (accessPath) await recordAuthenticatedUiAccess(user, accessPath);
return user;
}
+18
View File
@@ -0,0 +1,18 @@
import { createDatabase } from "@minecraft-account-manager/database";
const databaseUrl =
process.env.DATABASE_URL ??
"postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts";
const globalDatabase = globalThis as typeof globalThis & {
accountManagerDatabase?: ReturnType<typeof createDatabase>;
};
export const database =
globalDatabase.accountManagerDatabase ?? createDatabase(databaseUrl);
if (process.env.NODE_ENV !== "production") {
globalDatabase.accountManagerDatabase = database;
}
export const db = database.db;
+7
View File
@@ -0,0 +1,7 @@
import "next-auth/jwt";
declare module "next-auth/jwt" {
interface JWT {
roles?: string[];
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ The admin console will use Keycloak OIDC and JWT-backed Auth.js sessions, follow
## Application environment
- `AUTH_SECRET`
- `APP_URL`
- `NEXTAUTH_URL`
- `KEYCLOAK_ISSUER_URL`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
+2 -2
View File
@@ -10,7 +10,7 @@ User authentication begins with an opaque, short-lived, single-use token created
### Discord bot
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the guild selected in admin settings.
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
### Velocity plugin
@@ -23,7 +23,7 @@ The decision is fail closed. Unknown players, invalid responses, expired request
- Browser input is untrusted. Minecraft profile resolution occurs on the server.
- Forwarded IP headers are accepted only from configured reverse proxies.
- Discord IDs come from bot-authenticated requests or one-time-code records, not browser fields.
- Velocity requests will use per-server credentials, timestamps, and request IDs to support authentication and replay prevention.
- Velocity requests use hashed per-server bearer credentials, timestamps, and database-unique request IDs for authentication and replay prevention.
- Session and one-time-code values are random and stored only as hashes.
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
+39
View File
@@ -0,0 +1,39 @@
# Security review
Review date: 2026-08-01
## Scope
Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin authentication, and the Velocity admission plugin.
## Automated checks
- Semgrep `auto`: 0 findings
- `npm audit`: 0 known vulnerabilities after dependency overrides
- TypeScript, ESLint, unit tests, Next.js production build: passing
- Velocity Java tests and shaded plugin build: passing
## Implemented controls
- Discord login and session tokens use cryptographically secure randomness and are stored only as SHA-256 hashes.
- Login links expire after ten minutes, are single use, and are rate limited per Discord user with a PostgreSQL advisory lock.
- Session cookies are `httpOnly`, `sameSite=lax`, path-scoped, and secure in production.
- Admin access uses Keycloak OIDC and a required role.
- User mutations verify ownership server-side.
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
- Velocity and its API fail closed.
- ORM-parameterized queries are used throughout.
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
- Secrets are excluded from logs and repository configuration.
## Outstanding production requirements
- Select and implement a VPN/proxy intelligence provider before enabling VPN-based account-addition blocking. The current classification is explicitly `unknown`.
- Define and automate retention for exact IP addresses and audit events.
- Add monitoring and alerts for repeated login denials, plugin authentication failures, and Discord API failures.
- Use HTTPS for the public application and Velocity API URL. Protect the Velocity configuration file because it contains the one-time-displayed API token.
- Restrict database credentials so normal application roles cannot update or delete historical event rows outside approved application paths.
- Validate migrations in staging before production. Local migration application was unavailable during development because the Docker daemon was not running.
+932 -1555
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -13,7 +13,15 @@
"test": "npm run test --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"db:generate": "npm run db:generate --workspace @minecraft-account-manager/database",
"db:migrate": "npm run db:migrate --workspace @minecraft-account-manager/database"
"db:migrate": "npm run db:migrate --workspace @minecraft-account-manager/database",
"velocity:build": "cd plugins/velocity && ./gradlew clean test shadowJar"
},
"overrides": {
"esbuild": "0.25.12",
"next@16.2.12": {
"postcss": "8.5.25",
"sharp": "0.35.3"
}
},
"engines": {
"node": ">=22"
+18
View File
@@ -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"
}
}
+177
View File
@@ -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),
};
}
+104
View File
@@ -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);
});
});
+31
View File
@@ -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([]);
});
});
+18
View File
@@ -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);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
@@ -0,0 +1,10 @@
CREATE TABLE "plugin_requests" (
"request_id" uuid PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"received_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp (3) with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "plugin_requests" ADD CONSTRAINT "plugin_requests_server_id_plugin_credentials_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."plugin_credentials"("server_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "plugin_requests_expires_idx" ON "plugin_requests" USING btree ("expires_at");--> statement-breakpoint
ALTER TABLE "app_settings" DROP COLUMN "discord_guild_id";
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,13 @@
"when": 1785603505066,
"tag": "0000_supreme_human_fly",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785605590058,
"tag": "0001_silent_ultragirl",
"breakpoints": true
}
]
}
+3
View File
@@ -10,14 +10,17 @@
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"plugin:create-credential": "tsx scripts/create-plugin-credential.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@minecraft-account-manager/auth": "*",
"drizzle-orm": "^0.45.1",
"postgres": "^3.4.8"
},
"devDependencies": {
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,29 @@
import { randomBytes } from "node:crypto";
import { hashToken } from "@minecraft-account-manager/auth";
import { eq } from "drizzle-orm";
import { createDatabase, pluginCredentials } from "../src/index";
const serverId = process.argv[2]?.trim();
if (!serverId || !/^[a-z0-9][a-z0-9_-]{1,99}$/i.test(serverId)) {
throw new Error("Usage: npm run plugin:create-credential --workspace @minecraft-account-manager/database -- <server-id>");
}
const databaseUrl = process.env.DATABASE_URL?.trim();
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const token = randomBytes(32).toString("base64url");
const { db, client } = createDatabase(databaseUrl);
const now = new Date();
await db
.insert(pluginCredentials)
.values({ serverId, secretHash: hashToken(token) })
.onConflictDoUpdate({
target: pluginCredentials.serverId,
set: { secretHash: hashToken(token), revokedAt: null, updatedAt: now },
});
console.log(`Server ID: ${serverId}`);
console.log(`API token: ${token}`);
console.log("Store this token in the Velocity plugin configuration now; it will not be shown again.");
await client.end();
+132
View File
@@ -0,0 +1,132 @@
import { LoginRateLimitedError, type AuthRepository } from "@minecraft-account-manager/auth";
import { and, desc, eq, gt, isNull, lt, sql } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import { loginCodes, sessions, users } from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
export function createAuthRepository(db: Database): AuthRepository {
return {
async saveLoginCode(code) {
await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${code.discordUserId}))`);
const [latestCode] = await tx
.select({ createdAt: loginCodes.createdAt })
.from(loginCodes)
.where(eq(loginCodes.discordUserId, code.discordUserId))
.orderBy(desc(loginCodes.createdAt))
.limit(1);
if (latestCode && latestCode.createdAt > new Date(code.createdAt.getTime() - 30_000)) {
throw new LoginRateLimitedError();
}
await tx.delete(loginCodes).where(lt(loginCodes.expiresAt, code.createdAt));
await tx
.update(loginCodes)
.set({ consumedAt: code.createdAt })
.where(
and(
eq(loginCodes.discordUserId, code.discordUserId),
isNull(loginCodes.consumedAt),
),
);
await tx.insert(loginCodes).values({
tokenHash: code.tokenHash,
discordUserId: code.discordUserId,
discordUsername: code.discordUsername,
discordGlobalName: code.discordGlobalName,
expiresAt: code.expiresAt,
createdAt: code.createdAt,
});
});
},
async exchangeLoginCode(input) {
return db.transaction(async (tx) => {
const [loginCode] = await tx
.update(loginCodes)
.set({ consumedAt: input.now })
.where(
and(
eq(loginCodes.tokenHash, input.loginCodeHash),
isNull(loginCodes.consumedAt),
gt(loginCodes.expiresAt, input.now),
),
)
.returning();
if (!loginCode) {
return null;
}
const [existingUser] = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.discordUserId, loginCode.discordUserId))
.limit(1);
const [user] = await tx
.insert(users)
.values({
discordUserId: loginCode.discordUserId,
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
})
.onConflictDoUpdate({
target: users.discordUserId,
set: {
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
updatedAt: input.now,
},
})
.returning({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
firstName: users.firstName,
});
if (!user) {
throw new Error("Failed to create or update the Discord user");
}
await tx.insert(sessions).values({
userId: user.id,
tokenHash: input.sessionTokenHash,
expiresAt: input.sessionExpiresAt,
lastSeenAt: input.now,
createdAt: input.now,
});
return { user, isNewUser: !existingUser };
});
},
};
}
export async function findUserBySessionToken(db: Database, tokenHash: string, now = new Date()) {
const [user] = await db
.select({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
firstName: users.firstName,
onboardingCompletedAt: users.onboardingCompletedAt,
sessionExpiresAt: sessions.expiresAt,
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(
and(
eq(sessions.tokenHash, tokenHash),
isNull(sessions.revokedAt),
gt(sessions.expiresAt, now),
),
)
.limit(1);
return user ?? null;
}
+35
View File
@@ -0,0 +1,35 @@
import { randomUUID } from "node:crypto";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import { events } from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
export interface RecordEventInput {
type: string;
source: string;
subject?: string;
data?: Record<string, unknown>;
actorUserId?: string;
ipAddress?: string;
correlationId?: string;
time?: Date;
}
export async function recordEvent(db: Database, input: RecordEventInput) {
const id = randomUUID();
await db.insert(events).values({
id,
specVersion: "1.0",
source: input.source,
type: input.type,
subject: input.subject,
time: input.time ?? new Date(),
dataContentType: "application/json",
data: input.data ?? {},
actorUserId: input.actorUserId,
ipAddress: input.ipAddress,
correlationId: input.correlationId,
});
return id;
}
+2
View File
@@ -10,4 +10,6 @@ export function createDatabase(databaseUrl: string) {
};
}
export * from "./auth-repository";
export * from "./events";
export * from "./schema";
+15 -1
View File
@@ -134,7 +134,6 @@ export const sessions = pgTable(
export const appSettings = pgTable("app_settings", {
id: text("id").primaryKey().default("default"),
discordGuildId: text("discord_guild_id"),
registrationMessage: text("registration_message")
.notNull()
.default("Please register your Minecraft account before joining."),
@@ -186,6 +185,21 @@ export const pluginCredentials = pgTable(
(table) => [uniqueIndex("plugin_credentials_server_id_uidx").on(table.serverId)],
);
export const pluginRequests = pgTable(
"plugin_requests",
{
requestId: uuid("request_id").primaryKey(),
serverId: text("server_id")
.notNull()
.references(() => pluginCredentials.serverId, { onDelete: "cascade" }),
receivedAt: timestamp("received_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date", precision: 3 }).notNull(),
},
(table) => [index("plugin_requests_expires_idx").on(table.expiresAt)],
);
export const events = pgTable(
"events",
{
+1 -1
View File
@@ -3,5 +3,5 @@
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts", "drizzle.config.ts"]
"include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@minecraft-account-manager/minecraft",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.0"
}
}
+65
View File
@@ -0,0 +1,65 @@
const MINECRAFT_USERNAME = /^[A-Za-z0-9_]{3,16}$/;
const MINECRAFT_UUID = /^[0-9a-f]{32}$/i;
const DISCORD_NICKNAME_LIMIT = 32;
export interface JavaProfile {
uuid: string;
username: string;
}
export async function lookupJavaProfile(
username: string,
request: typeof fetch = fetch,
): Promise<JavaProfile | null> {
const candidate = username.trim();
if (!MINECRAFT_USERNAME.test(candidate)) return null;
const response = await request(
`https://api.mojang.com/users/profiles/minecraft/${encodeURIComponent(candidate)}`,
{ headers: { accept: "application/json" }, cache: "no-store" },
);
if (response.status === 204 || response.status === 404) return null;
if (!response.ok) throw new Error(`Mojang profile lookup failed (${response.status})`);
const profile: unknown = await response.json();
if (!profile || typeof profile !== "object") return null;
const { id, name } = profile as { id?: unknown; name?: unknown };
if (typeof id !== "string" || !MINECRAFT_UUID.test(id)) return null;
if (typeof name !== "string" || !MINECRAFT_USERNAME.test(name)) return null;
return { uuid: id.toLowerCase(), username: name };
}
export function formatDiscordNickname(firstName: string, minecraftUsername: string) {
const suffix = ` (${minecraftUsername})`;
const availableCharacters = DISCORD_NICKNAME_LIMIT - [...suffix].length;
const shortenedName = [...firstName.trim()].slice(0, Math.max(1, availableCharacters)).join("").trimEnd();
return `${shortenedName}${suffix}`;
}
export async function updateGuildNickname(
input: {
guildId: string;
discordUserId: string;
nickname: string;
botToken: string;
},
request: typeof fetch = fetch,
) {
const response = await request(
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
{
method: "PATCH",
headers: {
authorization: `Bot ${input.botToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ nick: input.nickname }),
},
);
if (!response.ok) {
throw new Error(`Discord nickname update failed (${response.status})`);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { updateGuildNickname } from "../src/index";
describe("Discord nickname updates", () => {
it("updates a member in the configured guild using bot authentication", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
await updateGuildNickname(
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
request,
);
expect(request).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/123456789012345678/members/987654321098765432",
expect.objectContaining({ method: "PATCH", body: JSON.stringify({ nick: "Sam (Notch)" }) }),
);
});
it("reports Discord permission failures without pretending the nickname changed", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response("Missing Permissions", { status: 403 }));
await expect(
updateGuildNickname(
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
request,
),
).rejects.toThrow("Discord nickname update failed (403)");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest";
import { formatDiscordNickname, lookupJavaProfile } from "../src/index";
describe("Java Edition profiles", () => {
it("returns Mojang's canonical UUID and username", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ id: "069a79f444e94726a5befca90e38aaf5", name: "Notch" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(lookupJavaProfile("notch", request)).resolves.toEqual({
uuid: "069a79f444e94726a5befca90e38aaf5",
username: "Notch",
});
});
it("returns null when Mojang does not recognize the username", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
await expect(lookupJavaProfile("UnknownPlayer", request)).resolves.toBeNull();
});
it("preserves the Minecraft username while fitting Discord's nickname limit", () => {
expect(formatDiscordNickname("Alexandria Catherine", "SixteenCharName1")).toBe(
"Alexandria Ca (SixteenCharName1)",
);
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["vitest/globals"] },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@minecraft-account-manager/network",
"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" }
}
+30
View File
@@ -0,0 +1,30 @@
import { isIP } from "node:net";
export type IpClassification = "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor";
export interface IpIntelligenceResult {
classification: IpClassification;
provider: string | null;
rawResponse?: Record<string, unknown>;
}
export interface IpIntelligenceProvider {
classify(ipAddress: string): Promise<IpIntelligenceResult>;
}
export class NoopIpIntelligenceProvider implements IpIntelligenceProvider {
async classify(_ipAddress: string): Promise<IpIntelligenceResult> {
return { classification: "unknown", provider: null };
}
}
export function getClientIp(headers: Headers, trustProxy: boolean) {
if (!trustProxy) return null;
const candidate =
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
headers.get("x-real-ip")?.trim() ||
null;
return candidate && isIP(candidate) ? candidate : null;
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getClientIp, NoopIpIntelligenceProvider } from "../src/index";
describe("client IP extraction", () => {
it("ignores spoofable forwarding headers unless a trusted proxy is configured", () => {
const headers = new Headers({ "x-forwarded-for": "203.0.113.1" });
expect(getClientIp(headers, false)).toBeNull();
});
it("uses the first valid address supplied by a trusted proxy", () => {
const headers = new Headers({ "x-forwarded-for": "203.0.113.1, 10.0.0.2" });
expect(getClientIp(headers, true)).toBe("203.0.113.1");
});
it("rejects malformed proxy values", () => {
expect(getClientIp(new Headers({ "x-forwarded-for": "not-an-ip" }), true)).toBeNull();
});
});
describe("VPN intelligence", () => {
it("explicitly reports unknown when no provider is configured", async () => {
await expect(new NoopIpIntelligenceProvider().classify("203.0.113.1")).resolves.toEqual({
classification: "unknown",
provider: null,
});
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["node", "vitest/globals"] },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
.gradle/
build/
View File
+23 -2
View File
@@ -1,3 +1,24 @@
# Velocity plugin
# Velocity admission plugin
The fail-closed Velocity admission plugin will be implemented after the web API contract is complete. Unknown players and unavailable API responses will be denied with the configured registration message.
The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, and unknown requests are denied.
## Build
```bash
cd plugins/velocity
./gradlew clean test shadowJar
```
Copy `build/libs/minecraft-account-manager-velocity-0.1.0.jar` to Velocity's `plugins/` directory and start Velocity once to create `plugins/minecraft-account-manager/config.properties`.
## Provision a credential
From the repository root, with `DATABASE_URL` configured:
```bash
npm run plugin:create-credential --workspace @minecraft-account-manager/database -- velocity-main
```
Copy the displayed token into the plugin's `api-token`. Configure the HTTPS account-manager URL and ensure `server-id` matches. Restrict access to the plugin configuration because it contains the bearer token, then restart Velocity.
The proxy must run in online mode. Unknown players and API failures receive the configured registration message.
+38
View File
@@ -0,0 +1,38 @@
plugins {
java
id("com.gradleup.shadow") version "8.3.9"
}
group = "games.twentyfaces"
version = "0.1.0"
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
}
dependencies {
compileOnly("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT")
annotationProcessor("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT")
implementation("com.google.code.gson:gson:2.13.2")
testImplementation(platform("org.junit:junit-bom:5.14.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(17))
}
tasks.test {
useJUnitPlatform()
}
tasks.shadowJar {
archiveClassifier.set("")
relocate("com.google.gson", "games.twentyfaces.accountmanager.lib.gson")
}
tasks.build {
dependsOn(tasks.shadowJar)
}
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+1
View File
@@ -0,0 +1 @@
rootProject.name = "minecraft-account-manager-velocity"
@@ -0,0 +1,7 @@
package games.twentyfaces.accountmanager;
record AccessDecision(boolean allowed, String message) {
static AccessDecision denied(String message) {
return new AccessDecision(false, message);
}
}
@@ -0,0 +1,70 @@
package games.twentyfaces.accountmanager;
import com.google.gson.Gson;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.UUID;
final class AccountManagerClient {
private final PluginConfig config;
private final HttpClient httpClient;
private final Gson gson = new Gson();
AccountManagerClient(PluginConfig config) {
this(config, HttpClient.newBuilder().connectTimeout(config.timeout()).build());
}
AccountManagerClient(PluginConfig config, HttpClient httpClient) {
this.config = config;
this.httpClient = httpClient;
}
AccessDecision check(UUID minecraftUuid, String username, String ipAddress) {
String compactUuid = minecraftUuid.toString().replace("-", "").toLowerCase();
AccessRequest payload = new AccessRequest(
UUID.randomUUID().toString(),
config.serverId(),
compactUuid,
username,
ipAddress,
Instant.now().toString()
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(config.apiUrl() + "/api/velocity/access"))
.timeout(config.timeout())
.header("Authorization", "Bearer " + config.apiToken())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) return AccessDecision.denied(config.registrationMessage());
AccessDecision decision = gson.fromJson(response.body(), AccessDecision.class);
if (decision == null) return AccessDecision.denied(config.registrationMessage());
if (!decision.allowed() && (decision.message() == null || decision.message().isBlank())) {
return AccessDecision.denied(config.registrationMessage());
}
return decision;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return AccessDecision.denied(config.registrationMessage());
} catch (IOException | RuntimeException exception) {
return AccessDecision.denied(config.registrationMessage());
}
}
private record AccessRequest(
String requestId,
String serverId,
String minecraftUuid,
String username,
String ipAddress,
String occurredAt
) {}
}
@@ -0,0 +1,69 @@
package games.twentyfaces.accountmanager;
import com.google.inject.Inject;
import com.velocitypowered.api.event.EventTask;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.ResultedEvent;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import java.io.IOException;
import java.nio.file.Path;
import net.kyori.adventure.text.Component;
import org.slf4j.Logger;
@Plugin(
id = "minecraft-account-manager",
name = "Minecraft Account Manager",
version = "0.1.0",
description = "Fail-closed admission checks for registered Java accounts"
)
public final class MinecraftAccountManagerPlugin {
private final Logger logger;
private final Path dataDirectory;
private volatile AccountManagerClient accountManagerClient;
private volatile String fallbackMessage = "Please register your Minecraft account in Discord before joining.";
@Inject
public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory) {
this.logger = logger;
this.dataDirectory = dataDirectory;
}
@Subscribe
public void onProxyInitialization(ProxyInitializeEvent event) {
try {
PluginConfig config = PluginConfig.load(dataDirectory);
fallbackMessage = config.registrationMessage();
accountManagerClient = new AccountManagerClient(config);
logger.info("Minecraft account admission checks configured for server {}", config.serverId());
} catch (IOException | RuntimeException exception) {
accountManagerClient = null;
logger.error("Account manager configuration failed; all joins will be denied", exception);
}
}
@Subscribe
public EventTask onLogin(LoginEvent event) {
return EventTask.async(() -> {
AccountManagerClient client = accountManagerClient;
if (client == null) {
event.setResult(ResultedEvent.ComponentResult.denied(Component.text(fallbackMessage)));
return;
}
String ipAddress = event.getPlayer().getRemoteAddress().getAddress().getHostAddress();
AccessDecision decision = client.check(
event.getPlayer().getUniqueId(),
event.getPlayer().getUsername(),
ipAddress
);
if (!decision.allowed()) {
event.setResult(ResultedEvent.ComponentResult.denied(Component.text(decision.message())));
logger.info("Denied Minecraft login for {} ({})", event.getPlayer().getUsername(), event.getPlayer().getUniqueId());
}
});
}
}
@@ -0,0 +1,42 @@
package games.twentyfaces.accountmanager;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Properties;
record PluginConfig(String apiUrl, String serverId, String apiToken, Duration timeout, String registrationMessage) {
static PluginConfig load(Path dataDirectory) throws IOException {
Files.createDirectories(dataDirectory);
Path configPath = dataDirectory.resolve("config.properties");
if (Files.notExists(configPath)) {
try (InputStream defaults = PluginConfig.class.getResourceAsStream("/config.properties")) {
if (defaults == null) throw new IOException("Bundled config.properties is missing");
Files.copy(defaults, configPath);
}
}
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(configPath)) {
properties.load(input);
}
String apiUrl = required(properties, "api-url").replaceAll("/+$", "");
String serverId = required(properties, "server-id");
String apiToken = required(properties, "api-token");
long timeoutMillis = Long.parseLong(properties.getProperty("request-timeout-ms", "3000"));
String message = properties.getProperty(
"registration-message",
"Please register your Minecraft account in Discord before joining."
).trim();
return new PluginConfig(apiUrl, serverId, apiToken, Duration.ofMillis(timeoutMillis), message);
}
private static String required(Properties properties, String name) {
String value = properties.getProperty(name, "").trim();
if (value.isEmpty()) throw new IllegalArgumentException(name + " must be configured");
return value;
}
}
@@ -0,0 +1,6 @@
# Base URL of the Next.js service, without a trailing slash
api-url=http://localhost:3000
server-id=velocity-main
api-token=replace-with-generated-token
request-timeout-ms=3000
registration-message=Please register your Minecraft account in Discord before joining.
@@ -0,0 +1,30 @@
package games.twentyfaces.accountmanager;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class AccountManagerClientTest {
@Test
void deniesPlayersWhenTheApiCannotBeReached() {
PluginConfig config = new PluginConfig(
"http://127.0.0.1:1",
"velocity-test",
"test-token",
Duration.ofMillis(100),
"Register through Discord."
);
AccessDecision decision = new AccountManagerClient(config).check(
UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"),
"Notch",
"203.0.113.10"
);
assertFalse(decision.allowed());
assertEquals("Register through Discord.", decision.message());
}
}