Skip to content

Commit c823dd1

Browse files
feat: Add Discord OAuth2 integration for authentication flow
1 parent 9b4c7c9 commit c823dd1

13 files changed

Lines changed: 1048 additions & 11 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ DATABASE_URL=postgresql://user:password@localhost:5432/teachlink
3030
DB_POOL_MAX=20
3131
DB_CONNECTION_TIMEOUT=5000
3232
DB_IDLE_TIMEOUT=30000
33+
34+
# Discord OAuth Configuration
35+
DISCORD_CLIENT_ID=your_discord_client_id
36+
DISCORD_CLIENT_SECRET=your_discord_client_secret
37+
DISCORD_REDIRECT_URI=http://localhost:3000/api/auth/discord/callback

docs/DISCORD_OAUTH_INTEGRATION.md

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Discord OAuth Integration
2+
3+
This document describes the Discord OAuth2 integration implementation for the TeachLink authentication flow.
4+
5+
## Overview
6+
7+
The Discord OAuth integration allows users to authenticate using their Discord account, providing a seamless signup/login experience.
8+
9+
## Features
10+
11+
- **OAuth2 Flow**: Implements the standard Discord OAuth2 authorization code flow
12+
- **Security**: Uses state parameter to prevent CSRF attacks
13+
- **Email Verification**: Requires Discord accounts to have verified emails
14+
- **Avatar Support**: Fetches and displays user avatars from Discord
15+
- **Edge Runtime**: Optimized for Edge deployment for fast performance
16+
17+
## Architecture
18+
19+
### Components
20+
21+
1. **OAuth Utilities** (`src/lib/discord/oauth.ts`)
22+
- `getDiscordAuthUrl()`: Generates Discord authorization URL
23+
- `exchangeCodeForToken()`: Exchanges authorization code for access token
24+
- `getDiscordUser()`: Fetches user information from Discord
25+
- `getDiscordAvatarUrl()`: Generates avatar URL with fallback
26+
- `generateState()`: Generates random state for CSRF protection
27+
28+
2. **API Routes**
29+
- `GET /api/auth/discord`: Initiates OAuth flow
30+
- `GET /api/auth/discord/callback`: Handles OAuth callback
31+
32+
3. **UI Components**
33+
- `DiscordButton`: Reusable button component for Discord auth
34+
- Updated login/signup pages with Discord button
35+
36+
### Flow Diagram
37+
38+
```
39+
User clicks Discord button
40+
41+
GET /api/auth/discord
42+
43+
Generate state, set cookie, redirect to Discord
44+
45+
User authorizes on Discord
46+
47+
Discord redirects to callback with code
48+
49+
GET /api/auth/discord/callback
50+
51+
Validate state, exchange code for token
52+
53+
Fetch user info from Discord
54+
55+
Create/update user session
56+
57+
Return auth response
58+
```
59+
60+
## Configuration
61+
62+
Add the following environment variables to your `.env` file:
63+
64+
```env
65+
DISCORD_CLIENT_ID=your_discord_client_id
66+
DISCORD_CLIENT_SECRET=your_discord_client_secret
67+
DISCORD_REDIRECT_URI=http://localhost:3000/api/auth/discord/callback
68+
```
69+
70+
### Getting Discord OAuth Credentials
71+
72+
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
73+
2. Create a new application
74+
3. Navigate to "OAuth2" → "General"
75+
4. Copy the Client ID and generate a Client Secret
76+
5. Add your redirect URI under "Redirects"
77+
6. Save the credentials in your environment variables
78+
79+
### Production Redirect URI
80+
81+
For production, use your actual domain:
82+
```env
83+
DISCORD_REDIRECT_URI=https://yourdomain.com/api/auth/discord/callback
84+
```
85+
86+
## Security Considerations
87+
88+
1. **CSRF Protection**: State parameter is stored in httpOnly cookie and validated on callback
89+
2. **HTTPS Required**: In production, always use HTTPS for OAuth callbacks
90+
3. **Secret Management**: Never commit Discord secrets to version control
91+
4. **Email Verification**: Only accepts Discord accounts with verified emails
92+
5. **Rate Limiting**: All OAuth endpoints are rate-limited
93+
94+
## API Reference
95+
96+
### GET /api/auth/discord
97+
98+
Initiates Discord OAuth flow.
99+
100+
**Response:** Redirect to Discord authorization page
101+
102+
**Cookie:** Sets `discord_oauth_state` for CSRF protection
103+
104+
### GET /api/auth/discord/callback
105+
106+
Handles Discord OAuth callback.
107+
108+
**Query Parameters:**
109+
- `code`: Authorization code from Discord
110+
- `state`: State parameter for CSRF validation
111+
- `error`: OAuth error (if any)
112+
113+
**Response:**
114+
```json
115+
{
116+
"message": "Discord authentication successful",
117+
"user": {
118+
"id": "user_id",
119+
"name": "username",
120+
"email": "user@example.com",
121+
"avatar": "avatar_url",
122+
"provider": "discord",
123+
"providerId": "discord_user_id"
124+
},
125+
"token": "jwt_token"
126+
}
127+
```
128+
129+
**Error Responses:**
130+
- `400`: Invalid parameters, unverified email, or OAuth error
131+
- `500`: Internal server error
132+
133+
## Testing
134+
135+
### Unit Tests
136+
137+
Test OAuth utility functions:
138+
```bash
139+
pnpm test src/lib/discord/__tests__/oauth.test.ts
140+
```
141+
142+
### Integration Tests
143+
144+
Test API routes:
145+
```bash
146+
pnpm test src/app/api/auth/discord/__tests__/route.test.ts
147+
pnpm test src/app/api/auth/discord/callback/__tests__/route.test.ts
148+
```
149+
150+
### E2E Tests
151+
152+
Test complete OAuth flow:
153+
```bash
154+
pnpm test:e2e e2e/auth/discord.spec.ts
155+
```
156+
157+
## Future Enhancements
158+
159+
- [ ] Implement token refresh logic
160+
- [ ] Add Discord role-based access control
161+
- [ ] Store Discord tokens for API integrations
162+
- [ ] Add Discord guild membership verification
163+
- [ ] Implement account linking (multiple OAuth providers)
164+
165+
## Troubleshooting
166+
167+
### Common Issues
168+
169+
1. **"Discord OAuth configuration is missing"**
170+
- Ensure all environment variables are set
171+
- Check that variables are loaded in the Edge runtime
172+
173+
2. **"Invalid state parameter"**
174+
- Clear cookies and try again
175+
- Ensure state cookie is being set correctly
176+
177+
3. **"Discord email must be verified"**
178+
- User must verify their email on Discord first
179+
- Cannot use Discord accounts without verified email
180+
181+
4. **Callback URL mismatch**
182+
- Ensure redirect URI matches exactly what's configured in Discord Developer Portal
183+
- Check for trailing slashes or protocol differences (http vs https)
184+
185+
## Related Documentation
186+
187+
- [Discord OAuth2 Documentation](https://discord.com/developers/docs/topics/oauth2)
188+
- [Next.js Edge Runtime](https://nextjs.org/docs/pages/building-your-application/rendering/edge-runtime)
189+
- [Authentication Flow Documentation](./AUTHENTICATION_FLOW.md)

e2e/auth/discord.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Discord OAuth Authentication', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/login');
6+
});
7+
8+
test('should display Discord button on login page', async ({ page }) => {
9+
const discordButton = page.locator('button:has-text("Discord")');
10+
await expect(discordButton).toBeVisible();
11+
});
12+
13+
test('should display Discord button on signup page', async ({ page }) => {
14+
await page.goto('/signup');
15+
const discordButton = page.locator('button:has-text("Discord")');
16+
await expect(discordButton).toBeVisible();
17+
});
18+
19+
test('should redirect to Discord when clicking Discord button', async ({ page }) => {
20+
const discordButton = page.locator('button:has-text("Discord")');
21+
22+
// Note: This test will actually redirect to Discord, which requires valid OAuth credentials
23+
// For testing purposes, we'll just verify the click action and URL change
24+
25+
// Mock the redirect for testing
26+
await page.route('**/api/auth/discord', route => {
27+
route.fulfill({
28+
status: 302,
29+
headers: {
30+
location: 'https://discord.com/oauth2/authorize',
31+
},
32+
});
33+
});
34+
35+
await discordButton.click();
36+
37+
// Verify that a request was made to the Discord auth endpoint
38+
await expect(page).toHaveURL(/discord\.com/);
39+
});
40+
41+
test('should have accessible Discord button', async ({ page }) => {
42+
const discordButton = page.locator('button:has-text("Discord")');
43+
44+
// Check for accessibility attributes
45+
await expect(discordButton).toHaveAttribute('type', 'button');
46+
47+
// Check that it's keyboard navigable
48+
await discordButton.focus();
49+
await expect(discordButton).toBeFocused();
50+
});
51+
52+
test('should have consistent Discord button styling across pages', async ({ page }) => {
53+
// Check on login page
54+
await page.goto('/login');
55+
const loginDiscordButton = page.locator('button:has-text("Discord")');
56+
const loginClasses = await loginDiscordButton.getAttribute('class');
57+
58+
// Check on signup page
59+
await page.goto('/signup');
60+
const signupDiscordButton = page.locator('button:has-text("Discord")');
61+
const signupClasses = await signupDiscordButton.getAttribute('class');
62+
63+
// Both should have similar base classes
64+
expect(loginClasses).toContain('px-4');
65+
expect(loginClasses).toContain('py-2.5');
66+
expect(signupClasses).toContain('px-4');
67+
expect(signupClasses).toContain('py-2.5');
68+
});
69+
});

src/app/(auth)/login/page.tsx

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ import { FormError, FieldError } from '../../../components/forms/FormError';
1212
import { SubmitButton } from '../../../components/forms/SubmitButton';
1313
import { useMutation } from '../../../hooks/useMutation';
1414
import { apiClient } from '@/lib/api';
15+
import { DiscordButton } from '../../../components/auth/DiscordButton';
1516

1617
export default function LoginPage() {
1718
const [showPassword, setShowPassword] = useState(false);
1819
const [successMessage, setSuccessMessage] = useState('');
1920
const router = useRouter();
2021

22+
const handleDiscordLogin = () => {
23+
window.location.href = '/api/auth/discord';
24+
};
25+
2126
const {
2227
register,
2328
handleSubmit,
@@ -167,16 +172,41 @@ export default function LoginPage() {
167172
</div>
168173
</div>
169174

170-
<div className="grid grid-cols-2 gap-4">
171-
{['Google', 'GitHub'].map((provider) => (
172-
<button
173-
key={provider}
174-
type="button"
175-
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"
176-
>
177-
<span>{provider}</span>
178-
</button>
179-
))}
175+
<div className="grid grid-cols-3 gap-4">
176+
<DiscordButton onClick={handleDiscordLogin} />
177+
<button
178+
type="button"
179+
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"
180+
>
181+
<svg className="w-5 h-5" viewBox="0 0 24 24">
182+
<path
183+
fill="#4285F4"
184+
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"
185+
/>
186+
<path
187+
fill="#34A853"
188+
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"
189+
/>
190+
<path
191+
fill="#FBBC05"
192+
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"
193+
/>
194+
<path
195+
fill="#EA4335"
196+
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"
197+
/>
198+
</svg>
199+
<span>Google</span>
200+
</button>
201+
<button
202+
type="button"
203+
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"
204+
>
205+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
206+
<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" />
207+
</svg>
208+
<span>GitHub</span>
209+
</button>
180210
</div>
181211
</div>
182212
</div>

src/app/(auth)/signup/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ import { FormError, FieldError } from '../../../components/forms/FormError';
1212
import { SubmitButton } from '../../../components/forms/SubmitButton';
1313
import { useMutation } from '../../../hooks/useMutation';
1414
import { apiClient } from '@/lib/api';
15+
import { DiscordButton } from '../../../components/auth/DiscordButton';
1516

1617
export default function SignupPage() {
1718
const [showPassword, setShowPassword] = useState(false);
1819
const [successMessage, setSuccessMessage] = useState('');
1920
const router = useRouter();
2021

22+
const handleDiscordSignup = () => {
23+
window.location.href = '/api/auth/discord';
24+
};
25+
2126
const {
2227
register,
2328
handleSubmit,
@@ -177,7 +182,8 @@ export default function SignupPage() {
177182
</div>
178183

179184
{/* Social buttons */}
180-
<div className="grid grid-cols-2 gap-4">
185+
<div className="grid grid-cols-3 gap-4">
186+
<DiscordButton onClick={handleDiscordSignup} />
181187
<button
182188
type="button"
183189
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"

0 commit comments

Comments
 (0)