diff --git a/.env.example b/.env.example index 60d8804a..2b1e8aec 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,8 @@ DATABASE_URL=postgresql://user:password@localhost:5432/teachlink DB_POOL_MAX=20 DB_CONNECTION_TIMEOUT=5000 DB_IDLE_TIMEOUT=30000 + +# Discord OAuth Configuration +DISCORD_CLIENT_ID=your_discord_client_id +DISCORD_CLIENT_SECRET=your_discord_client_secret +DISCORD_REDIRECT_URI=http://localhost:3000/api/auth/discord/callback diff --git a/docs/DISCORD_OAUTH_INTEGRATION.md b/docs/DISCORD_OAUTH_INTEGRATION.md new file mode 100644 index 00000000..0200568c --- /dev/null +++ b/docs/DISCORD_OAUTH_INTEGRATION.md @@ -0,0 +1,189 @@ +# Discord OAuth Integration + +This document describes the Discord OAuth2 integration implementation for the TeachLink authentication flow. + +## Overview + +The Discord OAuth integration allows users to authenticate using their Discord account, providing a seamless signup/login experience. + +## Features + +- **OAuth2 Flow**: Implements the standard Discord OAuth2 authorization code flow +- **Security**: Uses state parameter to prevent CSRF attacks +- **Email Verification**: Requires Discord accounts to have verified emails +- **Avatar Support**: Fetches and displays user avatars from Discord +- **Edge Runtime**: Optimized for Edge deployment for fast performance + +## Architecture + +### Components + +1. **OAuth Utilities** (`src/lib/discord/oauth.ts`) + - `getDiscordAuthUrl()`: Generates Discord authorization URL + - `exchangeCodeForToken()`: Exchanges authorization code for access token + - `getDiscordUser()`: Fetches user information from Discord + - `getDiscordAvatarUrl()`: Generates avatar URL with fallback + - `generateState()`: Generates random state for CSRF protection + +2. **API Routes** + - `GET /api/auth/discord`: Initiates OAuth flow + - `GET /api/auth/discord/callback`: Handles OAuth callback + +3. **UI Components** + - `DiscordButton`: Reusable button component for Discord auth + - Updated login/signup pages with Discord button + +### Flow Diagram + +``` +User clicks Discord button + ↓ +GET /api/auth/discord + ↓ +Generate state, set cookie, redirect to Discord + ↓ +User authorizes on Discord + ↓ +Discord redirects to callback with code + ↓ +GET /api/auth/discord/callback + ↓ +Validate state, exchange code for token + ↓ +Fetch user info from Discord + ↓ +Create/update user session + ↓ +Return auth response +``` + +## Configuration + +Add the following environment variables to your `.env` file: + +```env +DISCORD_CLIENT_ID=your_discord_client_id +DISCORD_CLIENT_SECRET=your_discord_client_secret +DISCORD_REDIRECT_URI=http://localhost:3000/api/auth/discord/callback +``` + +### Getting Discord OAuth Credentials + +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Create a new application +3. Navigate to "OAuth2" → "General" +4. Copy the Client ID and generate a Client Secret +5. Add your redirect URI under "Redirects" +6. Save the credentials in your environment variables + +### Production Redirect URI + +For production, use your actual domain: +```env +DISCORD_REDIRECT_URI=https://yourdomain.com/api/auth/discord/callback +``` + +## Security Considerations + +1. **CSRF Protection**: State parameter is stored in httpOnly cookie and validated on callback +2. **HTTPS Required**: In production, always use HTTPS for OAuth callbacks +3. **Secret Management**: Never commit Discord secrets to version control +4. **Email Verification**: Only accepts Discord accounts with verified emails +5. **Rate Limiting**: All OAuth endpoints are rate-limited + +## API Reference + +### GET /api/auth/discord + +Initiates Discord OAuth flow. + +**Response:** Redirect to Discord authorization page + +**Cookie:** Sets `discord_oauth_state` for CSRF protection + +### GET /api/auth/discord/callback + +Handles Discord OAuth callback. + +**Query Parameters:** +- `code`: Authorization code from Discord +- `state`: State parameter for CSRF validation +- `error`: OAuth error (if any) + +**Response:** +```json +{ + "message": "Discord authentication successful", + "user": { + "id": "user_id", + "name": "username", + "email": "user@example.com", + "avatar": "avatar_url", + "provider": "discord", + "providerId": "discord_user_id" + }, + "token": "jwt_token" +} +``` + +**Error Responses:** +- `400`: Invalid parameters, unverified email, or OAuth error +- `500`: Internal server error + +## Testing + +### Unit Tests + +Test OAuth utility functions: +```bash +pnpm test src/lib/discord/__tests__/oauth.test.ts +``` + +### Integration Tests + +Test API routes: +```bash +pnpm test src/app/api/auth/discord/__tests__/route.test.ts +pnpm test src/app/api/auth/discord/callback/__tests__/route.test.ts +``` + +### E2E Tests + +Test complete OAuth flow: +```bash +pnpm test:e2e e2e/auth/discord.spec.ts +``` + +## Future Enhancements + +- [ ] Implement token refresh logic +- [ ] Add Discord role-based access control +- [ ] Store Discord tokens for API integrations +- [ ] Add Discord guild membership verification +- [ ] Implement account linking (multiple OAuth providers) + +## Troubleshooting + +### Common Issues + +1. **"Discord OAuth configuration is missing"** + - Ensure all environment variables are set + - Check that variables are loaded in the Edge runtime + +2. **"Invalid state parameter"** + - Clear cookies and try again + - Ensure state cookie is being set correctly + +3. **"Discord email must be verified"** + - User must verify their email on Discord first + - Cannot use Discord accounts without verified email + +4. **Callback URL mismatch** + - Ensure redirect URI matches exactly what's configured in Discord Developer Portal + - Check for trailing slashes or protocol differences (http vs https) + +## Related Documentation + +- [Discord OAuth2 Documentation](https://discord.com/developers/docs/topics/oauth2) +- [Next.js Edge Runtime](https://nextjs.org/docs/pages/building-your-application/rendering/edge-runtime) +- [Authentication Flow Documentation](./AUTHENTICATION_FLOW.md) diff --git a/e2e/auth/discord.spec.ts b/e2e/auth/discord.spec.ts new file mode 100644 index 00000000..8c16717f --- /dev/null +++ b/e2e/auth/discord.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Discord OAuth Authentication', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + }); + + test('should display Discord button on login page', async ({ page }) => { + const discordButton = page.locator('button:has-text("Discord")'); + await expect(discordButton).toBeVisible(); + }); + + test('should display Discord button on signup page', async ({ page }) => { + await page.goto('/signup'); + const discordButton = page.locator('button:has-text("Discord")'); + await expect(discordButton).toBeVisible(); + }); + + test('should redirect to Discord when clicking Discord button', async ({ page }) => { + const discordButton = page.locator('button:has-text("Discord")'); + + // Note: This test will actually redirect to Discord, which requires valid OAuth credentials + // For testing purposes, we'll just verify the click action and URL change + + // Mock the redirect for testing + await page.route('**/api/auth/discord', route => { + route.fulfill({ + status: 302, + headers: { + location: 'https://discord.com/oauth2/authorize', + }, + }); + }); + + await discordButton.click(); + + // Verify that a request was made to the Discord auth endpoint + await expect(page).toHaveURL(/discord\.com/); + }); + + test('should have accessible Discord button', async ({ page }) => { + const discordButton = page.locator('button:has-text("Discord")'); + + // Check for accessibility attributes + await expect(discordButton).toHaveAttribute('type', 'button'); + + // Check that it's keyboard navigable + await discordButton.focus(); + await expect(discordButton).toBeFocused(); + }); + + test('should have consistent Discord button styling across pages', async ({ page }) => { + // Check on login page + await page.goto('/login'); + const loginDiscordButton = page.locator('button:has-text("Discord")'); + const loginClasses = await loginDiscordButton.getAttribute('class'); + + // Check on signup page + await page.goto('/signup'); + const signupDiscordButton = page.locator('button:has-text("Discord")'); + const signupClasses = await signupDiscordButton.getAttribute('class'); + + // Both should have similar base classes + expect(loginClasses).toContain('px-4'); + expect(loginClasses).toContain('py-2.5'); + expect(signupClasses).toContain('px-4'); + expect(signupClasses).toContain('py-2.5'); + }); +}); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index a35892d4..ffd0ffcb 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -12,12 +12,17 @@ import { FormError, FieldError } from '../../../components/forms/FormError'; import { SubmitButton } from '../../../components/forms/SubmitButton'; import { useMutation } from '../../../hooks/useMutation'; import { apiClient } from '@/lib/api'; +import { DiscordButton } from '../../../components/auth/DiscordButton'; export default function LoginPage() { const [showPassword, setShowPassword] = useState(false); const [successMessage, setSuccessMessage] = useState(''); const router = useRouter(); + const handleDiscordLogin = () => { + window.location.href = '/api/auth/discord'; + }; + const { register, handleSubmit, @@ -167,16 +172,41 @@ export default function LoginPage() { -
- {['Google', 'GitHub'].map((provider) => ( - - ))} +
+ + +
diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 4aed8fba..9cceff0c 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -12,12 +12,17 @@ import { FormError, FieldError } from '../../../components/forms/FormError'; import { SubmitButton } from '../../../components/forms/SubmitButton'; import { useMutation } from '../../../hooks/useMutation'; import { apiClient } from '@/lib/api'; +import { DiscordButton } from '../../../components/auth/DiscordButton'; export default function SignupPage() { const [showPassword, setShowPassword] = useState(false); const [successMessage, setSuccessMessage] = useState(''); const router = useRouter(); + const handleDiscordSignup = () => { + window.location.href = '/api/auth/discord'; + }; + const { register, handleSubmit, @@ -177,7 +182,8 @@ export default function SignupPage() { {/* Social buttons */} -
+
+