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:
2026-02-25 20:12:13 -05:00
parent c3b05046b7
commit b37e15d59a
12 changed files with 578 additions and 31 deletions
+36 -3
View File
@@ -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(