import { SignJWT, jwtVerify } from "jose"; import { prisma } from "@/lib/prisma"; // ─── Provider Config ──────────────────────────────────────────────────────── export type Provider = "google" | "github"; interface ProviderConfig { authorizeUrl: string; tokenUrl: string; scopes: string[]; clientIdEnv: string; clientSecretEnv: string; } export const PROVIDER_CONFIGS: Record = { google: { authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", scopes: ["openid", "email", "profile"], clientIdEnv: "GOOGLE_CLIENT_ID", clientSecretEnv: "GOOGLE_CLIENT_SECRET", }, github: { authorizeUrl: "https://github.com/login/oauth/authorize", tokenUrl: "https://github.com/login/oauth/access_token", scopes: ["read:user", "user:email"], clientIdEnv: "GITHUB_CLIENT_ID", clientSecretEnv: "GITHUB_CLIENT_SECRET", }, }; // ─── State Management ──────────────────────────────────────────────────────── const STATE_SECRET = new TextEncoder().encode( process.env.JWT_SECRET || "dev-jwt-secret-change-in-production" ); export interface StatePayload { provider: Provider; redirectTo?: string; } /** Generate a signed state string for CSRF protection. */ export async function generateState( provider: Provider, redirectTo?: string ): Promise { return new SignJWT({ provider, redirectTo } as unknown as import("jose").JWTPayload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("10m") .sign(STATE_SECRET); } /** Verify a state string and return the payload, or null if invalid/expired. */ export async function verifyState(token: string): Promise { try { const { payload } = await jwtVerify(token, STATE_SECRET); return payload as unknown as StatePayload; } catch { return null; } } // ─── Token Exchange & User Info ────────────────────────────────────────────── interface ProviderUser { providerId: string; email: string; name: string; avatar: string | null; } /** * Exchange an authorization code for a Google access token, * then fetch user info. */ export async function exchangeGoogleCode( code: string, redirectUri: string ): Promise { const clientId = process.env.GOOGLE_CLIENT_ID; const clientSecret = process.env.GOOGLE_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error("Google OAuth is not configured"); } // Exchange code for token const tokenRes = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, grant_type: "authorization_code", }), }); if (!tokenRes.ok) { const errBody = await tokenRes.text(); throw new Error(`Google token exchange failed: ${errBody}`); } const tokenData = await tokenRes.json(); const accessToken = tokenData.access_token; // Fetch user info const userRes = await fetch( "https://www.googleapis.com/oauth2/v2/userinfo", { headers: { Authorization: `Bearer ${accessToken}` }, } ); if (!userRes.ok) { throw new Error("Failed to fetch Google user info"); } const userData = await userRes.json(); return { providerId: userData.id, email: userData.email, name: userData.name || userData.given_name || "User", avatar: userData.picture || null, }; } /** * Exchange an authorization code for a GitHub access token, * then fetch user info (and primary email). */ export async function exchangeGitHubCode( code: string, redirectUri: string ): Promise { const clientId = process.env.GITHUB_CLIENT_ID; const clientSecret = process.env.GITHUB_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error("GitHub OAuth is not configured"); } // Exchange code for token const tokenRes = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, }), }); if (!tokenRes.ok) { const errBody = await tokenRes.text(); throw new Error(`GitHub token exchange failed: ${errBody}`); } const tokenData = await tokenRes.json(); if (tokenData.error) { throw new Error(`GitHub OAuth error: ${tokenData.error_description || tokenData.error}`); } const accessToken = tokenData.access_token; // Fetch user info const userRes = await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", "User-Agent": "Falah-App", }, }); if (!userRes.ok) { throw new Error("Failed to fetch GitHub user info"); } const userData = await userRes.json(); // GitHub doesn't always return a public email; fetch emails if needed let email = userData.email; if (!email) { try { const emailsRes = await fetch("https://api.github.com/user/emails", { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", "User-Agent": "Falah-App", }, }); if (emailsRes.ok) { const emails = await emailsRes.json(); const primary = emails.find((e: { primary: boolean }) => e.primary); if (primary) email = primary.email; } } catch { // fall back to empty email } } return { providerId: String(userData.id), email: email || "", name: userData.name || userData.login || "User", avatar: userData.avatar_url || null, }; } // ─── Dispatch ──────────────────────────────────────────────────────────────── /** Exchange a code for user info based on provider. */ export async function exchangeCode( provider: Provider, code: string, redirectUri: string ): Promise { switch (provider) { case "google": return exchangeGoogleCode(code, redirectUri); case "github": return exchangeGitHubCode(code, redirectUri); default: throw new Error(`Unsupported provider: ${provider}`); } } // ─── Find or Create User ───────────────────────────────────────────────────── export async function findOrCreateOAuthUser( provider: Provider, providerUser: ProviderUser ) { const { providerId, email, name, avatar } = providerUser; // Try to find by provider + providerId first let user = await prisma.user.findFirst({ where: { provider, providerId }, }); if (user) { // Update avatar if provider has a new one if (avatar && avatar !== user.avatar) { user = await prisma.user.update({ where: { id: user.id }, data: { avatar }, }); } return user; } // Try to find by email (so OAuth can link to an existing email account) if (email) { user = await prisma.user.findUnique({ where: { email } }); if (user) { // Link the OAuth provider to this existing user user = await prisma.user.update({ where: { id: user.id }, data: { provider, providerId, avatar: avatar || user.avatar }, }); return user; } } // Create a new user user = await prisma.user.create({ data: { email: email || `${providerId}@${provider}.oauth`, name, provider, providerId, avatar, flhBalance: 5000, trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); return user; } // ─── Build OAuth URL ───────────────────────────────────────────────────────── /** * Construct the provider's OAuth authorization URL. */ export function buildAuthorizationUrl( provider: Provider, state: string, redirectUri: string ): string { const config = PROVIDER_CONFIGS[provider]; const clientId = process.env[config.clientIdEnv]; if (!clientId) { throw new Error( `${provider} OAuth is not configured. Missing ${config.clientIdEnv} env var.` ); } const params = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: "code", scope: config.scopes.join(" "), state, }); return `${config.authorizeUrl}?${params.toString()}`; }