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
+8
View File
@@ -8,6 +8,8 @@ export const useAuthStore = defineStore("auth", () => {
const loading = ref(true);
const hasUsers = ref(true);
const registrationOpen = ref(false);
const oauthEnabled = ref(false);
const localAuthEnabled = ref(true);
const isAuthenticated = computed(() => user.value !== null);
const isAdmin = computed(() => user.value?.role === "admin");
@@ -28,9 +30,13 @@ export const useAuthStore = defineStore("auth", () => {
const data = await apiGet<AuthStatus>("/api/auth/status");
hasUsers.value = data.has_users;
registrationOpen.value = data.registration_open;
oauthEnabled.value = data.oauth_enabled ?? false;
localAuthEnabled.value = data.local_auth_enabled ?? true;
} catch {
hasUsers.value = true;
registrationOpen.value = false;
oauthEnabled.value = false;
localAuthEnabled.value = true;
}
}
@@ -56,6 +62,8 @@ export const useAuthStore = defineStore("auth", () => {
loading,
hasUsers,
registrationOpen,
oauthEnabled,
localAuthEnabled,
isAuthenticated,
isAdmin,
checkAuth,