Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
189 changes: 189 additions & 0 deletions docs/DISCORD_OAUTH_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -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)
69 changes: 69 additions & 0 deletions e2e/auth/discord.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
50 changes: 40 additions & 10 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -167,16 +172,41 @@ export default function LoginPage() {
</div>
</div>

<div className="grid grid-cols-2 gap-4">
{['Google', 'GitHub'].map((provider) => (
<button
key={provider}
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"
>
<span>{provider}</span>
</button>
))}
<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>
</div>
</div>
</div>
Expand Down
8 changes: 7 additions & 1 deletion src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,7 +182,8 @@ export default function SignupPage() {
</div>

{/* Social buttons */}
<div className="grid grid-cols-2 gap-4">
<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"
Expand Down
Loading
Loading