Add shared port registry workflow and improve scaffold tooling

This commit is contained in:
2026-05-19 21:22:34 -04:00
parent 107f8a2691
commit 3b668c9ced
33 changed files with 2235 additions and 2 deletions

View File

@@ -0,0 +1,172 @@
// organisms/DiscordAuthWidget.scss
//
// NOTE: Update the three @use paths below to match the target project's style
// foundation before placing this file. Remove any imports that are unused.
//
// @use '<path-to>/global-color' as color;
// @use '<path-to>/global-variables' as vars;
// @use '<path-to>/global-mixins' as mix;
//
// Replace vars.$radius-full, vars.$font-body, vars.$radius-md, vars.$radius-sm,
// vars.$z-topbar, and vars.$shadow-card with the target project's equivalents,
// or convert them to CSS custom properties.
$discord-blurple: #5865f2;
$discord-blurple-hover: #4752c4;
$avatar-size: 2rem;
.discord-auth-widget {
position: relative;
display: inline-flex;
align-items: center;
}
// ── Login button ───────────────────────────────────────────────────────────
.discord-auth-widget__login-btn {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.9rem;
border: none;
border-radius: 9999px;
background: $discord-blurple;
color: #fff;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
&:hover {
background: $discord-blurple-hover;
}
&:active {
transform: scale(0.97);
}
}
.discord-auth-widget__discord-logo {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
}
// ── Profile trigger ────────────────────────────────────────────────────────
.discord-auth-widget__profile {
position: relative;
}
.discord-auth-widget__profile-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.55rem 0.3rem 0.3rem;
border: 1px solid var(--line-soft);
border-radius: 9999px;
background: var(--bg-surface);
color: var(--text-main);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
&:hover,
&.is-open {
background: var(--bg-filter);
border-color: var(--line-strong);
}
}
.discord-auth-widget__avatar {
width: $avatar-size;
height: $avatar-size;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
&--initials {
display: inline-flex;
align-items: center;
justify-content: center;
background: $discord-blurple;
color: #fff;
font-size: 0.85rem;
font-weight: 700;
}
}
.discord-auth-widget__display-name {
max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discord-auth-widget__chevron {
width: 0.65rem;
height: 0.65rem;
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.18s;
.is-open & {
transform: rotate(180deg);
}
}
// ── Dropdown menu ──────────────────────────────────────────────────────────
.discord-auth-widget__menu {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
min-width: 9rem;
padding: 0.3rem;
border: 1px solid var(--line-strong);
border-radius: 0.5rem;
background: var(--bg-surface-strong);
box-shadow: var(--shadow-card);
z-index: 100;
}
.discord-auth-widget__menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.25rem;
background: transparent;
color: var(--text-main);
font-size: 0.85rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background 0.12s, color 0.12s;
&:hover {
background: var(--bg-filter);
}
&--danger {
color: var(--red);
&:hover {
background: rgba(243, 67, 83, 0.1);
}
}
}
// ── Menu transition ────────────────────────────────────────────────────────
.discord-menu-enter-active,
.discord-menu-leave-active {
transition: opacity 0.15s, transform 0.15s;
}
.discord-menu-enter-from,
.discord-menu-leave-to {
opacity: 0;
transform: translateY(-0.3rem);
}

View File

@@ -0,0 +1,9 @@
import DiscordAuthWidget from './DiscordAuthWidget.vue';
export default {
title: 'Organisms/DiscordAuthWidget',
component: DiscordAuthWidget,
};
export const LoggedOut = {};
export const LoggedIn = {};

View File

@@ -0,0 +1,120 @@
<script setup>
import { onUnmounted, ref, watch } from "vue";
import { useAuth } from "../../composables/useAuth.js";
const { isLoading, isLoggedIn, login, logout, user } = useAuth();
const menuOpen = ref(false);
const wrapperRef = ref(null);
function toggleMenu() {
menuOpen.value = !menuOpen.value;
}
function handleLogout() {
menuOpen.value = false;
logout();
}
function handleDocumentClick(event) {
if (wrapperRef.value && !wrapperRef.value.contains(event.target)) {
menuOpen.value = false;
}
}
watch(menuOpen, (open) => {
if (open) {
document.addEventListener("click", handleDocumentClick);
} else {
document.removeEventListener("click", handleDocumentClick);
}
});
onUnmounted(() => {
document.removeEventListener("click", handleDocumentClick);
});
</script>
<template>
<div
v-if="!isLoading"
ref="wrapperRef"
class="discord-auth-widget"
>
<!-- Logged out -->
<button
v-if="!isLoggedIn"
class="discord-auth-widget__login-btn"
type="button"
@click="login()"
>
<svg
class="discord-auth-widget__discord-logo"
viewBox="0 0 24 24"
aria-hidden="true"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Sign in with Discord
</button>
<!-- Logged in -->
<div v-else class="discord-auth-widget__profile">
<button
class="discord-auth-widget__profile-btn"
:class="{ 'is-open': menuOpen }"
type="button"
:aria-expanded="menuOpen"
aria-haspopup="menu"
@click="toggleMenu"
>
<img
v-if="user?.avatarUrl"
:src="user.avatarUrl"
:alt="user.displayName"
class="discord-auth-widget__avatar"
referrerpolicy="no-referrer"
/>
<span
v-else
class="discord-auth-widget__avatar discord-auth-widget__avatar--initials"
aria-hidden="true"
>{{ (user?.displayName ?? '?')[0].toUpperCase() }}</span>
<span class="discord-auth-widget__display-name">{{ user?.displayName }}</span>
<svg
class="discord-auth-widget__chevron"
viewBox="0 0 10 6"
aria-hidden="true"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M1 1l4 4 4-4" />
</svg>
</button>
<Transition name="discord-menu">
<div
v-if="menuOpen"
class="discord-auth-widget__menu"
role="menu"
>
<button
class="discord-auth-widget__menu-item discord-auth-widget__menu-item--danger"
type="button"
role="menuitem"
@click="handleLogout"
>
Log out
</button>
</div>
</Transition>
</div>
</div>
</template>
<style lang="scss">
@use './DiscordAuthWidget.scss';
</style>

View File

@@ -0,0 +1,60 @@
import { ref, computed } from "vue";
// Module-level singleton — shared across all composable call sites.
const user = ref(null);
const features = ref([]);
const isLoading = ref(false);
let sessionChecked = false;
const isLoggedIn = computed(() => !!user.value);
async function checkSession() {
if (sessionChecked) return;
isLoading.value = true;
try {
const res = await fetch("/api/auth/session", { credentials: "include" });
if (res.ok) {
const data = await res.json();
user.value = data.user ?? null;
features.value = data.features ?? [];
}
} catch {
// No session or auth server not reachable — stay logged out.
} finally {
isLoading.value = false;
sessionChecked = true;
}
}
function login(returnTo = window.location.pathname) {
window.location.href = `/api/auth/discord/start?returnTo=${encodeURIComponent(returnTo)}`;
}
async function logout() {
try {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
} finally {
user.value = null;
features.value = [];
sessionChecked = false;
}
}
function setSession(data) {
user.value = data.user ?? null;
features.value = data.features ?? [];
sessionChecked = true;
}
export function useAuth() {
return {
user,
features,
isLoading,
isLoggedIn,
checkSession,
login,
logout,
setSession,
};
}

View File

@@ -0,0 +1,68 @@
<script setup>
import { onMounted, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuth } from "../composables/useAuth.js";
const router = useRouter();
const route = useRoute();
const { setSession } = useAuth();
const error = ref(null);
onMounted(async () => {
const provider = `${route.query.provider || ""}`.toLowerCase();
const code = `${route.query.code || ""}`;
const state = `${route.query.state || ""}`;
if (!provider || !code || !state) {
error.value = "Missing OAuth callback parameters.";
return;
}
try {
const res = await fetch("/api/auth/callback", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, code, state }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
error.value = data.error || `Auth failed (${res.status}).`;
return;
}
const data = await res.json();
setSession(data);
const returnTo = `${data.returnTo || "/"}`;
router.replace(returnTo.startsWith("/") ? returnTo : "/");
} catch {
error.value = "Could not reach the auth server. Try again.";
}
});
</script>
<template>
<div class="oauth-callback-page">
<p v-if="error" class="oauth-callback-error">{{ error }}</p>
<p v-else class="oauth-callback-loading">Completing sign-in</p>
</div>
</template>
<style scoped>
.oauth-callback-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
font-family: var(--font-body, sans-serif);
color: var(--color-text-muted, #666);
}
.oauth-callback-error {
color: var(--color-danger, #c0392b);
}
</style>

View File

@@ -0,0 +1,9 @@
AUTH_PORT=8787
NODE_ENV=development
JWT_SECRET=replace-me
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=30
COOKIE_NAME=gopvp_refresh
COOKIE_SECURE=false
COOKIE_SAME_SITE=lax
AUTH_CLIENTS_FILE=src/server/discord-oauth/clients.json

View File

@@ -0,0 +1,39 @@
# discord-oauth
This bundle lives under `src/server/discord-oauth` and provides a Discord OAuth auth service with PKCE, session rotation, and allowlist support.
## Quickstart
1. Copy `clients.example.json` to `clients.json`.
2. Fill in the Discord client ID, client secret, and allowlist entries.
3. Set `JWT_SECRET` and the other auth env vars.
4. Decide how to run the auth server next:
- If the app already has a Node backend, import or mount this bundle there.
- If the app is frontend-only, add an `auth:dev` script that runs `node src/server/discord-oauth/server.js` and proxy `/api/auth` to that port from Vite.
5. Start the app and test login, callback, session refresh, and logout.
## Wiring Options
### Option 1: Separate auth process
Add a package script such as:
```json
{
"scripts": {
"auth:dev": "node src/server/discord-oauth/server.js"
}
}
```
Then point your Vite proxy at the auth server port for `/api/auth` requests.
### Option 2: Existing Node backend
If the project already has an Express or Node entrypoint, import this bundle there and start it alongside the rest of the backend so the frontend can reach the same `/api/auth/*` routes.
## Notes
- The callback route should match the Discord redirect URI.
- Keep secrets out of committed files.
- Replace the placeholder frontend origin and allowlist values before running.

View File

@@ -0,0 +1,31 @@
function normalizeDiscordUser(profile) {
return {
id: profile.id,
email: profile.email,
username: profile.username,
displayName: profile.global_name || profile.username,
avatarUrl: profile.avatar
? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
: "",
provider: "discord",
};
}
export function resolveEntitlements(profile, authClient) {
const user = normalizeDiscordUser(profile);
const allowed = authClient.allowlistDiscordIds.has(`${user.id || ""}`);
if (!allowed) {
return {
allowed: false,
user,
features: [],
};
}
return {
allowed: true,
user,
features: authClient.defaultFeatureKeys,
};
}

View File

@@ -0,0 +1,11 @@
[
{
"key": "discord-oauth-local",
"frontendOrigin": "__FRONTEND_ORIGIN__",
"discordClientId": "",
"discordClientSecret": "",
"discordScopes": __SCOPES_JSON__,
"allowlistDiscordIds": __ALLOWLIST_DISCORD_IDS_JSON__,
"defaultFeatureKeys": ["feature.auth"]
}
]

View File

@@ -0,0 +1,161 @@
import "dotenv/config";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
function parseCsv(value) {
return `${value || ""}`
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function normalizeOrigin(value) {
if (!value) {
return "";
}
try {
return new URL(value).origin;
} catch {
return "";
}
}
function defaultFeatureKeys() {
return parseCsv(
process.env.DEFAULT_FEATURE_KEYS ||
"feature.team-list,feature.bills-pc,feature.team-coach",
);
}
function normalizeClient(rawClient) {
const frontendOrigin = normalizeOrigin(rawClient.frontendOrigin);
return {
key: `${rawClient.key || ""}`,
frontendOrigin,
discordClientId: `${rawClient.discordClientId || ""}`,
discordClientSecret: `${rawClient.discordClientSecret || ""}`,
discordScopes: Array.isArray(rawClient.discordScopes)
? rawClient.discordScopes
: parseCsv(rawClient.discordScopes || "identify,email"),
allowlistDiscordIds: new Set(
Array.isArray(rawClient.allowlistDiscordIds)
? rawClient.allowlistDiscordIds.map((entry) => `${entry}`)
: parseCsv(rawClient.allowlistDiscordIds || ""),
),
defaultFeatureKeys: Array.isArray(rawClient.defaultFeatureKeys)
? rawClient.defaultFeatureKeys
: parseCsv(rawClient.defaultFeatureKeys || defaultFeatureKeys().join(",")),
};
}
function validateClient(client) {
if (!client.key) {
throw new Error("Auth client is missing key.");
}
if (!client.frontendOrigin) {
throw new Error(`Auth client ${client.key} is missing frontendOrigin.`);
}
}
function parseClientsFromJson(jsonText) {
if (!jsonText) {
return [];
}
const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) {
throw new Error("Auth clients config must be an array.");
}
return parsed.map((entry) => normalizeClient(entry));
}
function resolveClientsFilePath(configPath) {
if (!configPath) {
return "";
}
if (path.isAbsolute(configPath)) {
return configPath;
}
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..", configPath);
}
function loadClientsFromFile(configPath) {
const resolvedPath = resolveClientsFilePath(configPath.trim());
const fileText = fs.readFileSync(resolvedPath, "utf8");
const clients = parseClientsFromJson(fileText);
clients.forEach(validateClient);
return clients;
}
function loadAuthClients() {
const configPath = process.env.AUTH_CLIENTS_FILE || "";
const jsonText = process.env.AUTH_CLIENTS_JSON || "";
if (jsonText.trim()) {
const clients = parseClientsFromJson(jsonText);
clients.forEach(validateClient);
return clients;
}
if (configPath.trim()) {
return loadClientsFromFile(configPath);
}
const localDefaultPath = "src/server/discord-oauth/clients.json";
const resolvedDefaultPath = resolveClientsFilePath(localDefaultPath);
if (fs.existsSync(resolvedDefaultPath)) {
return loadClientsFromFile(localDefaultPath);
}
const legacyClient = normalizeClient({
key: "legacy-local",
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
discordClientId: process.env.DISCORD_CLIENT_ID || "",
discordClientSecret: process.env.DISCORD_CLIENT_SECRET || "",
discordScopes: parseCsv(process.env.DISCORD_SCOPES || "identify,email"),
allowlistDiscordIds: parseCsv(process.env.ALLOWLIST_DISCORD_IDS || ""),
defaultFeatureKeys: defaultFeatureKeys(),
});
validateClient(legacyClient);
return [legacyClient];
}
const authClients = loadAuthClients();
const authClientsByOrigin = new Map(
authClients.map((client) => [client.frontendOrigin, client]),
);
const authClientsByKey = new Map(authClients.map((client) => [client.key, client]));
const nodeEnv = process.env.NODE_ENV || "development";
const jwtSecret = process.env.JWT_SECRET || "";
if (nodeEnv !== "development" && !jwtSecret.trim()) {
throw new Error("JWT_SECRET is required when NODE_ENV is not development.");
}
export const config = {
port: Number(process.env.PORT || 8787),
jwtSecret: jwtSecret || "dev-only-secret-change-me",
accessTokenTtl: process.env.ACCESS_TOKEN_TTL || "15m",
refreshTokenTtlDays: Number(process.env.REFRESH_TOKEN_TTL_DAYS || 30),
cookieName: process.env.COOKIE_NAME || "gopvp_refresh",
cookieSecure: `${process.env.COOKIE_SECURE || "false"}` === "true",
cookieSameSite: process.env.COOKIE_SAME_SITE || "lax",
authClients,
};
export function getAuthClientByOrigin(origin) {
return authClientsByOrigin.get(normalizeOrigin(origin));
}
export function getAuthClientByKey(clientKey) {
return authClientsByKey.get(`${clientKey || ""}`);
}

View File

@@ -0,0 +1,46 @@
function getCrypto() {
if (globalThis.crypto?.subtle && globalThis.crypto?.getRandomValues) {
return globalThis.crypto;
}
throw new Error("Web Crypto API is required for PKCE utilities.");
}
function toBase64Url(bytes) {
let base64;
if (typeof globalThis.btoa === "function") {
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
base64 = globalThis.btoa(binary);
} else {
base64 = Buffer.from(bytes).toString("base64");
}
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function createOAuthState(byteLength = 24) {
const cryptoObject = getCrypto();
const bytes = new Uint8Array(byteLength);
cryptoObject.getRandomValues(bytes);
return toBase64Url(bytes);
}
export function createCodeVerifier(byteLength = 64) {
const cryptoObject = getCrypto();
const bytes = new Uint8Array(byteLength);
cryptoObject.getRandomValues(bytes);
return toBase64Url(bytes);
}
export async function createCodeChallenge(codeVerifier) {
const cryptoObject = getCrypto();
const encoder = new TextEncoder();
const digest = await cryptoObject.subtle.digest(
"SHA-256",
encoder.encode(codeVerifier),
);
return toBase64Url(new Uint8Array(digest));
}

View File

@@ -0,0 +1,26 @@
function buildAuthUrl(baseUrl, params) {
const url = new URL(baseUrl);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.length > 0) {
url.searchParams.set(key, `${value}`);
}
});
return url.toString();
}
export function buildDiscordAuthorizationUrl(options) {
const scopes = Array.isArray(options.scopes)
? options.scopes.join(" ")
: options.scopes || "identify email";
return buildAuthUrl("https://discord.com/oauth2/authorize", {
client_id: options.clientId,
redirect_uri: options.redirectUri,
response_type: "code",
scope: scopes,
state: options.state,
code_challenge: options.codeChallenge,
code_challenge_method: options.codeChallenge ? "S256" : undefined,
prompt: options.prompt,
});
}

View File

@@ -0,0 +1,46 @@
export async function exchangeDiscordCode({
code,
codeVerifier,
clientId,
clientSecret,
redirectUri,
}) {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
code_verifier: codeVerifier,
grant_type: "authorization_code",
redirect_uri: redirectUri,
});
const response = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Discord token exchange failed: ${text}`);
}
return response.json();
}
export async function fetchDiscordProfile(accessToken) {
const response = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Discord profile fetch failed: ${text}`);
}
return response.json();
}

View File

@@ -0,0 +1,304 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import {
buildDiscordAuthorizationUrl,
} from "./lib/oauth/providers.js";
import {
createCodeChallenge,
createCodeVerifier,
createOAuthState,
} from "./lib/oauth/pkce.js";
import { config, getAuthClientByKey, getAuthClientByOrigin } from "./config.js";
import { resolveEntitlements } from "./allowlist.js";
import { SessionStore } from "./sessionStore.js";
import {
exchangeDiscordCode,
fetchDiscordProfile,
} from "./providers/discord.js";
const app = express();
const store = new SessionStore({ refreshTokenTtlDays: config.refreshTokenTtlDays });
app.use(
cors({
origin(origin, callback) {
if (!origin) {
callback(null, true);
return;
}
const authClient = getAuthClientByOrigin(origin);
if (authClient) {
callback(null, true);
return;
}
callback(new Error("Origin is not allowlisted for auth."));
},
credentials: true,
}),
);
app.use(express.json());
app.use(cookieParser());
function getRequestOrigin(req) {
const originHeader = req.get("origin");
if (originHeader) {
return originHeader;
}
const refererHeader = req.get("referer");
if (refererHeader) {
try {
return new URL(refererHeader).origin;
} catch {
// Ignore malformed Referer and continue to host/proto fallback.
}
}
const proto = req.get("x-forwarded-proto") || req.protocol || "http";
const host = req.get("x-forwarded-host") || req.get("host") || "";
if (!host) {
return "";
}
return `${proto}://${host}`;
}
function buildUserPayload(record) {
return {
user: record.user,
features: record.features,
};
}
function getCookieOptions(expiresAt) {
return {
httpOnly: true,
secure: config.cookieSecure,
sameSite: config.cookieSameSite,
path: "/",
expires: new Date(expiresAt),
};
}
function signAccessToken(record) {
return jwt.sign(
{
sub: record.user.id,
provider: record.user.provider,
features: record.features,
email: record.user.email || "",
},
config.jwtSecret,
{
expiresIn: config.accessTokenTtl,
},
);
}
function ensureProvider(provider) {
return provider === "discord";
}
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.get("/api/auth/:provider/start", async (req, res) => {
const provider = `${req.params.provider || ""}`.toLowerCase();
if (!ensureProvider(provider)) {
res.status(400).json({ error: "Unsupported provider." });
return;
}
const authClient = getAuthClientByOrigin(getRequestOrigin(req));
if (!authClient) {
res.status(403).json({ error: "Unknown or unauthorized frontend origin." });
return;
}
if (!authClient.discordClientId || !authClient.discordClientSecret) {
res.status(503).json({
error:
"Discord OAuth credentials are not configured for this frontend origin.",
});
return;
}
const returnTo = `${req.query.returnTo || "/"}`;
const state = createOAuthState();
const codeVerifier = createCodeVerifier();
const codeChallenge = await createCodeChallenge(codeVerifier);
store.createOAuthState({
state,
provider,
returnTo,
codeVerifier,
clientKey: authClient.key,
});
const redirectUri = `${authClient.frontendOrigin}/oauth/callback?provider=${provider}`;
const authUrl = buildDiscordAuthorizationUrl({
clientId: authClient.discordClientId,
redirectUri,
state,
scopes: authClient.discordScopes,
codeChallenge,
prompt: "consent",
});
res.redirect(authUrl);
});
app.post("/api/auth/callback", async (req, res) => {
const provider = `${req.body?.provider || ""}`.toLowerCase();
const code = `${req.body?.code || ""}`;
const state = `${req.body?.state || ""}`;
if (!ensureProvider(provider) || !code || !state) {
res.status(400).json({ error: "Invalid callback payload." });
return;
}
const oauthState = store.consumeOAuthState(state);
if (!oauthState || oauthState.provider !== provider) {
res.status(400).json({ error: "OAuth state is invalid or expired." });
return;
}
const authClient = getAuthClientByKey(oauthState.clientKey);
if (!authClient) {
res.status(400).json({ error: "Unknown auth client context." });
return;
}
if (!authClient.discordClientId || !authClient.discordClientSecret) {
res.status(503).json({
error:
"Discord OAuth credentials are not configured for this frontend origin.",
});
return;
}
try {
const redirectUri = `${authClient.frontendOrigin}/oauth/callback?provider=${provider}`;
const tokenPayload = await exchangeDiscordCode({
code,
codeVerifier: oauthState.codeVerifier,
clientId: authClient.discordClientId,
clientSecret: authClient.discordClientSecret,
redirectUri,
});
const profile = await fetchDiscordProfile(tokenPayload.access_token);
const entitlement = resolveEntitlements(profile, authClient);
if (!entitlement.allowed) {
res.status(403).json({ error: "Account is not allowlisted." });
return;
}
const sessionRecord = {
user: entitlement.user,
features: entitlement.features,
};
const refresh = store.createRefreshSession(sessionRecord);
const accessToken = signAccessToken(sessionRecord);
res.cookie(
config.cookieName,
refresh.refreshToken,
getCookieOptions(refresh.expiresAt),
);
res.json({
...buildUserPayload(sessionRecord),
accessToken,
returnTo: oauthState.returnTo,
});
} catch {
res.status(500).json({ error: "Failed to complete OAuth callback." });
}
});
app.get("/api/auth/session", (req, res) => {
const refreshToken = req.cookies[config.cookieName];
if (!refreshToken) {
res.status(401).json({ error: "No active session." });
return;
}
const sessionRecord = store.getRefreshSession(refreshToken);
if (!sessionRecord) {
res.clearCookie(config.cookieName, getCookieOptions(Date.now()));
res.status(401).json({ error: "Session expired." });
return;
}
const accessToken = signAccessToken(sessionRecord);
res.json({
...buildUserPayload(sessionRecord),
accessToken,
});
});
app.post("/api/auth/refresh", (req, res) => {
const refreshToken = req.cookies[config.cookieName];
if (!refreshToken) {
res.status(401).json({ error: "No refresh token." });
return;
}
const sessionRecord = store.getRefreshSession(refreshToken);
if (!sessionRecord) {
res.clearCookie(config.cookieName, getCookieOptions(Date.now()));
res.status(401).json({ error: "Session expired." });
return;
}
store.revokeRefreshSession(refreshToken);
const refresh = store.createRefreshSession({
user: sessionRecord.user,
features: sessionRecord.features,
});
const accessToken = signAccessToken(sessionRecord);
res.cookie(
config.cookieName,
refresh.refreshToken,
getCookieOptions(refresh.expiresAt),
);
res.json({
...buildUserPayload(sessionRecord),
accessToken,
});
});
app.post("/api/auth/logout", (req, res) => {
const refreshToken = req.cookies[config.cookieName];
if (refreshToken) {
store.revokeRefreshSession(refreshToken);
}
res.clearCookie(config.cookieName, {
httpOnly: true,
secure: config.cookieSecure,
sameSite: config.cookieSameSite,
path: "/",
});
res.status(204).send();
});
app.listen(config.port, () => {
console.log(`discord-oauth auth service running on http://localhost:${config.port}`);
});

View File

@@ -0,0 +1,74 @@
import crypto from "node:crypto";
const STATE_TTL_MS = 10 * 60 * 1000;
function now() {
return Date.now();
}
function ttlToMs(days) {
return days * 24 * 60 * 60 * 1000;
}
export class SessionStore {
constructor(options = {}) {
this.oauthStates = new Map();
this.refreshSessions = new Map();
this.refreshTtlMs = ttlToMs(options.refreshTokenTtlDays || 30);
}
createOAuthState(payload) {
this.oauthStates.set(payload.state, {
...payload,
createdAt: now(),
});
}
consumeOAuthState(state) {
const record = this.oauthStates.get(state);
if (!record) {
return null;
}
this.oauthStates.delete(state);
if (now() - record.createdAt > STATE_TTL_MS) {
return null;
}
return record;
}
createRefreshSession(payload) {
const refreshToken = crypto.randomBytes(48).toString("base64url");
const expiresAt = now() + this.refreshTtlMs;
this.refreshSessions.set(refreshToken, {
...payload,
expiresAt,
});
return {
refreshToken,
expiresAt,
};
}
getRefreshSession(refreshToken) {
const record = this.refreshSessions.get(refreshToken);
if (!record) {
return null;
}
if (record.expiresAt <= now()) {
this.refreshSessions.delete(refreshToken);
return null;
}
return record;
}
revokeRefreshSession(refreshToken) {
this.refreshSessions.delete(refreshToken);
}
}