From b37e15d59a23d3d64766701cabe78f75ee806675 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Feb 2026 20:12:13 -0500 Subject: [PATCH] Add Authentik OAuth/OIDC SSO, email change, and setup docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- alembic/versions/0015_add_oauth_fields.py | 20 ++++ docs/oauth-setup.md | 109 +++++++++++++++++++ frontend/src/stores/auth.ts | 8 ++ frontend/src/types/auth.ts | 3 + frontend/src/views/LoginView.vue | 63 ++++++++++- frontend/src/views/SettingsView.vue | 62 +++++++++++ src/fabledassistant/config.py | 11 ++ src/fabledassistant/models/user.py | 4 +- src/fabledassistant/routes/auth.py | 106 +++++++++++++++++- src/fabledassistant/services/auth.py | 39 ++++++- src/fabledassistant/services/oauth.py | 124 ++++++++++++++++++++++ summary.md | 60 +++++++---- 12 files changed, 578 insertions(+), 31 deletions(-) create mode 100644 alembic/versions/0015_add_oauth_fields.py create mode 100644 docs/oauth-setup.md create mode 100644 src/fabledassistant/services/oauth.py diff --git a/alembic/versions/0015_add_oauth_fields.py b/alembic/versions/0015_add_oauth_fields.py new file mode 100644 index 0000000..8311355 --- /dev/null +++ b/alembic/versions/0015_add_oauth_fields.py @@ -0,0 +1,20 @@ +"""Add OAuth/OIDC fields to users table.""" + +from alembic import op + +revision = "0015" +down_revision = "0014" + + +def upgrade() -> None: + op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS oauth_sub TEXT") + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oauth_sub ON users(oauth_sub)" + ) + op.execute("ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL") + + +def downgrade() -> None: + op.execute("ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL") + op.execute("DROP INDEX IF EXISTS ix_users_oauth_sub") + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS oauth_sub") diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md new file mode 100644 index 0000000..f1e1d48 --- /dev/null +++ b/docs/oauth-setup.md @@ -0,0 +1,109 @@ +# OAuth / OIDC SSO Setup (Authentik) + +Fabled Assistant supports single sign-on via any OpenID Connect provider. +This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc. + +--- + +## 1. Create the provider in Authentik + +1. Log in to the Authentik admin UI. +2. Go to **Applications → Providers → Create → OAuth2/OpenID Provider**. +3. Fill in: + - **Name:** `Fabled Assistant` (or whatever you like) + - **Authorization flow:** your default authorization flow + - **Client type:** `Confidential` + - **Redirect URIs:** `https://your-fabled-domain/api/auth/oauth/callback` + *(must match `BASE_URL` exactly, including scheme and any path prefix)* +4. Note the generated **Client ID** and **Client Secret**. +5. Go to **Applications → Create**, give it a name, and bind it to the provider you just created. +6. Note the **Issuer URL** from the provider detail page — it looks like: + ``` + https://auth.example.com/application/o/fabled-assistant/ + ``` + +--- + +## 2. Configure Fabled Assistant + +Add the following environment variables to the `app` service in `docker-compose.yml`: + +```yaml +services: + app: + environment: + # --- Required --- + OIDC_ISSUER: "https://auth.example.com/application/o/fabled-assistant/" + OIDC_CLIENT_ID: "abc123xyz" + OIDC_CLIENT_SECRET: "supersecret" + + # --- Optional --- + # Scopes to request (default: "openid profile email") + # OIDC_SCOPES: "openid profile email" + + # Disable local username/password login once SSO is working + # LOCAL_AUTH_ENABLED: "false" + + # Make sure BASE_URL matches the redirect URI you registered in Authentik + BASE_URL: "https://your-fabled-domain" +``` + +> **Docker Secrets alternative:** Instead of `OIDC_CLIENT_SECRET`, you can use +> `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file. + +Rebuild and restart: + +```bash +docker compose up --build -d +``` + +--- + +## 3. Verify + +1. Open `/api/auth/status` — it should return: + ```json + { "oauth_enabled": true, "local_auth_enabled": true, ... } + ``` +2. Go to the login page — you should see a **"Login with Authentik"** button. +3. Click it → you are redirected to Authentik → authenticate → redirected back to Fabled → logged in. +4. Check `/api/auth/me` to confirm your user record. + +--- + +## 4. Account linking + +When a user logs in via OAuth for the first time, Fabled checks in this order: + +1. **Existing OAuth sub** — returns that user immediately. +2. **Matching email** — if a local account already exists with the same email address, the OAuth identity is linked to it automatically. The user retains all their notes and tasks. +3. **New user** — a fresh account is created. The username defaults to the `preferred_username` claim from the provider; if taken, `_2`, `_3`, etc. is appended. + +--- + +## 5. Disable local login (optional) + +Once everyone is using SSO you can hide the username/password form: + +```yaml +LOCAL_AUTH_ENABLED: "false" +``` + +The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a `403`. The login page will only show the SSO button. + +> **Warning:** Make sure at least one account has been linked via OAuth before disabling local login, or you will be locked out. + +--- + +## 6. Other providers + +| Provider | Issuer URL format | +|------------|----------------------------------------------------------| +| Authentik | `https://auth.example.com/application/o//` | +| Keycloak | `https://keycloak.example.com/realms/` | +| Authelia | `https://auth.example.com` | +| Zitadel | `https://your-instance.zitadel.cloud` | +| Google | `https://accounts.google.com` | + +The OIDC discovery endpoint (`/.well-known/openid-configuration`) must be +publicly reachable from the Fabled container (server-to-server call). diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index aa31bf4..f18d15d 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -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("/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, diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 9488300..18f7446 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -4,9 +4,12 @@ export interface User { email: string | null; role: string; created_at: string; + has_password: boolean; } export interface AuthStatus { has_users: boolean; registration_open: boolean; + oauth_enabled: boolean; + local_auth_enabled: boolean; } diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 620b96e..0b06bba 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,5 +1,5 @@