OAuth Social Login
Add "Sign in with Google/GitHub" to your app.
When to Use This Skill
-
Adding social login options
-
Reducing signup friction
-
Linking multiple auth providers to one account
-
Enterprise SSO requirements
Architecture
┌─────────────────────────────────────────────────────┐ │ User clicks │ │ "Sign in with Google" │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Redirect to Provider │ │ │ │ /auth/google → Google OAuth consent screen │ │ Include: client_id, redirect_uri, scope, state │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Provider Callback │ │ │ │ /auth/google/callback?code=xxx&state=yyy │ │ 1. Verify state (CSRF protection) │ │ 2. Exchange code for tokens │ │ 3. Fetch user profile │ │ 4. Create/link user account │ │ 5. Issue session/JWT │ └─────────────────────────────────────────────────────┘
TypeScript Implementation
OAuth Configuration
// oauth-config.ts interface OAuthProvider { name: string; clientId: string; clientSecret: string; authorizationUrl: string; tokenUrl: string; userInfoUrl: string; scopes: string[]; }
const providers: Record<string, OAuthProvider> = { google: { name: 'Google', clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo', scopes: ['openid', 'email', 'profile'], }, github: { name: 'GitHub', clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, authorizationUrl: 'https://github.com/login/oauth/authorize', tokenUrl: 'https://github.com/login/oauth/access_token', userInfoUrl: 'https://api.github.com/user', scopes: ['read:user', 'user:email'], }, };
export { providers, OAuthProvider };
OAuth Service
// oauth-service.ts import crypto from 'crypto'; import { providers, OAuthProvider } from './oauth-config';
interface OAuthTokens { accessToken: string; refreshToken?: string; expiresIn?: number; tokenType: string; }
interface OAuthUserInfo { id: string; email: string; name?: string; picture?: string; provider: string; }
class OAuthService { private stateStore = new Map<string, { provider: string; redirectTo?: string }>();
generateAuthUrl(providerName: string, redirectTo?: string): string {
const provider = providers[providerName];
if (!provider) throw new Error(Unknown provider: ${providerName});
// Generate CSRF state token
const state = crypto.randomBytes(32).toString('hex');
this.stateStore.set(state, { provider: providerName, redirectTo });
// Auto-expire state after 10 minutes
setTimeout(() => this.stateStore.delete(state), 10 * 60 * 1000);
const params = new URLSearchParams({
client_id: provider.clientId,
redirect_uri: this.getCallbackUrl(providerName),
response_type: 'code',
scope: provider.scopes.join(' '),
state,
access_type: 'offline', // For refresh tokens (Google)
prompt: 'consent',
});
return `${provider.authorizationUrl}?${params}`;
}
async handleCallback( providerName: string, code: string, state: string ): Promise<{ user: OAuthUserInfo; redirectTo?: string }> { // Verify state const stateData = this.stateStore.get(state); if (!stateData || stateData.provider !== providerName) { throw new Error('Invalid state parameter'); } this.stateStore.delete(state);
const provider = providers[providerName];
if (!provider) throw new Error(`Unknown provider: ${providerName}`);
// Exchange code for tokens
const tokens = await this.exchangeCode(provider, code);
// Fetch user info
const userInfo = await this.fetchUserInfo(provider, tokens.accessToken, providerName);
return { user: userInfo, redirectTo: stateData.redirectTo };
}
private async exchangeCode(provider: OAuthProvider, code: string): Promise<OAuthTokens> { const response = await fetch(provider.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ client_id: provider.clientId, client_secret: provider.clientSecret, code, grant_type: 'authorization_code', redirect_uri: this.getCallbackUrl(provider.name.toLowerCase()), }), });
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
tokenType: data.token_type,
};
}
private async fetchUserInfo(
provider: OAuthProvider,
accessToken: string,
providerName: string
): Promise<OAuthUserInfo> {
const response = await fetch(provider.userInfoUrl, {
headers: {
Authorization: Bearer ${accessToken},
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
const data = await response.json();
// Normalize user info across providers
return this.normalizeUserInfo(data, providerName);
}
private normalizeUserInfo(data: Record<string, unknown>, provider: string): OAuthUserInfo {
switch (provider) {
case 'google':
return {
id: data.id as string,
email: data.email as string,
name: data.name as string,
picture: data.picture as string,
provider: 'google',
};
case 'github':
return {
id: String(data.id),
email: data.email as string,
name: (data.name || data.login) as string,
picture: data.avatar_url as string,
provider: 'github',
};
default:
throw new Error(Unknown provider: ${provider});
}
}
private getCallbackUrl(provider: string): string {
return ${process.env.APP_URL}/auth/${provider}/callback;
}
}
export const oauthService = new OAuthService();
Express Routes
// oauth-routes.ts import { Router, Request, Response } from 'express'; import { oauthService } from './oauth-service'; import { userService } from './user-service'; import { sessionService } from './session-service';
const router = Router();
// Initiate OAuth flow router.get('/auth/:provider', (req: Request, res: Response) => { const { provider } = req.params; const { redirect } = req.query;
try { const authUrl = oauthService.generateAuthUrl(provider, redirect as string); res.redirect(authUrl); } catch (error) { res.status(400).json({ error: (error as Error).message }); } });
// OAuth callback router.get('/auth/:provider/callback', async (req: Request, res: Response) => { const { provider } = req.params; const { code, state, error } = req.query;
if (error) {
return res.redirect(/login?error=${error});
}
if (!code || !state) { return res.redirect('/login?error=missing_params'); }
try { const { user: oauthUser, redirectTo } = await oauthService.handleCallback( provider, code as string, state as string );
// Find or create user
let user = await userService.findByOAuthId(provider, oauthUser.id);
if (!user) {
// Check if email already exists
const existingUser = await userService.findByEmail(oauthUser.email);
if (existingUser) {
// Link OAuth to existing account
user = await userService.linkOAuthAccount(existingUser.id, {
provider,
providerId: oauthUser.id,
email: oauthUser.email,
});
} else {
// Create new user
user = await userService.createFromOAuth({
email: oauthUser.email,
name: oauthUser.name,
picture: oauthUser.picture,
provider,
providerId: oauthUser.id,
});
}
}
// Create session
const session = await sessionService.create(user.id);
res.cookie('session', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.redirect(redirectTo || '/dashboard');
} catch (error) { console.error('OAuth callback error:', error); res.redirect('/login?error=oauth_failed'); } });
export { router as oauthRoutes };
User Service (Account Linking)
// user-service.ts interface User { id: string; email: string; name?: string; picture?: string; oauthAccounts: OAuthAccount[]; }
interface OAuthAccount { provider: string; providerId: string; email: string; }
class UserService { async findByOAuthId(provider: string, providerId: string): Promise<User | null> { // Find user by OAuth provider ID return db.user.findFirst({ where: { oauthAccounts: { some: { provider, providerId }, }, }, include: { oauthAccounts: true }, }); }
async findByEmail(email: string): Promise<User | null> { return db.user.findUnique({ where: { email }, include: { oauthAccounts: true }, }); }
async createFromOAuth(data: { email: string; name?: string; picture?: string; provider: string; providerId: string; }): Promise<User> { return db.user.create({ data: { email: data.email, name: data.name, picture: data.picture, oauthAccounts: { create: { provider: data.provider, providerId: data.providerId, email: data.email, }, }, }, include: { oauthAccounts: true }, }); }
async linkOAuthAccount(userId: string, account: OAuthAccount): Promise<User> { return db.user.update({ where: { id: userId }, data: { oauthAccounts: { create: account, }, }, include: { oauthAccounts: true }, }); }
async unlinkOAuthAccount(userId: string, provider: string): Promise<void> { const user = await this.findById(userId);
// Ensure user has another way to login
if (user.oauthAccounts.length <= 1 && !user.passwordHash) {
throw new Error('Cannot unlink last authentication method');
}
await db.oauthAccount.deleteMany({
where: { userId, provider },
});
} }
export const userService = new UserService();
Python Implementation
oauth_service.py
import secrets import httpx from dataclasses import dataclass from typing import Optional from urllib.parse import urlencode
@dataclass class OAuthProvider: name: str client_id: str client_secret: str authorization_url: str token_url: str user_info_url: str scopes: list[str]
PROVIDERS = { "google": OAuthProvider( name="Google", client_id=os.environ["GOOGLE_CLIENT_ID"], client_secret=os.environ["GOOGLE_CLIENT_SECRET"], authorization_url="https://accounts.google.com/o/oauth2/v2/auth", token_url="https://oauth2.googleapis.com/token", user_info_url="https://www.googleapis.com/oauth2/v2/userinfo", scopes=["openid", "email", "profile"], ), "github": OAuthProvider( name="GitHub", client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], authorization_url="https://github.com/login/oauth/authorize", token_url="https://github.com/login/oauth/access_token", user_info_url="https://api.github.com/user", scopes=["read:user", "user:email"], ), }
class OAuthService: def init(self): self._state_store: dict[str, dict] = {}
def generate_auth_url(self, provider_name: str, redirect_to: Optional[str] = None) -> str:
provider = PROVIDERS.get(provider_name)
if not provider:
raise ValueError(f"Unknown provider: {provider_name}")
state = secrets.token_hex(32)
self._state_store[state] = {"provider": provider_name, "redirect_to": redirect_to}
params = {
"client_id": provider.client_id,
"redirect_uri": self._get_callback_url(provider_name),
"response_type": "code",
"scope": " ".join(provider.scopes),
"state": state,
}
return f"{provider.authorization_url}?{urlencode(params)}"
async def handle_callback(self, provider_name: str, code: str, state: str):
state_data = self._state_store.pop(state, None)
if not state_data or state_data["provider"] != provider_name:
raise ValueError("Invalid state")
provider = PROVIDERS[provider_name]
tokens = await self._exchange_code(provider, code)
user_info = await self._fetch_user_info(provider, tokens["access_token"], provider_name)
return {"user": user_info, "redirect_to": state_data.get("redirect_to")}
async def _exchange_code(self, provider: OAuthProvider, code: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
provider.token_url,
data={
"client_id": provider.client_id,
"client_secret": provider.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": self._get_callback_url(provider.name.lower()),
},
headers={"Accept": "application/json"},
)
response.raise_for_status()
return response.json()
async def _fetch_user_info(self, provider: OAuthProvider, access_token: str, provider_name: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(
provider.user_info_url,
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
data = response.json()
return self._normalize_user_info(data, provider_name)
def _normalize_user_info(self, data: dict, provider: str) -> dict:
if provider == "google":
return {
"id": data["id"],
"email": data["email"],
"name": data.get("name"),
"picture": data.get("picture"),
"provider": "google",
}
elif provider == "github":
return {
"id": str(data["id"]),
"email": data["email"],
"name": data.get("name") or data.get("login"),
"picture": data.get("avatar_url"),
"provider": "github",
}
raise ValueError(f"Unknown provider: {provider}")
def _get_callback_url(self, provider: str) -> str:
return f"{os.environ['APP_URL']}/auth/{provider}/callback"
oauth_service = OAuthService()
Database Schema
-- Users table CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255), picture TEXT, password_hash VARCHAR(255), -- NULL for OAuth-only users created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() );
-- OAuth accounts (one user can have multiple) CREATE TABLE oauth_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, provider VARCHAR(50) NOT NULL, provider_id VARCHAR(255) NOT NULL, email VARCHAR(255), access_token TEXT, refresh_token TEXT, expires_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(provider, provider_id) );
CREATE INDEX idx_oauth_accounts_user ON oauth_accounts(user_id); CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_id);
Frontend Integration
// Login component function LoginPage() { return ( <div className="login-options"> <a href="/auth/google" className="oauth-button google"> <GoogleIcon /> Sign in with Google </a> <a href="/auth/github" className="oauth-button github"> <GitHubIcon /> Sign in with GitHub </a> <div className="divider">or</div> <form className="email-login"> {/* Email/password form */} </form> </div> ); }
Security Considerations
-
Always verify state parameter - Prevents CSRF attacks
-
Use HTTPS in production - Tokens are sensitive
-
Validate email ownership - Some providers don't verify emails
-
Handle account linking carefully - Prevent account takeover
-
Store tokens securely - Encrypt refresh tokens at rest
Common Mistakes
-
Not validating state parameter
-
Storing access tokens in localStorage
-
Not handling token refresh
-
Allowing unverified email linking
-
Missing error handling for revoked tokens