diff --git a/.env.example b/.env.example index ae3ccd6a..bce59406 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,13 @@ NEXT_PUBLIC_LOG_AGGREGATION_URL=https://your-log-aggregation-endpoint.com/logs DISCORD_CLIENT_ID=your_discord_client_id DISCORD_CLIENT_SECRET=your_discord_client_secret DISCORD_REDIRECT_URI=http://localhost:3000/api/auth/discord/callback + +# Google OAuth Configuration +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback + +# GitHub OAuth Configuration +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +GITHUB_REDIRECT_URI=http://localhost:3000/api/auth/github/callback diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 8788bb02..978dec61 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -13,6 +13,8 @@ import { SubmitButton } from '../../../components/forms/SubmitButton'; import { useMutation } from '../../../hooks/useMutation'; import { apiClient } from '@/lib/api'; import { DiscordButton } from '@/app/components/auth/DiscordButton'; +import { GoogleButton } from '@/app/components/auth/GoogleButton'; +import { GitHubButton } from '@/app/components/auth/GitHubButton'; export default function LoginPage() { const [showPassword, setShowPassword] = useState(false); @@ -23,6 +25,14 @@ export default function LoginPage() { window.location.href = '/api/auth/discord'; }; + const handleGoogleLogin = () => { + window.location.href = '/api/auth/google'; + }; + + const handleGitHubLogin = () => { + window.location.href = '/api/auth/github'; + }; + const { register, handleSubmit, @@ -188,39 +198,8 @@ export default function LoginPage() {
- - + +
diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index c58a9cb7..f10fe9ea 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -13,6 +13,8 @@ import { SubmitButton } from '../../../components/forms/SubmitButton'; import { useMutation } from '../../../hooks/useMutation'; import { apiClient } from '@/lib/api'; import { DiscordButton } from '@/app/components/auth/DiscordButton'; +import { GoogleButton } from '@/app/components/auth/GoogleButton'; +import { GitHubButton } from '@/app/components/auth/GitHubButton'; export default function SignupPage() { const [showPassword, setShowPassword] = useState(false); @@ -24,6 +26,14 @@ export default function SignupPage() { window.location.href = '/api/auth/discord'; }; + const handleGoogleSignup = () => { + window.location.href = '/api/auth/google'; + }; + + const handleGitHubSignup = () => { + window.location.href = '/api/auth/github'; + }; + const { register, handleSubmit, @@ -260,40 +270,9 @@ export default function SignupPage() { {/* Social buttons */}
- + - +
diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts new file mode 100644 index 00000000..77fcf01e --- /dev/null +++ b/src/app/api/auth/github/callback/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withRateLimit } from '@/lib/ratelimit'; +import { exchangeCodeForToken, getGitHubUser, getGitHubAvatarUrl } from '@/lib/github/oauth'; +import type { AuthResponseDTO, AuthErrorDTO } from '@/types/api/auth.dto'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; + +/** + * GET /api/auth/github/callback + * Handles GitHub OAuth2 callback and creates/updates user session + */ +export async function GET( + request: NextRequest, +): Promise> { + edgeLog('info', '/api/auth/github/callback', 'GET request received'); + + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + try { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + // Check for OAuth errors + if (error) { + edgeLog('error', '/api/auth/github/callback', `OAuth error: ${error}`); + return addHeaders( + NextResponse.json({ message: `GitHub OAuth error: ${error}` }, { status: 400 }), + ) as NextResponse; + } + + if (!code) { + return addHeaders( + NextResponse.json({ message: 'Authorization code is required' }, { status: 400 }), + ) as NextResponse; + } + + // Verify state parameter to prevent CSRF attacks + const storedState = request.cookies.get('github_oauth_state')?.value; + if (!state || state !== storedState) { + edgeLog('error', '/api/auth/github/callback', 'Invalid state parameter'); + return addHeaders( + NextResponse.json({ message: 'Invalid state parameter' }, { status: 400 }), + ) as NextResponse; + } + + // Exchange code for access token + const tokenResponse = await exchangeCodeForToken(code); + + // Get GitHub user information + const githubUser = await getGitHubUser(tokenResponse.access_token); + + // Validate that user has email + if (!githubUser.email) { + return addHeaders( + NextResponse.json( + { message: 'GitHub account must have a verified email' }, + { status: 400 }, + ), + ) as NextResponse; + } + + const mockUserId = Math.random().toString(36).substring(2, 9); + const mockToken = `mock-jwt-token-${Date.now()}`; + + // Clear the state cookie + const response = NextResponse.json( + { + message: 'GitHub authentication successful', + user: { + id: mockUserId, + name: githubUser.name || githubUser.login, + email: githubUser.email, + avatar: getGitHubAvatarUrl(githubUser), + provider: 'github', + providerId: String(githubUser.id), + }, + token: mockToken, + }, + { status: 200 }, + ); + + // Clear the state cookie + response.cookies.delete('github_oauth_state'); + + return addHeaders(response) as NextResponse; + } catch (error) { + edgeLog('error', '/api/auth/github/callback', `Error: ${error}`); + console.error('GitHub OAuth callback error:', error); + + return addHeaders( + NextResponse.json({ message: 'Internal server error' }, { status: 500 }), + ) as NextResponse; + } +} diff --git a/src/app/api/auth/github/route.ts b/src/app/api/auth/github/route.ts new file mode 100644 index 00000000..53973bee --- /dev/null +++ b/src/app/api/auth/github/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withRateLimit } from '@/lib/ratelimit'; +import { generateState, getGitHubAuthUrl } from '@/lib/github/oauth'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; + +/** + * GET /api/auth/github + * Initiates GitHub OAuth2 flow by redirecting to GitHub's authorization page + */ +export async function GET(request: NextRequest): Promise { + edgeLog('info', '/api/auth/github', 'GET request received'); + + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + try { + const state = generateState(); + const authUrl = getGitHubAuthUrl(state); + + // Store state in a cookie for verification during callback + const response = NextResponse.redirect(authUrl); + response.cookies.set('github_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, // 10 minutes + }); + + return addHeaders(response) as NextResponse; + } catch (error) { + edgeLog('error', '/api/auth/github', `Error: ${error}`); + + return addHeaders( + NextResponse.json({ message: 'Failed to initiate GitHub OAuth' }, { status: 500 }), + ) as NextResponse; + } +} diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts new file mode 100644 index 00000000..dd3f4c40 --- /dev/null +++ b/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withRateLimit } from '@/lib/ratelimit'; +import { exchangeCodeForToken, getGoogleUser, getGoogleAvatarUrl } from '@/lib/google/oauth'; +import type { AuthResponseDTO, AuthErrorDTO } from '@/types/api/auth.dto'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; + +/** + * GET /api/auth/google/callback + * Handles Google OAuth2 callback and creates/updates user session + */ +export async function GET( + request: NextRequest, +): Promise> { + edgeLog('info', '/api/auth/google/callback', 'GET request received'); + + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + try { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + // Check for OAuth errors + if (error) { + edgeLog('error', '/api/auth/google/callback', `OAuth error: ${error}`); + return addHeaders( + NextResponse.json({ message: `Google OAuth error: ${error}` }, { status: 400 }), + ) as NextResponse; + } + + if (!code) { + return addHeaders( + NextResponse.json({ message: 'Authorization code is required' }, { status: 400 }), + ) as NextResponse; + } + + // Verify state parameter to prevent CSRF attacks + const storedState = request.cookies.get('google_oauth_state')?.value; + if (!state || state !== storedState) { + edgeLog('error', '/api/auth/google/callback', 'Invalid state parameter'); + return addHeaders( + NextResponse.json({ message: 'Invalid state parameter' }, { status: 400 }), + ) as NextResponse; + } + + // Exchange code for access token + const tokenResponse = await exchangeCodeForToken(code); + + // Get Google user information + const googleUser = await getGoogleUser(tokenResponse.access_token); + + // Validate that user has email + if (!googleUser.email) { + return addHeaders( + NextResponse.json( + { message: 'Google account must have an email' }, + { status: 400 }, + ), + ) as NextResponse; + } + + // Validate email is verified + if (!googleUser.verified_email) { + return addHeaders( + NextResponse.json({ message: 'Google email must be verified' }, { status: 400 }), + ) as NextResponse; + } + + const mockUserId = Math.random().toString(36).substring(2, 9); + const mockToken = `mock-jwt-token-${Date.now()}`; + + // Clear the state cookie + const response = NextResponse.json( + { + message: 'Google authentication successful', + user: { + id: mockUserId, + name: googleUser.name, + email: googleUser.email, + avatar: getGoogleAvatarUrl(googleUser), + provider: 'google', + providerId: googleUser.id, + }, + token: mockToken, + }, + { status: 200 }, + ); + + // Clear the state cookie + response.cookies.delete('google_oauth_state'); + + return addHeaders(response) as NextResponse; + } catch (error) { + edgeLog('error', '/api/auth/google/callback', `Error: ${error}`); + console.error('Google OAuth callback error:', error); + + return addHeaders( + NextResponse.json({ message: 'Internal server error' }, { status: 500 }), + ) as NextResponse; + } +} diff --git a/src/app/api/auth/google/route.ts b/src/app/api/auth/google/route.ts new file mode 100644 index 00000000..0e56fc12 --- /dev/null +++ b/src/app/api/auth/google/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withRateLimit } from '@/lib/ratelimit'; +import { generateState, getGoogleAuthUrl } from '@/lib/google/oauth'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; + +/** + * GET /api/auth/google + * Initiates Google OAuth2 flow by redirecting to Google's authorization page + */ +export async function GET(request: NextRequest): Promise { + edgeLog('info', '/api/auth/google', 'GET request received'); + + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + try { + const state = generateState(); + const authUrl = getGoogleAuthUrl(state); + + // Store state in a cookie for verification during callback + const response = NextResponse.redirect(authUrl); + response.cookies.set('google_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, // 10 minutes + }); + + return addHeaders(response) as NextResponse; + } catch (error) { + edgeLog('error', '/api/auth/google', `Error: ${error}`); + + return addHeaders( + NextResponse.json({ message: 'Failed to initiate Google OAuth' }, { status: 500 }), + ) as NextResponse; + } +} diff --git a/src/app/components/auth/GitHubButton.tsx b/src/app/components/auth/GitHubButton.tsx new file mode 100644 index 00000000..9da13f89 --- /dev/null +++ b/src/app/components/auth/GitHubButton.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { motion } from 'framer-motion'; + +interface GitHubButtonProps { + onClick: () => void; + disabled?: boolean; +} + +export const GitHubButton = ({ onClick, disabled = false }: GitHubButtonProps) => { + return ( + + + GitHub + + ); +}; diff --git a/src/app/components/auth/GoogleButton.tsx b/src/app/components/auth/GoogleButton.tsx new file mode 100644 index 00000000..05b74b1e --- /dev/null +++ b/src/app/components/auth/GoogleButton.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { motion } from 'framer-motion'; + +interface GoogleButtonProps { + onClick: () => void; + disabled?: boolean; +} + +export const GoogleButton = ({ onClick, disabled = false }: GoogleButtonProps) => { + return ( + + + Google + + ); +}; diff --git a/src/lib/github/oauth.ts b/src/lib/github/oauth.ts new file mode 100644 index 00000000..05de6569 --- /dev/null +++ b/src/lib/github/oauth.ts @@ -0,0 +1,136 @@ +/** + * GitHub OAuth2 Integration + * Handles GitHub OAuth2 flow for authentication + */ + +export interface GitHubUser { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + html_url: string; + bio: string | null; + location: string | null; + blog: string | null; + twitter_username: string | null; + company: string | null; +} + +export interface GitHubTokenResponse { + access_token: string; + token_type: string; + scope: string; +} + +const GITHUB_API_BASE = 'https://api.github.com'; +const GITHUB_OAUTH_BASE = 'https://github.com/login/oauth'; + +/** + * Get GitHub OAuth authorization URL + */ +export function getGitHubAuthUrl(state: string): string { + const clientId = process.env.GITHUB_CLIENT_ID; + const redirectUri = process.env.GITHUB_REDIRECT_URI; + const scope = 'read:user user:email'; + + if (!clientId || !redirectUri) { + throw new Error('GitHub OAuth configuration is missing'); + } + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope, + state, + }); + + return `${GITHUB_OAUTH_BASE}/authorize?${params.toString()}`; +} + +/** + * Exchange authorization code for access token + */ +export async function exchangeCodeForToken(code: string): Promise { + const clientId = process.env.GITHUB_CLIENT_ID; + const clientSecret = process.env.GITHUB_CLIENT_SECRET; + const redirectUri = process.env.GITHUB_REDIRECT_URI; + + if (!clientId || !clientSecret || !redirectUri) { + throw new Error('GitHub OAuth configuration is missing'); + } + + const response = await fetch(`${GITHUB_OAUTH_BASE}/access_token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to exchange code for token: ${error}`); + } + + return response.json(); +} + +/** + * Get GitHub user information using access token + */ +export async function getGitHubUser(accessToken: string): Promise { + const response = await fetch(`${GITHUB_API_BASE}/user`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to fetch GitHub user: ${error}`); + } + + const user = await response.json(); + + // If user doesn't have public email, fetch primary email + if (!user.email) { + const emailResponse = await fetch(`${GITHUB_API_BASE}/user/emails`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (emailResponse.ok) { + const emails = await emailResponse.json(); + const primaryEmail = emails.find((e: any) => e.primary && e.verified); + if (primaryEmail) { + user.email = primaryEmail.email; + } + } + } + + return user; +} + +/** + * Generate a random state parameter for OAuth + */ +export function generateState(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} + +/** + * Get GitHub avatar URL + */ +export function getGitHubAvatarUrl(user: GitHubUser): string { + return user.avatar_url || ''; +} diff --git a/src/lib/google/oauth.ts b/src/lib/google/oauth.ts new file mode 100644 index 00000000..af2a3036 --- /dev/null +++ b/src/lib/google/oauth.ts @@ -0,0 +1,118 @@ +/** + * Google OAuth2 Integration + * Handles Google OAuth2 flow for authentication + */ + +export interface GoogleUser { + id: string; + email: string; + verified_email: boolean; + name: string; + given_name: string; + family_name: string; + picture: string; + locale: string; +} + +export interface GoogleTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + scope: string; + id_token: string; +} + +const GOOGLE_API_BASE = 'https://www.googleapis.com'; +const GOOGLE_OAUTH_BASE = 'https://accounts.google.com/o/oauth2/v2'; + +/** + * Get Google OAuth authorization URL + */ +export function getGoogleAuthUrl(state: string): string { + const clientId = process.env.GOOGLE_CLIENT_ID; + const redirectUri = process.env.GOOGLE_REDIRECT_URI; + const scope = 'openid email profile'; + + if (!clientId || !redirectUri) { + throw new Error('Google OAuth configuration is missing'); + } + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope, + state, + access_type: 'offline', + prompt: 'consent', + }); + + return `${GOOGLE_OAUTH_BASE}/auth?${params.toString()}`; +} + +/** + * Exchange authorization code for access token + */ +export async function exchangeCodeForToken(code: string): Promise { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = process.env.GOOGLE_REDIRECT_URI; + + if (!clientId || !clientSecret || !redirectUri) { + throw new Error('Google OAuth configuration is missing'); + } + + const response = await fetch(`${GOOGLE_API_BASE}/oauth2/v4/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to exchange code for token: ${error}`); + } + + return response.json(); +} + +/** + * Get Google user information using access token + */ +export async function getGoogleUser(accessToken: string): Promise { + const response = await fetch(`${GOOGLE_API_BASE}/oauth2/v2/userinfo`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to fetch Google user: ${error}`); + } + + return response.json(); +} + +/** + * Generate a random state parameter for OAuth + */ +export function generateState(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} + +/** + * Get Google avatar URL + */ +export function getGoogleAvatarUrl(user: GoogleUser): string { + return user.picture || ''; +}