Files
impactflow_discovery/app/auth.py
T
Joel Salmon 33674f92f4 Complete Phase 1: goals, cookie auth, profile editing
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with
the auth layer.

Goals (5 -> 7 prompts):
- Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw
  text on the conversation and store AI-articulated goal summaries on the
  profile. Extractor articulates the person's own stated goals (mirror,
  not compass) and never fabricates. Alembic 003 adds the four columns.

Cookie-based browser sessions (fixes frontend<->auth desync):
- OAuth callback now sets httpOnly session cookies and redirects into the
  app instead of returning JSON. get_current_user gains a cookie fallback
  (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie
  and set/clear cookies. New shared auth.js (authedFetch) sends cookies and
  silently refreshes on 401. Static pages drop the bogus user_id and call
  the correct /me endpoints.

Profile editing (read/edit/affirm):
- PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap
  narrative, goals); owner-scoped, partial update, 409 when locked. Edit
  mode in profile.html with Save/Cancel.

Also: bump default model to claude-sonnet-4-6, align ports to 8011
(OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and
refresh the README to match the shipped behavior.

Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases;
factored a shared app_client fixture into conftest.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:26:08 -05:00

296 lines
9.1 KiB
Python

"""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"
# Cookie names for the browser session. The refresh cookie is scoped to the
# auth path so it is only ever sent to /api/auth/* (refresh, logout), not to
# every discovery request.
ACCESS_COOKIE = "access_token"
REFRESH_COOKIE = "refresh_token"
REFRESH_COOKIE_PATH = "/api/auth"
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 _cookie_secure() -> bool:
"""Secure-by-default. Set COOKIE_SECURE=false for local http dev, where
Secure cookies would never be sent over plain http://localhost."""
return os.getenv("COOKIE_SECURE", "true").strip().lower() not in (
"false",
"0",
"no",
)
def set_auth_cookies(
response, access_token: str, refresh_token: Optional[str] = None
) -> None:
"""Write the session as httpOnly cookies. Pass refresh_token only when it
rotates (login); a plain access refresh leaves the refresh cookie intact."""
secure = _cookie_secure()
response.set_cookie(
ACCESS_COOKIE,
access_token,
max_age=_access_minutes() * 60,
httponly=True,
secure=secure,
samesite="lax",
path="/",
)
if refresh_token is not None:
response.set_cookie(
REFRESH_COOKIE,
refresh_token,
max_age=_refresh_days() * 86400,
httponly=True,
secure=secure,
samesite="lax",
path=REFRESH_COOKIE_PATH,
)
def clear_auth_cookies(response) -> None:
response.delete_cookie(ACCESS_COOKIE, path="/")
response.delete_cookie(REFRESH_COOKIE, path=REFRESH_COOKIE_PATH)
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
# Browser session: the access token rides in an httpOnly cookie. An
# expired cookie decodes to 401, which the frontend recovers from by
# calling /api/auth/refresh.
cookie_token = request.cookies.get(ACCESS_COOKIE)
if cookie_token:
payload = decode_access_token(cookie_token)
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 = "cookie"
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