Initial commit: ImpactFlow Discovery + Google OAuth auth layer

Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.

Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:

- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
  dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
  allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
  tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
  /api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
  /api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
  dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
  resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
  data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
  ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log

Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-05-27 10:59:41 -05:00
commit b8f176bb31
45 changed files with 4679 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
"""Authentication: Google OAuth flow, JWT issuance, dual-auth dependency.
Two ways to authenticate:
- JWT in `Authorization: Bearer <token>` (browser users, issued by /api/auth/callback)
- X-API-Key header matching IMPACTFLOW_API_KEY (machine-to-machine; MCP server)
The API key resolves to a synthetic admin user (`API_KEY_ADMIN_ID`) seeded into
the users table on first use, so foreign keys from data tables stay valid.
"""
import hashlib
import hmac
import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from authlib.integrations.starlette_client import OAuth
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import RefreshToken, User
API_KEY_ADMIN_ID = "api-key-admin"
JWT_ALGORITHM = "HS256"
oauth = OAuth()
oauth.register(
name="google",
client_id=os.getenv("GOOGLE_CLIENT_ID"),
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
server_metadata_url=(
"https://accounts.google.com/.well-known/openid-configuration"
),
client_kwargs={"scope": "openid email profile"},
)
def _jwt_secret() -> str:
secret = os.getenv("JWT_SECRET")
if not secret:
raise RuntimeError("JWT_SECRET is not configured")
return secret
def _access_minutes() -> int:
return int(os.getenv("JWT_ACCESS_MINUTES", "15"))
def _refresh_days() -> int:
return int(os.getenv("JWT_REFRESH_DAYS", "7"))
def create_access_token(user_id: str, email: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"email": email,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=_access_minutes())).timestamp()),
"type": "access",
}
return jwt.encode(payload, _jwt_secret(), algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, _jwt_secret(), algorithms=[JWT_ALGORITHM])
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
) from exc
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Wrong token type",
)
return payload
def hash_refresh_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def issue_refresh_token(
db: AsyncSession, user: User, device: Optional[str]
) -> str:
raw = secrets.token_urlsafe(48)
now = datetime.now(timezone.utc)
row = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token_hash=hash_refresh_token(raw),
device=(device or "")[:255] or None,
created_at=now,
expires_at=now + timedelta(days=_refresh_days()),
)
db.add(row)
await db.commit()
return raw
async def ensure_api_key_admin(db: AsyncSession) -> User:
"""Idempotently return the synthetic admin user backing the API key."""
existing = await db.get(User, API_KEY_ADMIN_ID)
if existing is not None:
return existing
now = datetime.now(timezone.utc)
admin = User(
id=API_KEY_ADMIN_ID,
email="api-key@impactflow.local",
display_name="API Key (MCP)",
role="admin",
created_at=now,
)
db.add(admin)
await db.commit()
await db.refresh(admin)
return admin
_bearer_scheme = HTTPBearer(auto_error=False)
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
_bearer_scheme
),
db: AsyncSession = Depends(get_db),
) -> User:
"""Dual-auth dependency. Tries X-API-Key first (cheap, no JWT decode),
then falls back to the Authorization bearer JWT."""
expected_api_key = os.getenv("IMPACTFLOW_API_KEY")
presented_api_key = request.headers.get("x-api-key")
if (
expected_api_key
and presented_api_key
and hmac.compare_digest(presented_api_key, expected_api_key)
):
admin = await ensure_api_key_admin(db)
request.state.user = admin
request.state.auth_source = "api_key"
return admin
if credentials is not None and credentials.scheme.lower() == "bearer":
payload = decode_access_token(credentials.credentials)
user = await db.get(User, payload["sub"])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User no longer exists",
)
request.state.user = user
request.state.auth_source = "jwt"
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
async def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required",
)
return user
def email_domain_allowed(email: str) -> bool:
raw = os.getenv("ALLOWED_EMAIL_DOMAINS", "").strip()
if not raw:
return True
allowed = {d.strip().lower() for d in raw.split(",") if d.strip()}
domain = email.rsplit("@", 1)[-1].lower()
return domain in allowed
async def find_or_create_google_user(
db: AsyncSession, user_info: dict
) -> User:
"""Idempotent: looks up by google_id, falls back to email, otherwise
creates. First created human user is auto-promoted to admin."""
google_id = user_info["sub"]
email = user_info["email"]
now = datetime.now(timezone.utc)
stmt = select(User).where(User.google_id == google_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if user is None:
stmt = select(User).where(User.email == email)
user = (await db.execute(stmt)).scalar_one_or_none()
if user is not None and user.google_id is None:
user.google_id = google_id
if user is None:
# Auto-promote the first real human user. The synthetic
# API_KEY_ADMIN_ID record is excluded from the count.
stmt = select(User).where(User.id != API_KEY_ADMIN_ID)
is_first = (await db.execute(stmt)).first() is None
user = User(
id=str(uuid.uuid4()),
email=email,
display_name=user_info.get("name") or email,
avatar_url=user_info.get("picture"),
google_id=google_id,
role="admin" if is_first else "user",
created_at=now,
last_login_at=now,
)
db.add(user)
else:
user.last_login_at = now
if user_info.get("name"):
user.display_name = user_info["name"]
if user_info.get("picture"):
user.avatar_url = user_info["picture"]
await db.commit()
await db.refresh(user)
return user