Add shared port registry workflow and improve scaffold tooling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import DiscordAuthWidget from './DiscordAuthWidget.vue';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/DiscordAuthWidget',
|
||||
component: DiscordAuthWidget,
|
||||
};
|
||||
|
||||
export const LoggedOut = {};
|
||||
export const LoggedIn = {};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"key": "discord-oauth-local",
|
||||
"frontendOrigin": "__FRONTEND_ORIGIN__",
|
||||
"discordClientId": "",
|
||||
"discordClientSecret": "",
|
||||
"discordScopes": __SCOPES_JSON__,
|
||||
"allowlistDiscordIds": __ALLOWLIST_DISCORD_IDS_JSON__,
|
||||
"defaultFeatureKeys": ["feature.auth"]
|
||||
}
|
||||
]
|
||||
@@ -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 || ""}`);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user