Dev Auth Mock Implementation Plan
Goal: Allow local development without Google OAuth credentials by providing mock authentication with role selection (admin/commenter).
Architecture: NextAuth Credentials provider (dev-only) presents a role picker on the sign-in page. It issues tokens prefixed dev-mock-<role>. The backend auth decorator recognizes this prefix when ALLOW_DEV_AUTH=true is set, skips Google API calls, but still runs the full middleware chain (header extraction, cache, user upsert, role assignment). Two layers prevent production execution: the env var gate and the frontend provider only loading in development.
Tech Stack: NextAuth.js Credentials provider, FastAPI auth decorator, Docker Compose env vars
Task 1: Backend — Dev Token Recognition in Auth Decorator
Files:
- Modify:
backend/decorators/auth.py:116-185 - Test:
backend/tests/test_dev_auth.py(create)
Step 1: Write the failing tests
Create backend/tests/test_dev_auth.py:
import os
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from decorators.auth import requires_auth
app = FastAPI()
@app.get("/protected")
@requires_auth
async def protected_endpoint(request: Request):
return {
"user_id": request.state.user.id,
"email": request.state.user.email,
"role": request.state.user.role,
}
DEV_TOKEN_PREFIX = "dev-mock-"
DEV_USERS = {
"admin": {
"email": "dev-admin@localhost",
"name": "Dev Admin",
"role": "admin",
},
"commenter": {
"email": "dev-commenter@localhost",
"name": "Dev Commenter",
"role": "commenter",
},
}
@pytest.fixture
def enable_dev_auth():
with patch.dict(os.environ, {"ALLOW_DEV_AUTH": "true"}):
yield
@pytest.fixture
def disable_dev_auth():
with patch.dict(os.environ, {"ALLOW_DEV_AUTH": ""}, clear=False):
yield
@pytest.fixture
def mock_users_for_dev():
from bson import ObjectId
async def mock_get_collection():
collection = AsyncMock()
collection.find_one_and_update.return_value = {
"_id": ObjectId(),
"email": "dev-admin@localhost",
"name": "Dev Admin",
"role": "admin",
"auth_providers": [],
}
return collection
with patch("decorators.auth.get_users_collection", mock_get_collection):
yield
@pytest.mark.asyncio
async def test_dev_token_admin_accepted_when_enabled(enable_dev_auth, mock_users_for_dev):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/protected",
headers={"Authorization": "Bearer dev-mock-admin"},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "dev-admin@localhost"
assert data["role"] == "admin"
@pytest.mark.asyncio
async def test_dev_token_commenter_accepted_when_enabled(enable_dev_auth, mock_users_for_dev):
from bson import ObjectId
async def mock_get_collection():
collection = AsyncMock()
collection.find_one_and_update.return_value = {
"_id": ObjectId(),
"email": "dev-commenter@localhost",
"name": "Dev Commenter",
"role": "commenter",
"auth_providers": [],
}
return collection
with patch("decorators.auth.get_users_collection", mock_get_collection):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/protected",
headers={"Authorization": "Bearer dev-mock-commenter"},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "dev-commenter@localhost"
assert data["role"] == "commenter"
@pytest.mark.asyncio
async def test_dev_token_rejected_when_disabled(disable_dev_auth):
"""Dev tokens must be rejected when ALLOW_DEV_AUTH is not set."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/protected",
headers={"Authorization": "Bearer dev-mock-admin"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_dev_token_invalid_role_rejected(enable_dev_auth):
"""Dev tokens with unknown roles must be rejected."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/protected",
headers={"Authorization": "Bearer dev-mock-superadmin"},
)
assert response.status_code == 401Step 2: Run tests to verify they fail
Run: cd backend && python -m pytest tests/test_dev_auth.py -v
Expected: FAIL — no dev token handling exists yet.
Step 3: Implement dev token handling
Modify backend/decorators/auth.py. Add constants and a dev token handler before verify_auth_and_get_user:
# Dev auth constants — only active when ALLOW_DEV_AUTH=true
DEV_TOKEN_PREFIX = "dev-mock-"
_DEV_USERS = {
"admin": {"email": "dev-admin@localhost", "name": "Dev Admin"},
"commenter": {"email": "dev-commenter@localhost", "name": "Dev Commenter"},
}
async def _handle_dev_token(token: str) -> UserInfo | None:
"""Authenticate using a dev mock token. Returns None if not a dev token."""
if not token.startswith(DEV_TOKEN_PREFIX):
return None
if os.environ.get("ALLOW_DEV_AUTH") != "true":
raise HTTPException(status_code=401, detail="Dev auth is not enabled.")
role = token[len(DEV_TOKEN_PREFIX):]
dev_user = _DEV_USERS.get(role)
if not dev_user:
raise HTTPException(status_code=401, detail=f"Unknown dev role: {role}")
return await get_or_create_user(
email=dev_user["email"],
name=dev_user["name"],
avatar_url=None,
provider_user_id=f"dev-{role}",
)Then in verify_auth_and_get_user, after _extract_bearer_token and cache check, add:
# Dev token bypass — only when ALLOW_DEV_AUTH=true
dev_user = await _handle_dev_token(token)
if dev_user:
_cache_user(token, dev_user)
return dev_userStep 4: Run tests to verify they pass
Run: cd backend && python -m pytest tests/test_dev_auth.py -v
Expected: All 4 tests PASS.
Step 5: Run full test suite
Run: cd backend && python -m pytest -x -q
Expected: All existing tests still pass.
Step 6: Commit
git add backend/decorators/auth.py backend/tests/test_dev_auth.py
git commit -m "feat: dev token auth bypass gated on ALLOW_DEV_AUTH"Task 2: Frontend — Credentials Provider for Dev Login
Files:
- Modify:
frontend/src/pages/api/auth/[...nextauth].ts - Modify:
frontend/src/shared/lib/auth.ts
Step 1: Add dev auth constants to shared auth module
In frontend/src/shared/lib/auth.ts, add:
export const REFRESH_TOKEN_ERROR = 'RefreshTokenError' as const;
export const DEV_TOKEN_PREFIX = 'dev-mock-';
export const DEV_USERS = {
admin: { name: 'Dev Admin', email: 'dev-admin@localhost' },
commenter: { name: 'Dev Commenter', email: 'dev-commenter@localhost' },
} as const;
export type DevRole = keyof typeof DEV_USERS;Step 2: Add Credentials provider to NextAuth config
In frontend/src/pages/api/auth/[...nextauth].ts, add CredentialsProvider import and conditionally include it in the providers array:
import CredentialsProvider from "next-auth/providers/credentials";
import { DEV_TOKEN_PREFIX, DEV_USERS, type DevRole } from "@/shared/lib/auth";
// Build providers list — dev credentials only in development
const providers = [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
scope: "openid email profile",
access_type: "offline",
prompt: "consent",
},
},
}),
];
if (process.env.NODE_ENV === 'development') {
providers.push(
CredentialsProvider({
id: "dev-credentials",
name: "Dev Login",
credentials: {
role: { label: "Role", type: "text" },
},
async authorize(credentials) {
const role = credentials?.role as DevRole;
const devUser = DEV_USERS[role];
if (!devUser) return null;
return {
id: `dev-${role}`,
name: devUser.name,
email: devUser.email,
image: null,
};
},
}) as any,
);
}Then update the jwt callback to handle credentials sign-in (no Google tokens, just the dev mock token):
async jwt({ token, account, user }) {
if (account) {
if (account.provider === 'dev-credentials') {
// Dev login — use mock token, skip Google token flow
const role = user.id?.replace('dev-', '') || 'commenter';
token.accessToken = `${DEV_TOKEN_PREFIX}${role}`;
token.accessTokenExpires = Date.now() + 24 * 60 * 60 * 1000; // 24h
// Fetch role from backend via /me (uses the dev token)
// ... same backend call as Google flow ...
} else {
// Google sign-in — existing flow
token.accessToken = account.access_token;
// ... rest unchanged ...
}
}
// ... rest of jwt callback unchanged ...
}Step 3: Update session callback
The session callback needs the user parameter added to get dev user info. The Credentials provider passes user data through differently than OAuth — it comes via the user param on initial sign-in, not from the account. This is already handled by the jwt callback storing userId and userRole from the /me backend call.
No changes needed to the session callback if the /me endpoint works with dev tokens (handled by Task 1).
Step 4: Commit
git add frontend/src/shared/lib/auth.ts frontend/src/pages/api/auth/\\[...nextauth\\].ts
git commit -m "feat: add dev Credentials provider for local auth"Task 3: Frontend — Sign-in UI with Role Selection
Files:
- Modify:
frontend/src/layout/TopNav.tsx
Step 1: Update sign-in button for dev mode
The current sign-in button calls signIn("google"). In development, replace it with role selection buttons:
const isDev = process.env.NODE_ENV === 'development';
// In the JSX:
{isDev ? (
<>
<button
onClick={() => signIn("dev-credentials", { role: "admin", callbackUrl: "/" })}
className="btn btn--primary"
data-testid="signin-dev-admin"
>
Dev Admin
</button>
<button
onClick={() => signIn("dev-credentials", { role: "commenter", callbackUrl: "/" })}
className="btn btn--secondary"
data-testid="signin-dev-commenter"
>
Dev Commenter
</button>
</>
) : (
<button
onClick={() => signIn("google")}
className="btn btn--primary"
data-testid="signin-button"
>
Sign in
</button>
)}Step 2: Verify manually
Run: make dev-local
- Navigate to site
- Two buttons should appear: “Dev Admin” and “Dev Commenter”
- Clicking either should sign in immediately (no Google redirect)
- Session should show correct role
- Admin should see editor/admin UI
- Commenter should see restricted UI
Step 3: Commit
git add frontend/src/layout/TopNav.tsx
git commit -m "feat: dev role picker buttons in TopNav"Task 4: Docker Compose — Set ALLOW_DEV_AUTH
Files:
- Modify:
docker-compose.yml - Modify:
.env.example
Step 1: Add ALLOW_DEV_AUTH to backend service
In docker-compose.yml, add to backend environment:
environment:
# ... existing vars ...
- ALLOW_DEV_AUTH=trueStep 2: Document in .env.example
Add to .env.example:
# Dev auth — set to "true" for local mock SSO (NEVER set in production)
ALLOW_DEV_AUTH=trueStep 3: Verify deploy.yml does NOT set ALLOW_DEV_AUTH
Read .github/workflows/deploy.yml and confirm the --set-env-vars line does not include ALLOW_DEV_AUTH. This is the production safety guarantee.
Step 4: Commit
git add docker-compose.yml .env.example
git commit -m "feat: enable ALLOW_DEV_AUTH in local Docker Compose"Task 5: NextAuth Session Strategy for Credentials
Files:
- Modify:
frontend/src/pages/api/auth/[...nextauth].ts
Step 1: Set session strategy to jwt
NextAuth’s Credentials provider requires session: { strategy: "jwt" } to be explicit (it defaults to “jwt” when no database adapter is present, but best to be explicit since adding Credentials changes NextAuth behavior):
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
providers: [
// ...
],
// ...
};Step 2: Verify locally
Run: make dev-local
- Sign in via dev admin button
- Refresh page — session should persist
- Check browser cookies — should see
next-auth.session-tokenJWT cookie
Step 3: Commit
git add frontend/src/pages/api/auth/\\[...nextauth\\].ts
git commit -m "fix: explicit jwt session strategy for credentials provider"Task 6: Formatting + Full Test Suite
Step 1: Format
Run: make format
Step 2: Run backend tests
Run: make test
Expected: All tests pass including new dev auth tests.
Step 3: Run frontend lint + type check
Run: cd frontend && npm run lint && npx tsc --noEmit
Expected: Clean.
Step 4: Commit any formatting fixes
git add -A
git commit -m "style: format"Task 7: Documentation — CLAUDE.md + .env.example
Step 1: Update CLAUDE.md
Add to the Environment Setup section, after the existing env var docs:
### Dev Authentication
Local development supports mock authentication without Google OAuth credentials.
Set `ALLOW_DEV_AUTH=true` in the backend environment (Docker Compose sets this automatically).
The frontend shows role-picker buttons (Admin/Commenter) instead of Google Sign-In when
`NODE_ENV=development`. Dev tokens use the `dev-mock-<role>` prefix and `@localhost` emails.
**Safety:** Two layers prevent production execution:
1. Backend rejects dev tokens unless `ALLOW_DEV_AUTH=true` (never set in deploy.yml)
2. Frontend Credentials provider only loads when `NODE_ENV=development`Step 2: Commit
git add CLAUDE.md
git commit -m "docs: document dev auth mock system"