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
+3 -1
View File
@@ -12,7 +12,8 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
email: Mapped[str | None] = mapped_column(Text, nullable=True)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
@@ -29,4 +30,5 @@ class User(Base):
"email": self.email,
"role": self.role,
"created_at": self.created_at.isoformat(),
"has_password": self.password_hash is not None,
}