Complete technical documentation for implementing Google OAuth 2.0 authentication in Stun.
User Browser Frontend Next.js Backend Express
| | |
|--- Click "Sign in" ----------->| GET /signin |
| | |
|<--- Redirect to Google --------| (Google Auth URL) |
| | |
|--- Google Login Flow -------->| [popup/redirect] |
| | |
|<--- Redirect /auth/callback----| [with code param] |
| | |
|--- POST /auth/callback-------->|---------------------->|
| | Exchange
| | code for token
| |<------ POST /auth/callback
| | (returns token)
| | |
|<--- Store token in cookie -----| (httpOnly secure) |
|<--- Redirect to / -------------| (authenticated) |
| | |
|--- GET / (with token) -------->|------------------------->|
| | Validate token
| |<------ 200 OK {user}
|<--- Render Home (authed) ------| |
File: backend/src/routes/auth.ts
Implements 4 core authentication endpoints:
Initiates Google OAuth flow by returning the Google auth URL.
Request:
POST http://localhost:3001/auth/signin
Content-Type: application/json
{
"redirectUrl": "http://localhost:3000"
}Response (200 OK):
{
"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=..."
}Flow:
- Receive redirect URL from frontend
- Generate Google auth URL with client ID, scopes, redirect URI
- Return URL for frontend to redirect user to
Handles OAuth code exchange and token generation.
Request:
POST http://localhost:3001/auth/callback
Content-Type: application/json
{
"code": "4/0AX4XfW...",
"redirectUrl": "http://localhost:3000"
}Response (200 OK):
{
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkaXNkY...",
"user": {
"uid": "google_oauth_user_123",
"email": "user@gmail.com",
"name": "User Name",
"picture": "https://lh3.googleusercontent.com/..."
}
}Flow:
- Receive authorization code from Google (via /auth/callback?code=...)
- Exchange code for Google ID token using
google-auth-library - Extract user info from ID token
- Create or update user in Firestore
- Generate Firebase custom token using Admin SDK
- Return token and user info to frontend
- Frontend stores token in httpOnly cookie
Validates an authentication token.
Request:
POST http://localhost:3001/auth/verify-token
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkaXNkY...
{
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkaXNkY..."
}Response (200 OK):
{
"valid": true,
"uid": "google_oauth_user_123",
"email": "user@gmail.com"
}Response (401 Unauthorized):
{
"error": "Invalid token"
}Flow:
- Receive token from request header or body
- Validate token signature using Firebase Admin SDK
- If valid, return decoded user info
- If invalid, return 401 error
Clears user session.
Request:
POST http://localhost:3001/auth/signout
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkaXNkY...Response (200 OK):
{
"message": "Signed out successfully"
}Flow:
- Receive token from header
- Optionally revoke token in Firestore (update user document)
- Clear session on backend
- Frontend clears httpOnly cookie
- Redirect to /signin
File: backend/src/middleware/auth.middleware.ts
Enhanced to validate actual Firebase tokens instead of just checking existence.
Current (placeholder):
export const requireAuth = (req: any, res: any, next: any) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
req.user = { uid: token };
next();
};Updated:
import * as admin from 'firebase-admin';
export const requireAuth = async (req: any, res: any, next: any) => {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token provided' });
const decodedToken = await admin.auth().verifyIdToken(token);
req.user = { uid: decodedToken.uid, email: decodedToken.email };
next();
} catch (error: any) {
return res.status(401).json({ error: 'Invalid token', message: error.message });
}
};Key Changes:
- Use Firebase Admin SDK to verify token signature
- Decode token to extract real user info (uid, email, etc.)
- Attach decoded user to
req.user - Return 401 for invalid/expired tokens
All board routes are protected by requireAuth:
// backend/src/routes/board.route.ts
router.post('/boards', requireAuth, boardController.createBoard);
router.get('/boards', requireAuth, boardController.listBoards);
router.get('/:id', requireAuth, boardController.getBoard);
router.put('/:id', requireAuth, boardController.updateBoard);
router.patch('/:id/visibility', requireAuth, boardController.updateVisibility);
router.post('/:id/share', requireAuth, boardController.addCollaborator);
router.delete('/:id/share/:userId', requireAuth, boardController.removeCollaborator);Each board operation:
- Extracts
req.user.uidfrom token - Passes UID to service layer
- Service checks Firestore permissions before allowing operation
- Returns 401 if token invalid, 403 if user lacks permission
File: web/app/signin/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { getGoogleAuthUrl } from '@/lib/auth';
export default function SignInPage() {
const router = useRouter();
const handleGoogleSignIn = async () => {
try {
const authUrl = await getGoogleAuthUrl();
window.location.href = authUrl;
} catch (error) {
console.error('Auth error:', error);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<h1>Stun - AI Thinking Environment</h1>
<button onClick={handleGoogleSignIn} style={{ padding: '10px 20px', fontSize: '16px' }}>
Sign in with Google
</button>
</div>
);
}Flow:
- Display "Sign in with Google" button
- Click handler calls
getGoogleAuthUrl() - Redirects to Google authorization endpoint
- After user grants consent, Google redirects to
/auth/callback?code=...
File: web/app/auth/callback/page.tsx
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { exchangeCodeForToken } from '@/lib/auth';
export default function CallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const handleCallback = async () => {
try {
const code = searchParams.get('code');
if (!code) {
router.push('/signin');
return;
}
const { token, user } = await exchangeCodeForToken(code);
// Store token in httpOnly cookie (via API)
await fetch('/api/auth/set-token', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
// Redirect to home with user stored locally
localStorage.setItem('user', JSON.stringify(user));
router.push('/');
} catch (error) {
console.error('Callback error:', error);
router.push('/signin');
}
};
handleCallback();
}, [searchParams, router]);
return <div>Authenticating...</div>;
}Flow:
- Mount and extract
codefrom URL query params - Call
exchangeCodeForToken(code)to exchange code for token - Store token in httpOnly cookie via API call
- Store user info in localStorage
- Redirect to home page
File: web/lib/auth.ts
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
export async function getGoogleAuthUrl(): Promise<string> {
const response = await fetch(`${API_BASE}/auth/signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirectUrl: window.location.origin })
});
const { authUrl } = await response.json();
return authUrl;
}
export async function exchangeCodeForToken(code: string) {
const response = await fetch(`${API_BASE}/auth/callback`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
redirectUrl: window.location.origin
})
});
const { token, user } = await response.json();
return { token, user };
}
export async function verifyToken(token: string) {
const response = await fetch(`${API_BASE}/auth/verify-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
return await response.json();
}
export async function signOut() {
const response = await fetch(`${API_BASE}/auth/signout`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
});
// Clear local storage
localStorage.removeItem('user');
localStorage.removeItem('token');
return await response.json();
}Key Functions:
getGoogleAuthUrl()- Get authorization endpoint URL from backendexchangeCodeForToken(code)- Exchange code for tokenverifyToken(token)- Validate token with backendsignOut()- Clear session and local storage
File: web/hooks/useAuth.ts
import { useEffect, useState } from 'react';
export interface User {
uid: string;
email: string;
name: string;
picture?: string;
}
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const storedUser = localStorage.getItem('user');
const storedToken = localStorage.getItem('token');
if (storedUser && storedToken) {
setUser(JSON.parse(storedUser));
setToken(storedToken);
}
setLoading(false);
}, []);
const logout = async () => {
// Call signout endpoint
localStorage.removeItem('user');
localStorage.removeItem('token');
setUser(null);
setToken(null);
};
return { user, token, loading, logout };
}Usage:
function MyComponent() {
const { user, token, loading } = useAuth();
if (loading) return <div>Loading...</div>;
if (!user) return <div>Not authenticated</div>;
return <div>Welcome {user.email}</div>;
}File: web/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const publicRoutes = ['/signin', '/auth/callback'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public routes
if (publicRoutes.includes(pathname)) {
return NextResponse.next();
}
// Check for auth token in cookies
const token = request.cookies.get('token')?.value;
// Redirect to signin if no token and not a public route
if (!token && !publicRoutes.includes(pathname)) {
return NextResponse.redirect(new URL('/signin', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};Protection Logic:
- Define public routes:
/signin,/auth/callback - All other routes require token in cookies
- Redirect to
/signinif no token - Allow token to flow through to API calls
- httpOnly Cookies: Server sets token in httpOnly, secure cookie
- No localStorage: Never store tokens in localStorage (XSS vulnerability)
- Secure Flag: In production, use secure flag (HTTPS only)
- SameSite: Use
SameSite=Strictto prevent CSRF
- Firebase Admin SDK: Verify token signature server-side
- Token Expiry: Tokens expire after 1 hour; refresh token strategy required
- Rotation: Consider short-lived tokens with refresh token rotation
- CORS: Configure CORS to only allow frontend origin
- Rate Limiting: Implement rate limiting on auth endpoints
- Logging: Log auth attempts for security auditing
- HTTPS: Use HTTPS in production
Symptom: Error: Invalid client. The OAuth client was not found.
Solution:
- Verify
GOOGLE_CLIENT_IDin Google Cloud Console matches env variable - Check Client ID is for a "Web application" not mobile/desktop
- Ensure client secret is correct
Symptom: The redirect URI in the request does not match
Solution:
- Add
http://localhost:3000/auth/callbackto Google Cloud Console authorized redirect URIs - Ensure frontend sends correct redirect URL to backend
- Check exact match including protocol and path
Symptom: Import error for Google OAuth library
Solution:
cd backend
bun add google-auth-library firebase-adminSymptom: 401 Unauthorized - Invalid token
Solution:
- Ensure
FIREBASE_PROJECT_IDand credentials are correct - Check token hasn't expired (valid for 1 hour)
- Verify Firebase Admin SDK is initialized properly
- Check Firestore has configured rules allowing reads/writes
- Start backend:
cd backend && bun run dev - Start frontend:
cd web && bun run dev - Visit
http://localhost:3000→ auto-redirects to/signin - Click "Sign in with Google"
- Complete Google consent screen
- Redirect to
http://localhost:3000/with authentication - Create/list boards should work with authenticated user
- Click signout to clear session
- Implement refresh token rotation (tokens expire after 1 hour)
- Add "Remember me" functionality
- Implement multi-factor authentication
- Add social login (GitHub, Microsoft)
- Create user profile/settings page
- Implement password reset flow (if supporting email/password)