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>
This commit is contained in:
@@ -31,7 +31,10 @@ async def get_user_count() -> int:
|
||||
|
||||
|
||||
async def create_user(
|
||||
username: str, password: str, email: str | None = None
|
||||
username: str,
|
||||
password: str | None,
|
||||
email: str | None = None,
|
||||
oauth_sub: str | None = None,
|
||||
) -> User:
|
||||
user_count = await get_user_count()
|
||||
role = "admin" if user_count == 0 else "user"
|
||||
@@ -40,7 +43,8 @@ async def create_user(
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
password_hash=hash_password(password) if password is not None else None,
|
||||
oauth_sub=oauth_sub,
|
||||
role=role,
|
||||
)
|
||||
session.add(user)
|
||||
@@ -82,7 +86,12 @@ async def authenticate(username: str, password: str) -> User | None:
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if user and verify_password(password, user.password_hash):
|
||||
if user is None:
|
||||
return None
|
||||
if user.password_hash is None:
|
||||
# OAuth-only user — cannot use password login
|
||||
return None
|
||||
if verify_password(password, user.password_hash):
|
||||
return user
|
||||
return None
|
||||
|
||||
@@ -157,6 +166,30 @@ async def set_registration_open(admin_user_id: int, open: bool) -> None:
|
||||
await set_setting(admin_user_id, "registration_open", "true" if open else "false")
|
||||
|
||||
|
||||
async def update_user_email(user_id: int, email: str | None) -> None:
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user:
|
||||
user.email = email
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_user_by_oauth_sub(sub: str) -> User | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.oauth_sub == sub)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def link_oauth_sub(user_id: int, sub: str) -> None:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
update(User).where(User.id == user_id).values(oauth_sub=sub)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_user_by_email(email: str) -> User | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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
|
||||
Reference in New Issue
Block a user