diff --git a/api/roblox/oauth/start.ts b/api/roblox/oauth/start.ts new file mode 100644 index 00000000..67890c6f --- /dev/null +++ b/api/roblox/oauth/start.ts @@ -0,0 +1,83 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { randomUUID, createHash } from "crypto"; + +function isHttps(req: VercelRequest) { + const xfProto = (req.headers["x-forwarded-proto"] || "").toString(); + return xfProto.includes("https"); +} + +function toBase64Url(input: Buffer) { + return input + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", "GET"); + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + const clientId = process.env.ROBLOX_OAUTH_CLIENT_ID; + if (!clientId) return res.status(500).json({ error: "Roblox OAuth not configured" }); + + const baseSite = + process.env.PUBLIC_BASE_URL || process.env.SITE_URL || "https://aethex.dev"; + + const redirectParam = Array.isArray(req.query.redirect_uri) + ? req.query.redirect_uri[0] + : (req.query.redirect_uri as string | undefined); + + const redirectUri = redirectParam && redirectParam.startsWith("http") + ? redirectParam + : process.env.ROBLOX_OAUTH_REDIRECT_URI || `${baseSite}/roblox-callback`; + + const scope = (Array.isArray(req.query.scope) ? req.query.scope[0] : (req.query.scope as string)) || + process.env.ROBLOX_OAUTH_SCOPE || + "openid"; + + const state = (Array.isArray(req.query.state) ? req.query.state[0] : (req.query.state as string)) || randomUUID(); + + const codeVerifier = toBase64Url(Buffer.from(randomUUID() + randomUUID())).slice(0, 64); + const codeChallenge = toBase64Url( + createHash("sha256").update(codeVerifier).digest(), + ); + + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: String(scope), + state: String(state), + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + const authorizeUrl = `https://apis.roblox.com/oauth/authorize?${params.toString()}`; + + const secure = isHttps(req) || process.env.NODE_ENV === "production"; + const cookieCommon = `Path=/; HttpOnly; SameSite=Lax; Max-Age=${10 * 60}${secure ? "; Secure" : ""}`; + res.setHeader("Set-Cookie", [ + `roblox_oauth_state=${encodeURIComponent(state)}; ${cookieCommon}`, + `roblox_oauth_code_verifier=${encodeURIComponent(codeVerifier)}; ${cookieCommon}`, + ]); + + // CORS for JSON fetch testing + const origin = (req.headers.origin as string) || "*"; + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + + const json = ((Array.isArray(req.query.json) ? req.query.json[0] : req.query.json) || "").toString().toLowerCase(); + if (json === "true") { + return res.status(200).json({ authorizeUrl, state }); + } + + res.status(302).setHeader("Location", authorizeUrl); + return res.end(); + } catch (e: any) { + return res.status(500).json({ error: e?.message || String(e) }); + } +}