Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 12 additions & 33 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -188,39 +198,8 @@ export default function LoginPage() {

<div className="grid grid-cols-3 gap-4">
<DiscordButton onClick={handleDiscordLogin} />
<button
type="button"
className="px-4 py-2.5 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center justify-center gap-2 text-sm font-medium text-gray-700"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
<span>Google</span>
</button>
<button
type="button"
className="px-4 py-2.5 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center justify-center gap-2 text-sm font-medium text-gray-700"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span>GitHub</span>
</button>
<GoogleButton onClick={handleGoogleLogin} />
<GitHubButton onClick={handleGitHubLogin} />
</div>
</div>
</div>
Expand Down
45 changes: 12 additions & 33 deletions src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -260,40 +270,9 @@ export default function SignupPage() {
{/* Social buttons */}
<div className="grid grid-cols-3 gap-4">
<DiscordButton onClick={handleDiscordSignup} />
<button
type="button"
className="px-4 py-2.5 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
<span className="text-sm font-medium text-gray-700">Google</span>
</button>
<GoogleButton onClick={handleGoogleSignup} />

<button
type="button"
className="px-4 py-2.5 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span className="text-sm font-medium text-gray-700">GitHub</span>
</button>
<GitHubButton onClick={handleGitHubSignup} />
</div>
</div>
</motion.div>
Expand Down
98 changes: 98 additions & 0 deletions src/app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse<AuthResponseDTO | AuthErrorDTO>> {
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;
}
}
39 changes: 39 additions & 0 deletions src/app/api/auth/github/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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;
}
}
105 changes: 105 additions & 0 deletions src/app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse<AuthResponseDTO | AuthErrorDTO>> {
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;
}
}
Loading
Loading