All checks were successful
Build Frontend / Build Frontend (push) Successful in 22s
- Fixed login URL from /auth/steam to /api/auth/steam - Updated all Steam login buttons to custom green design with 'Login to Steam' text - Enhanced CORS configuration with explicit preflight handling - Added Steam image proxy endpoint for CORS-free image loading - Improved environment variable management with .env.local support - Added ENV_SETUP.md guide for environment configuration
563 lines
17 KiB
JavaScript
563 lines
17 KiB
JavaScript
import { generateTokenPair } from "../utils/jwt.js";
|
|
import {
|
|
authenticate,
|
|
verifyRefreshTokenMiddleware,
|
|
} from "../middleware/auth.js";
|
|
import { config } from "../config/index.js";
|
|
import Session from "../models/Session.js";
|
|
|
|
/**
|
|
* Authentication routes
|
|
* @param {FastifyInstance} fastify - Fastify instance
|
|
*/
|
|
export default async function authRoutes(fastify, options) {
|
|
// Debug endpoint to check cookies and headers (development only)
|
|
fastify.get("/debug-cookies", async (request, reply) => {
|
|
if (config.isProduction) {
|
|
return reply.status(404).send({ error: "Not found" });
|
|
}
|
|
|
|
// Parse the raw cookie header manually to compare
|
|
const rawCookieHeader = request.headers.cookie || "";
|
|
const manualParsedCookies = {};
|
|
if (rawCookieHeader) {
|
|
rawCookieHeader.split(";").forEach((cookie) => {
|
|
const [name, ...valueParts] = cookie.trim().split("=");
|
|
if (name) {
|
|
manualParsedCookies[name] = valueParts.join("=");
|
|
}
|
|
});
|
|
}
|
|
|
|
return reply.send({
|
|
success: true,
|
|
cookies: request.cookies || {},
|
|
manualParsedCookies: manualParsedCookies,
|
|
rawCookieHeader: rawCookieHeader,
|
|
headers: {
|
|
authorization: request.headers.authorization || null,
|
|
origin: request.headers.origin || null,
|
|
referer: request.headers.referer || null,
|
|
cookie: request.headers.cookie ? "Present" : "Missing",
|
|
host: request.headers.host || null,
|
|
},
|
|
hasAccessToken: !!request.cookies?.accessToken,
|
|
hasRefreshToken: !!request.cookies?.refreshToken,
|
|
manualHasAccessToken: !!manualParsedCookies.accessToken,
|
|
manualHasRefreshToken: !!manualParsedCookies.refreshToken,
|
|
config: {
|
|
cookieDomain: config.cookie.domain,
|
|
cookieSecure: config.cookie.secure,
|
|
cookieSameSite: config.cookie.sameSite,
|
|
corsOrigin: config.cors.origin,
|
|
},
|
|
note: "If request.cookies is empty but manualParsedCookies has data, then @fastify/cookie isn't parsing correctly",
|
|
});
|
|
});
|
|
|
|
// Test endpoint to verify Steam configuration
|
|
fastify.get("/steam/test", async (request, reply) => {
|
|
return reply.send({
|
|
success: true,
|
|
steamConfig: {
|
|
apiKeySet: !!config.steam.apiKey,
|
|
realm: config.steam.realm,
|
|
returnURL: config.steam.returnURL,
|
|
},
|
|
message: "Steam authentication is configured. Try /auth/steam to login.",
|
|
});
|
|
});
|
|
|
|
// Steam image proxy - proxy Steam CDN images to avoid CORS issues
|
|
fastify.get("/steam/image-proxy", async (request, reply) => {
|
|
try {
|
|
const { url } = request.query;
|
|
|
|
if (!url) {
|
|
return reply.status(400).send({
|
|
error: "BadRequest",
|
|
message: "Image URL is required",
|
|
});
|
|
}
|
|
|
|
// Validate that it's a Steam CDN URL
|
|
const validDomains = [
|
|
"community.steamstatic.com",
|
|
"community.cloudflare.steamstatic.com",
|
|
"cdn.steamstatic.com",
|
|
"cdn.cloudflare.steamstatic.com",
|
|
"avatars.steamstatic.com",
|
|
"avatars.cloudflare.steamstatic.com",
|
|
];
|
|
|
|
const urlObj = new URL(url);
|
|
const isValidDomain = validDomains.some(
|
|
(domain) => urlObj.hostname === domain
|
|
);
|
|
|
|
if (!isValidDomain) {
|
|
return reply.status(400).send({
|
|
error: "BadRequest",
|
|
message: "Invalid Steam CDN URL",
|
|
});
|
|
}
|
|
|
|
// Fetch the image from Steam
|
|
const imageResponse = await fetch(url);
|
|
|
|
if (!imageResponse.ok) {
|
|
return reply.status(imageResponse.status).send({
|
|
error: "FetchError",
|
|
message: "Failed to fetch image from Steam CDN",
|
|
});
|
|
}
|
|
|
|
// Get the image buffer
|
|
const imageBuffer = await imageResponse.arrayBuffer();
|
|
const contentType =
|
|
imageResponse.headers.get("content-type") || "image/jpeg";
|
|
|
|
// Set appropriate headers
|
|
reply.header("Content-Type", contentType);
|
|
reply.header("Cache-Control", "public, max-age=86400"); // Cache for 24 hours
|
|
reply.header("Access-Control-Allow-Origin", config.cors.origin);
|
|
|
|
return reply.send(Buffer.from(imageBuffer));
|
|
} catch (error) {
|
|
console.error("❌ Steam image proxy error:", error);
|
|
return reply.status(500).send({
|
|
error: "ProxyError",
|
|
message: "Failed to proxy Steam image",
|
|
details: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Steam login - initiate OAuth flow
|
|
fastify.get("/steam", async (request, reply) => {
|
|
try {
|
|
// Manually construct the Steam OpenID URL
|
|
const returnURL = encodeURIComponent(config.steam.returnURL);
|
|
const realm = encodeURIComponent(config.steam.realm);
|
|
|
|
const steamOpenIDURL =
|
|
`https://steamcommunity.com/openid/login?` +
|
|
`openid.mode=checkid_setup&` +
|
|
`openid.ns=http://specs.openid.net/auth/2.0&` +
|
|
`openid.identity=http://specs.openid.net/auth/2.0/identifier_select&` +
|
|
`openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&` +
|
|
`openid.return_to=${returnURL}&` +
|
|
`openid.realm=${realm}`;
|
|
|
|
return reply.redirect(steamOpenIDURL);
|
|
} catch (error) {
|
|
console.error("❌ Steam authentication error:", error);
|
|
return reply.status(500).send({
|
|
error: "AuthenticationError",
|
|
message: "Failed to initiate Steam login",
|
|
details: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Steam OAuth callback - Manual verification
|
|
fastify.get("/steam/return", async (request, reply) => {
|
|
try {
|
|
const query = request.query;
|
|
|
|
// Verify this is a valid OpenID response
|
|
if (query["openid.mode"] !== "id_res") {
|
|
return reply.status(400).send({
|
|
error: "AuthenticationError",
|
|
message: "Invalid OpenID response mode",
|
|
});
|
|
}
|
|
|
|
// Verify the response with Steam
|
|
const verifyParams = new URLSearchParams();
|
|
for (const key in query) {
|
|
verifyParams.append(key, query[key]);
|
|
}
|
|
verifyParams.set("openid.mode", "check_authentication");
|
|
|
|
const verifyResponse = await fetch(
|
|
"https://steamcommunity.com/openid/login",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: verifyParams.toString(),
|
|
}
|
|
);
|
|
|
|
const verifyText = await verifyResponse.text();
|
|
|
|
if (!verifyText.includes("is_valid:true")) {
|
|
console.error("❌ Steam OpenID verification failed");
|
|
return reply.status(401).send({
|
|
error: "AuthenticationError",
|
|
message: "Steam authentication verification failed",
|
|
});
|
|
}
|
|
|
|
// Extract Steam ID from the claimed_id
|
|
const claimedId = query["openid.claimed_id"];
|
|
const steamIdMatch = claimedId.match(/(\d+)$/);
|
|
|
|
if (!steamIdMatch) {
|
|
return reply.status(400).send({
|
|
error: "AuthenticationError",
|
|
message: "Could not extract Steam ID",
|
|
});
|
|
}
|
|
|
|
const steamId = steamIdMatch[1];
|
|
|
|
// Get user profile from Steam API
|
|
const steamApiUrl = `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${config.steam.apiKey}&steamids=${steamId}`;
|
|
const profileResponse = await fetch(steamApiUrl);
|
|
const profileData = await profileResponse.json();
|
|
|
|
if (
|
|
!profileData.response ||
|
|
!profileData.response.players ||
|
|
profileData.response.players.length === 0
|
|
) {
|
|
return reply.status(400).send({
|
|
error: "AuthenticationError",
|
|
message: "Could not fetch Steam profile",
|
|
});
|
|
}
|
|
|
|
const profile = profileData.response.players[0];
|
|
|
|
// Import User model
|
|
const User = (await import("../models/User.js")).default;
|
|
|
|
// Find or create user
|
|
let user = await User.findOne({ steamId });
|
|
|
|
if (user) {
|
|
// Update existing user
|
|
user.username = profile.personaname;
|
|
user.avatar =
|
|
profile.avatarfull || profile.avatarmedium || profile.avatar;
|
|
user.communityvisibilitystate = profile.communityvisibilitystate;
|
|
await user.save();
|
|
console.log(
|
|
`✅ Existing user logged in: ${user.username} (${steamId})`
|
|
);
|
|
} else {
|
|
// Create new user
|
|
user = new User({
|
|
username: profile.personaname,
|
|
steamId: steamId,
|
|
avatar: profile.avatarfull || profile.avatarmedium || profile.avatar,
|
|
account_creation:
|
|
profile.timecreated || Math.floor(Date.now() / 1000),
|
|
communityvisibilitystate: profile.communityvisibilitystate,
|
|
balance: 0,
|
|
staffLevel: 0,
|
|
});
|
|
await user.save();
|
|
console.log(`✅ New user registered: ${user.username} (${steamId})`);
|
|
}
|
|
|
|
// Generate JWT tokens
|
|
const { accessToken, refreshToken } = generateTokenPair(user);
|
|
|
|
// Extract device information
|
|
const userAgent = request.headers["user-agent"] || "Unknown";
|
|
const ip =
|
|
request.ip ||
|
|
request.headers["x-forwarded-for"] ||
|
|
request.headers["x-real-ip"] ||
|
|
"Unknown";
|
|
|
|
// Simple device detection
|
|
let device = "Desktop";
|
|
let browser = "Unknown";
|
|
let os = "Unknown";
|
|
|
|
if (
|
|
userAgent.includes("Mobile") ||
|
|
userAgent.includes("Android") ||
|
|
userAgent.includes("iPhone")
|
|
) {
|
|
device = "Mobile";
|
|
} else if (userAgent.includes("Tablet") || userAgent.includes("iPad")) {
|
|
device = "Tablet";
|
|
}
|
|
|
|
if (userAgent.includes("Chrome")) browser = "Chrome";
|
|
else if (userAgent.includes("Firefox")) browser = "Firefox";
|
|
else if (userAgent.includes("Safari")) browser = "Safari";
|
|
else if (userAgent.includes("Edge")) browser = "Edge";
|
|
|
|
if (userAgent.includes("Windows")) os = "Windows";
|
|
else if (userAgent.includes("Mac")) os = "macOS";
|
|
else if (userAgent.includes("Linux")) os = "Linux";
|
|
else if (userAgent.includes("Android")) os = "Android";
|
|
else if (userAgent.includes("iOS") || userAgent.includes("iPhone"))
|
|
os = "iOS";
|
|
|
|
// Create session
|
|
try {
|
|
const session = new Session({
|
|
userId: user._id,
|
|
steamId: user.steamId,
|
|
token: accessToken,
|
|
refreshToken: refreshToken,
|
|
ip: ip,
|
|
userAgent: userAgent,
|
|
device: device,
|
|
browser: browser,
|
|
os: os,
|
|
location: {
|
|
country: null, // Can integrate with IP geolocation service
|
|
city: null,
|
|
region: null,
|
|
},
|
|
isActive: true,
|
|
lastActivity: new Date(),
|
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
|
});
|
|
|
|
await session.save();
|
|
console.log(
|
|
`📱 Session created for ${user.username} from ${ip} (${device}/${browser})`
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to create session:", error);
|
|
// Don't fail login if session creation fails
|
|
}
|
|
|
|
// Set cookies
|
|
reply
|
|
.setCookie("accessToken", accessToken, {
|
|
httpOnly: true,
|
|
secure: config.cookie.secure,
|
|
sameSite: config.cookie.sameSite,
|
|
path: "/",
|
|
maxAge: 15 * 60,
|
|
domain: config.cookie.domain,
|
|
})
|
|
.setCookie("refreshToken", refreshToken, {
|
|
httpOnly: true,
|
|
secure: config.cookie.secure,
|
|
sameSite: config.cookie.sameSite,
|
|
path: "/",
|
|
maxAge: 7 * 24 * 60 * 60,
|
|
domain: config.cookie.domain,
|
|
});
|
|
|
|
console.log(`✅ User ${user.username} logged in successfully`);
|
|
|
|
// Redirect to frontend
|
|
return reply.redirect(`${config.cors.origin}/`);
|
|
} catch (error) {
|
|
console.error("❌ Steam callback error:", error);
|
|
return reply.status(500).send({
|
|
error: "AuthenticationError",
|
|
message: "Steam authentication failed",
|
|
details: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get current user
|
|
fastify.get(
|
|
"/me",
|
|
{
|
|
preHandler: authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
// Remove sensitive data
|
|
const user = request.user.toObject();
|
|
delete user.twoFactor.secret;
|
|
delete user.email.emailToken;
|
|
|
|
return reply.send({
|
|
success: true,
|
|
user: user,
|
|
});
|
|
}
|
|
);
|
|
|
|
// Decode JWT token (for debugging - shows what's in the token)
|
|
fastify.get("/decode-token", async (request, reply) => {
|
|
try {
|
|
let token = null;
|
|
|
|
// Try to get token from Authorization header
|
|
const authHeader = request.headers.authorization;
|
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
token = authHeader.substring(7);
|
|
}
|
|
|
|
// If not in header, try cookies
|
|
if (!token && request.cookies && request.cookies.accessToken) {
|
|
token = request.cookies.accessToken;
|
|
}
|
|
|
|
if (!token) {
|
|
return reply.status(400).send({
|
|
error: "NoToken",
|
|
message: "No token provided. Send in Authorization header or cookie.",
|
|
});
|
|
}
|
|
|
|
// Import JWT utilities
|
|
const { decodeToken } = await import("../utils/jwt.js");
|
|
const decoded = decodeToken(token);
|
|
|
|
if (!decoded) {
|
|
return reply.status(400).send({
|
|
error: "InvalidToken",
|
|
message: "Could not decode token",
|
|
});
|
|
}
|
|
|
|
return reply.send({
|
|
success: true,
|
|
decoded: decoded,
|
|
message: "This shows what data is stored in your JWT token",
|
|
});
|
|
} catch (error) {
|
|
return reply.status(500).send({
|
|
error: "DecodeError",
|
|
message: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Refresh access token using refresh token
|
|
fastify.post(
|
|
"/refresh",
|
|
{
|
|
preHandler: verifyRefreshTokenMiddleware,
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const user = request.user;
|
|
|
|
// Generate new token pair
|
|
const { accessToken, refreshToken } = generateTokenPair(user);
|
|
|
|
// Update existing session with new tokens
|
|
try {
|
|
const oldSession = await Session.findOne({
|
|
refreshToken: request.refreshToken,
|
|
userId: user._id,
|
|
});
|
|
|
|
if (oldSession) {
|
|
oldSession.token = accessToken;
|
|
oldSession.refreshToken = refreshToken;
|
|
oldSession.lastActivity = new Date();
|
|
await oldSession.save();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to update session:", error);
|
|
}
|
|
|
|
// Set new cookies
|
|
reply
|
|
.setCookie("accessToken", accessToken, {
|
|
httpOnly: true,
|
|
secure: config.cookie.secure,
|
|
sameSite: config.cookie.sameSite,
|
|
path: "/",
|
|
maxAge: 15 * 60, // 15 minutes
|
|
domain: config.cookie.domain,
|
|
})
|
|
.setCookie("refreshToken", refreshToken, {
|
|
httpOnly: true,
|
|
secure: config.cookie.secure,
|
|
sameSite: config.cookie.sameSite,
|
|
path: "/",
|
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
|
domain: config.cookie.domain,
|
|
});
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Tokens refreshed successfully",
|
|
accessToken,
|
|
refreshToken,
|
|
});
|
|
} catch (error) {
|
|
console.error("Token refresh error:", error);
|
|
return reply.status(500).send({
|
|
error: "InternalServerError",
|
|
message: "Failed to refresh tokens",
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// Logout - clear cookies and invalidate session
|
|
fastify.post(
|
|
"/logout",
|
|
{
|
|
preHandler: authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
// Deactivate current session
|
|
try {
|
|
const session = await Session.findOne({
|
|
token: request.token,
|
|
userId: request.user._id,
|
|
});
|
|
|
|
if (session) {
|
|
await session.deactivate();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to deactivate session:", error);
|
|
}
|
|
|
|
// Clear cookies
|
|
reply
|
|
.clearCookie("accessToken", {
|
|
path: "/",
|
|
domain: config.cookie.domain,
|
|
})
|
|
.clearCookie("refreshToken", {
|
|
path: "/",
|
|
domain: config.cookie.domain,
|
|
});
|
|
|
|
console.log(`👋 User ${request.user.username} logged out`);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Logged out successfully",
|
|
});
|
|
} catch (error) {
|
|
console.error("Logout error:", error);
|
|
return reply.status(500).send({
|
|
error: "InternalServerError",
|
|
message: "Failed to logout",
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// Verify access token endpoint (useful for checking if token is still valid)
|
|
fastify.get(
|
|
"/verify",
|
|
{
|
|
preHandler: authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
return reply.send({
|
|
success: true,
|
|
valid: true,
|
|
userId: request.user._id,
|
|
steamId: request.user.steamId,
|
|
});
|
|
}
|
|
);
|
|
}
|