Files
FabledScribe/src/fabledassistant/services/oauth.py
T
bvandeusen b37e15d59a Add Authentik OAuth/OIDC SSO, email change, and setup docs
Phase 18 changes:

OAuth/OIDC SSO (Authorization Code + PKCE):
- alembic/versions/0015_add_oauth_fields.py: add oauth_sub UNIQUE column,
  drop NOT NULL on password_hash
- src/fabledassistant/services/oauth.py: OIDC discovery (cached), build_auth_url,
  exchange_code, get_userinfo, find_or_create_oauth_user (sub→email auto-link→create)
- src/fabledassistant/routes/auth.py: GET /api/auth/oauth/login and
  GET /api/auth/oauth/callback; LOCAL_AUTH_ENABLED guards on login/register;
  /api/auth/status now returns oauth_enabled + local_auth_enabled
- src/fabledassistant/config.py: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,
  OIDC_SCOPES, LOCAL_AUTH_ENABLED, oidc_enabled() classmethod
- src/fabledassistant/models/user.py: password_hash nullable, oauth_sub field,
  has_password bool in to_dict()
- src/fabledassistant/services/auth.py: create_user accepts password=None +
  oauth_sub kwarg; authenticate returns None for OAuth-only users;
  add get_user_by_oauth_sub, link_oauth_sub, update_user_email
- frontend: AuthStatus + User types updated; auth store exposes oauthEnabled +
  localAuthEnabled; LoginView shows SSO button / hides password form accordingly

Email change:
- PUT /api/auth/email: requires password confirmation for local-auth users,
  skips check for OAuth-only users; enforces email uniqueness
- SettingsView.vue: new Email Address section pre-filled with current email,
  updates authStore.user in-place on success

Docs:
- docs/oauth-setup.md: step-by-step Authentik provider setup, example
  docker-compose env vars, account linking explanation, per-provider issuer URL table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 20:12:13 -05:00

125 lines
4.0 KiB
Python

import logging
import urllib.parse
import httpx
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.user import User
from sqlalchemy import select
logger = logging.getLogger(__name__)
_oidc_config: dict | None = None
async def get_oidc_config() -> dict:
global _oidc_config
if _oidc_config is not None:
return _oidc_config
discovery_url = Config.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url, follow_redirects=True)
resp.raise_for_status()
_oidc_config = resp.json()
logger.info("OIDC config loaded from %s", discovery_url)
return _oidc_config
async def build_auth_url(state: str, code_challenge: str) -> str:
oidc = await get_oidc_config()
redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
params = {
"response_type": "code",
"client_id": Config.OIDC_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": Config.OIDC_SCOPES,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
return oidc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
async def exchange_code(code: str, redirect_uri: str, code_verifier: str) -> dict:
oidc = await get_oidc_config()
async with httpx.AsyncClient() as client:
resp = await client.post(
oidc["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": Config.OIDC_CLIENT_ID,
"client_secret": Config.OIDC_CLIENT_SECRET,
"code_verifier": code_verifier,
},
)
resp.raise_for_status()
return resp.json()
async def get_userinfo(access_token: str) -> dict:
oidc = await get_oidc_config()
async with httpx.AsyncClient() as client:
resp = await client.get(
oidc["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
resp.raise_for_status()
return resp.json()
async def find_or_create_oauth_user(
sub: str, email: str, preferred_username: str
) -> User:
async with async_session() as session:
# 1. Look up by oauth_sub
result = await session.execute(select(User).where(User.oauth_sub == sub))
user = result.scalars().first()
if user:
return user
# 2. Look up by email — auto-link
if email:
from sqlalchemy import func
result = await session.execute(
select(User).where(func.lower(User.email) == email.lower())
)
user = result.scalars().first()
if user:
user.oauth_sub = sub
await session.commit()
await session.refresh(user)
logger.info(
"Linked OAuth sub %s to existing user %d (%s) via email",
sub, user.id, user.username,
)
return user
# 3. Create new user — pick a non-colliding username
base_username = preferred_username or (email.split("@")[0] if email else "user")
candidate = base_username
suffix = 2
while True:
result = await session.execute(
select(User).where(User.username == candidate)
)
if not result.scalars().first():
break
candidate = f"{base_username}_{suffix}"
suffix += 1
user = User(
username=candidate,
email=email or None,
password_hash=None,
oauth_sub=sub,
)
session.add(user)
await session.commit()
await session.refresh(user)
logger.info("Auto-provisioned OAuth user %d (%s) for sub %s", user.id, user.username, sub)
return user