Audience: Developers who want to add a new OAuth provider (e.g., Facebook, Discord, Microsoft) to HeritageGraph.
- Architecture Overview
- Current Auth Modes
- Quick Start — Switching Auth Modes
- Step-by-Step: Adding a New OAuth Provider
- Reference: Existing Providers
- Token Flow Diagrams
- Troubleshooting
HeritageGraph auth has two layers — a frontend (NextAuth v4) and a backend (Django REST Framework). Every authenticaton flow follows the same pattern:
┌──────────────────┐
│ OAuth Provider │
┌─────────────►│ (Google/GitHub) │
│ └────────┬─────────┘
│ │ Returns token (id_token / access_token)
│ ▼
┌──┴───────────┐ ┌──────────────┐ ┌──────────────────┐
│ Browser │ │ NextAuth │ │ Django Backend │
│ │◄──┤ (Next.js) ├──►│ (DRF) │
│ │ │ │ │ │
│ │ │ Stores │ │ Verifies token, │
│ │ │ token in │ │ creates/syncs │
│ │ │ JWT cookie │ │ User + Profile │
└──────────────┘ └──────────────┘ └──────────────────┘
Key principle: The frontend (NextAuth) handles the OAuth dance and stores the provider's token. The backend (Django) verifies that token on every API request. Each provider needs implementation on both sides.
| Provider | Token sent to Django | Django verification method |
|---|---|---|
| Credentials (dev) | SimpleJWT access token |
JWTAuthentication (built-in) |
Google id_token (JWT) |
google.oauth2.id_token.verify_oauth2_token() |
|
| GitHub | GitHub access_token (opaque) |
Call https://api.github.com/user |
| Mode | Providers | Detection | Use Case |
|---|---|---|---|
| Dev (default) | Credentials (username/password → JWT) | No GOOGLE_CLIENT_ID or GITHUB_ID set |
Local development |
| Google OAuth | GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET set |
Production | |
| GitHub | GitHub OAuth | GITHUB_ID + GITHUB_SECRET set |
Production |
| All | Google + GitHub | Both sets of vars configured | Production (multiple sign-in options) |
Detection is automatic. The frontend checks which env vars are set and enables the corresponding providers at startup.
No setup needed. Just create a user and go:
make auth-dev # Reset to JWT mode (clears OAuth env vars)
make superuser # Create admin user
make backend # Terminal 1
make frontend # Terminal 2
# → Login at http://localhost:3000/auth/login# 1. Get credentials from https://console.cloud.google.com/apis/credentials
# 2. Set callback URL: http://localhost:3000/api/auth/callback/google
make auth-google \
GOOGLE_CLIENT_ID=123456.apps.googleusercontent.com \
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxx
# 3. Also set the backend env var:
echo "GOOGLE_CLIENT_ID=123456.apps.googleusercontent.com" > heritage_graph/.env
# 4. Restart both servers
make kill-ports && make backend # Terminal 1
make frontend # Terminal 2# 1. Get credentials from https://github.com/settings/developers → OAuth Apps
# 2. Set callback URL: http://localhost:3000/api/auth/callback/github
# 3. Set homepage URL: http://localhost:3000
make auth-github \
GITHUB_ID=Ov23lixxxxxxxxxx \
GITHUB_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 4. Also set the backend env vars:
cat >> heritage_graph/.env << 'EOF'
GITHUB_CLIENT_ID=Ov23lixxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
EOF
# 5. Restart both serversmake auth-all \
GOOGLE_CLIENT_ID=123456.apps.googleusercontent.com \
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxx \
GITHUB_ID=Ov23lixxxxxxxxxx \
GITHUB_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxmake auth-statusUse this guide when you want to add a provider that isn't currently supported (e.g., Facebook, Discord, Microsoft, Twitter/X, GitLab).
Every OAuth provider requires you to register an "app" or "client" in their developer console:
- Go to the provider's developer settings
- Create a new OAuth application
- Note the Client ID and Client Secret
- Set the callback/redirect URL to:
For production:
http://localhost:3000/api/auth/callback/<provider_id>https://yourdomain.com/api/auth/callback/<provider_id>
Common provider developer consoles:
| Provider | Developer Console URL |
|---|---|
| https://console.cloud.google.com/apis/credentials | |
| GitHub | https://github.com/settings/developers |
| https://developers.facebook.com/apps/ | |
| Discord | https://discord.com/developers/applications |
| Microsoft | https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps |
| GitLab | https://gitlab.com/-/profile/applications |
| Twitter/X | https://developer.twitter.com/en/portal/projects-and-apps |
NextAuth has 60+ built-in providers. See the full list at: https://next-auth.js.org/providers/
File to edit: heritage_graph_ui/src/app/api/auth/[...nextauth]/route.ts
import MyProvider from "next-auth/providers/<provider_id>";const isMyProviderEnabled =
!!process.env.MY_PROVIDER_CLIENT_ID && !!process.env.MY_PROVIDER_CLIENT_SECRET;Update hasOAuthProvider:
const hasOAuthProvider = isGoogleAuthEnabled || isGitHubAuthEnabled || isMyProviderEnabled;if (isMyProviderEnabled) {
providers.push(
MyProvider({
clientId: process.env.MY_PROVIDER_CLIENT_ID!,
clientSecret: process.env.MY_PROVIDER_CLIENT_SECRET!,
})
);
}Different providers return different token types:
- ID token providers (Google, Microsoft): Use
account.id_token - Access token providers (GitHub, Facebook, Discord): Use
account.access_token
async jwt({ token, user, account }) {
// ... existing code ...
} else if (account?.provider === "<provider_id>") {
// Use id_token or access_token depending on the provider
token.accessToken = account.access_token; // or account.id_token
token.authProvider = "<provider_id>";
}
// ...
}This file mirrors the route handler and is used for getServerSession(). Apply the same changes there.
File to edit: heritage_graph/apps/heritage_data/authentication.py
Create a new authentication class that:
- Reads the
Authorization: Bearer <token>header - Verifies the token with the provider's API
- Extracts user info (email, name)
- Creates or syncs a Django
User+UserProfile - Returns
(user, None)if valid, orNoneif the token isn't for this provider
# ====================================================================
# Production Authentication — <ProviderName> OAuth
# ====================================================================
MY_PROVIDER_CLIENT_ID = os.environ.get("MY_PROVIDER_CLIENT_ID", "")
class MyProviderTokenAuthentication(authentication.BaseAuthentication):
"""
Authenticate requests using <ProviderName> OAuth tokens.
"""
def authenticate(self, request):
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return None
# Skip if provider is not configured
if not MY_PROVIDER_CLIENT_ID:
return None
token = auth_header.split(" ")[1]
# ── Verify the token with the provider ──
# Option A: For access tokens — call the provider's user info API
try:
resp = http_requests.get(
"https://provider.example.com/api/userinfo",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
except http_requests.RequestException:
return None
if resp.status_code != 200:
return None # Not a token for this provider — let next auth class try
user_data = resp.json()
# Option B: For ID tokens (JWT) — verify the signature
# import jwt
# payload = jwt.decode(token, options={"verify_signature": True}, ...)
# ── Extract user info ──
email = user_data.get("email")
if not email:
raise exceptions.AuthenticationFailed("Token missing email.")
first_name = user_data.get("first_name", "")
last_name = user_data.get("last_name", "")
# ── Map to Django user ──
username = email
user, created = User.objects.get_or_create(
username=username,
defaults={"email": email},
)
user.email = email
user.first_name = first_name
user.last_name = last_name
user.save()
if created:
logger.info("Created new user from <Provider> sign-in: %s", email)
# ── Sync UserProfile ──
profile, _ = UserProfile.objects.get_or_create(user=user)
profile.first_name = first_name
profile.last_name = last_name
profile.email = email
profile.save()
return (user, None)| Rule | Why |
|---|---|
Return None if MY_PROVIDER_CLIENT_ID is not set |
Skip gracefully when provider isn't configured |
Return None if the provider rejects the token |
Let the next auth class in the chain try |
Raise AuthenticationFailed only for verified-but-invalid tokens |
E.g., email missing, email not verified |
Use get_or_create for Users |
Auto-create on first login |
Always sync UserProfile |
Downstream code expects it to exist |
Use username = email |
Consistent with Google/GitHub backends |
Add your new class to the DRF authentication chain in both settings files:
heritage_graph/settings/development.py:
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = (
"apps.heritage_data.authentication.DevSessionAuthentication",
"apps.heritage_data.authentication.GoogleTokenAuthentication",
"apps.heritage_data.authentication.GitHubTokenAuthentication",
"apps.heritage_data.authentication.MyProviderTokenAuthentication", # ← ADD
"rest_framework_simplejwt.authentication.JWTAuthentication",
)heritage_graph/settings/production.py:
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = (
"apps.heritage_data.authentication.GoogleTokenAuthentication",
"apps.heritage_data.authentication.GitHubTokenAuthentication",
"apps.heritage_data.authentication.MyProviderTokenAuthentication", # ← ADD
"rest_framework_simplejwt.authentication.JWTAuthentication",
)Order matters: Put custom providers BEFORE
JWTAuthenticationbecause SimpleJWT raisesAuthenticationFailedfor any invalid JWT, which stops the chain. Custom providers returnNoneto pass through.
Update three files:
- Root
.env.example— Add the backend env vars - Frontend
heritage_graph_ui/.env.example— Add the NextAuth env vars - Backend
heritage_graph/.env.example— Add the verification env vars
Example:
# .env.example (root)
MY_PROVIDER_CLIENT_ID=your-client-id
MY_PROVIDER_CLIENT_SECRET=your-client-secret
# heritage_graph_ui/.env.example
MY_PROVIDER_CLIENT_ID=your-client-id
MY_PROVIDER_CLIENT_SECRET=your-client-secret
# heritage_graph/.env.example
MY_PROVIDER_CLIENT_ID=your-client-idNaming convention: NextAuth uses specific env var names for some providers. Check the NextAuth provider docs for the expected names. For example, GitHub uses
GITHUB_IDandGITHUB_SECRET.
Add a new target in the Makefile under the AUTHENTICATION section:
auth-myprovider: ## Enable MyProvider OAuth
@echo "==> Configuring MyProvider OAuth..."
@if [ -z "$(MY_PROVIDER_CLIENT_ID)" ] || [ -z "$(MY_PROVIDER_CLIENT_SECRET)" ]; then \
echo ""; \
echo " Usage: make auth-myprovider MY_PROVIDER_CLIENT_ID=xxx MY_PROVIDER_CLIENT_SECRET=yyy"; \
echo ""; \
echo " Get credentials from: https://provider.example.com/developers"; \
echo " Set callback URL to: http://localhost:3000/api/auth/callback/myprovider"; \
echo ""; \
exit 1; \
fi
@rm -f $(FRONTEND_ENV)
@echo '# Auth mode: MyProvider OAuth' > $(FRONTEND_ENV)
@echo 'NEXTAUTH_URL=http://localhost:3000' >> $(FRONTEND_ENV)
@echo "NEXTAUTH_SECRET=$$(openssl rand -base64 32)" >> $(FRONTEND_ENV)
@echo 'NEXT_PUBLIC_API_URL=http://localhost:8000' >> $(FRONTEND_ENV)
@echo "MY_PROVIDER_CLIENT_ID=$(MY_PROVIDER_CLIENT_ID)" >> $(FRONTEND_ENV)
@echo "MY_PROVIDER_CLIENT_SECRET=$(MY_PROVIDER_CLIENT_SECRET)" >> $(FRONTEND_ENV)
@echo ""
@echo " ✓ Auth mode: MyProvider OAuth"
@echo ""Also update:
- The
.PHONYlist at the top - The
helptarget to show the new command - The
auth-statustarget to check for the new env var - The
auth-alltarget if it should also include the new provider
# 1. Set up the provider
make auth-myprovider MY_PROVIDER_CLIENT_ID=xxx MY_PROVIDER_CLIENT_SECRET=yyy
# 2. Set backend env
echo "MY_PROVIDER_CLIENT_ID=xxx" >> heritage_graph/.env
# 3. Start servers
make backend # Terminal 1
make frontend # Terminal 2
# 4. Open http://localhost:3000
# → Click "Sign in with MyProvider"
# → Should redirect to provider → back to app
# 5. Verify Django user was created
make shell
>>> from django.contrib.auth.models import User
>>> User.objects.last()
# 6. Verify API call works
# From the browser console or another component, check that
# fetch() with the Bearer token returns 200, not 401.
# 7. Check auth status
make auth-status- Frontend:
CredentialsProviderin NextAuth — callsPOST /api/token/with username/password - Backend:
DevSessionAuthentication+JWTAuthentication - Token: SimpleJWT access token
- Login page:
/auth/login - No external setup needed
- Frontend:
GoogleProviderin NextAuth - Backend:
GoogleTokenAuthentication— verifies withgoogle.oauth2.id_token.verify_oauth2_token() - Token type: Google ID token (JWT signed by Google)
- Env vars (frontend):
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET - Env vars (backend):
GOOGLE_CLIENT_ID - Callback URL:
/api/auth/callback/google - Console: https://console.cloud.google.com/apis/credentials
- Frontend:
GitHubProviderin NextAuth - Backend:
GitHubTokenAuthentication— verifies by callinghttps://api.github.com/user - Token type: GitHub access token (opaque string)
- Env vars (frontend):
GITHUB_ID,GITHUB_SECRET - Env vars (backend):
GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET - Callback URL:
/api/auth/callback/github - Console: https://github.com/settings/developers → OAuth Apps
Browser → NextAuth → Google OAuth consent screen
↓
Google returns: { id_token, access_token }
↓
NextAuth stores id_token in JWT cookie
↓
Browser → fetch("/data/api/...", { Authorization: "Bearer <id_token>" })
↓
Django → GoogleTokenAuthentication
→ google.oauth2.id_token.verify_oauth2_token(id_token, GOOGLE_CLIENT_ID)
→ Extracts email, name from JWT claims
→ User.objects.get_or_create(username=email)
→ Returns (user, None)
Browser → NextAuth → GitHub OAuth authorization page
↓
GitHub returns: { access_token }
↓
NextAuth stores access_token in JWT cookie
↓
Browser → fetch("/data/api/...", { Authorization: "Bearer <access_token>" })
↓
Django → GitHubTokenAuthentication
→ GET https://api.github.com/user (with Bearer token)
→ Extracts login, email, name from response
→ User.objects.get_or_create(username=email)
→ Returns (user, None)
Browser → /auth/login → POST /api/token/ { username, password }
↓
SimpleJWT returns { access, refresh }
↓
NextAuth stores access in JWT cookie
↓
Browser → fetch("/data/api/...", { Authorization: "Bearer <jwt_access>" })
↓
Django → JWTAuthentication (SimpleJWT)
→ Verifies JWT signature with Django SECRET_KEY
→ Extracts user_id from token payload
→ Returns (user, None)
The .env.local file may have special characters. Make sure env values don't have quotes:
# ✅ Correct
GITHUB_ID=Ov23lixxx
# ❌ Wrong
GITHUB_ID="Ov23lixxx"Your OAuth app's callback URL must exactly match what NextAuth expects:
http://localhost:3000/api/auth/callback/<provider_id>
For production:
https://yourdomain.com/api/auth/callback/<provider_id>
Check that:
- The backend env var for the provider is set (e.g.,
GITHUB_CLIENT_IDinheritage_graph/.env) - The auth class is listed in
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]in your settings file - The auth class is listed before
JWTAuthenticationin the chain
Check the Next.js server logs (terminal running make frontend). Common causes:
- Missing
NEXTAUTH_SECRET— generate one:openssl rand -base64 32 - Missing
NEXTAUTH_URL— should behttp://localhost:3000
Some GitHub users have private emails. The GitHubTokenAuthentication backend already handles this by calling /user/emails, but the GitHub OAuth app must request the user:email scope. NextAuth's GitHub provider does this by default.
All OAuth backends use username = email for consistency. If a user signs in with Google first and GitHub second (with the same email), they'll map to the same Django user — this is intentional.
make auth-devThis clears the OAuth env vars and switches back to username/password login.
| File | Role |
|---|---|
heritage_graph_ui/src/app/api/auth/[...nextauth]/route.ts |
NextAuth route handler — provider selection + callbacks |
heritage_graph_ui/src/lib/auth.ts |
NextAuth config for getServerSession() |
heritage_graph_ui/src/app/auth/login/page.tsx |
Dev-only login page (username/password) |
heritage_graph_ui/types/next-auth.d.ts |
TypeScript type augmentations |
heritage_graph/apps/heritage_data/authentication.py |
Django DRF auth backends (all providers) |
heritage_graph/settings/development.py |
Dev DRF auth class chain |
heritage_graph/settings/production.py |
Prod DRF auth class chain |
Makefile |
make auth-* commands for switching providers |
.env.example (root) |
Full env var template |
heritage_graph_ui/.env.example |
Frontend env var template |
heritage_graph/.env.example |
Backend env var template |