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:
+39
-21
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-02-24 — Phase 17: Connection pool hardening (pool_pre_ping, pool_recycle)
|
||||
2026-02-25 — Phase 18: Authentik OAuth/OIDC SSO + email change
|
||||
|
||||
## Project Overview
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -145,11 +145,12 @@ for AI-assisted features.
|
||||
|
||||
### Users
|
||||
- `id` (int PK), `username` (text UNIQUE NOT NULL), `email` (text nullable),
|
||||
`password_hash` (text NOT NULL), `role` (text NOT NULL DEFAULT 'user'),
|
||||
`created_at` (timestamptz)
|
||||
`password_hash` (text **nullable** — NULL for OAuth-only accounts),
|
||||
`oauth_sub` (text UNIQUE nullable — OIDC subject identifier),
|
||||
`role` (text NOT NULL DEFAULT 'user'), `created_at` (timestamptz)
|
||||
- First registered user auto-assigned `role='admin'`; claims orphaned data
|
||||
- Passwords hashed with bcrypt
|
||||
- `to_dict()` excludes `password_hash`
|
||||
- Passwords hashed with bcrypt; OAuth-only users have `password_hash = NULL`
|
||||
- `to_dict()` excludes `password_hash`; includes `has_password: bool`
|
||||
|
||||
### Notes (unified — includes tasks)
|
||||
- `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]),
|
||||
@@ -223,12 +224,14 @@ for AI-assisted features.
|
||||
```
|
||||
fabledassistant/
|
||||
├── summary.md # This file — canonical project context
|
||||
├── pyproject.toml # Python project config (deps include caldav, icalendar)
|
||||
├── pyproject.toml # Python project config (deps include caldav, icalendar, httpx)
|
||||
├── Dockerfile # Multi-stage build (Node → Python)
|
||||
├── .dockerignore # Prevents secrets/node_modules/__pycache__/.env.* leaking into build
|
||||
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama) — app service has healthcheck
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm, secrets, network isolation)
|
||||
├── alembic.ini # Alembic config (prepend_sys_path = src)
|
||||
├── docs/
|
||||
│ └── oauth-setup.md # Step-by-step Authentik OIDC setup guide with example docker-compose config
|
||||
├── alembic/
|
||||
│ ├── env.py # Async migration runner
|
||||
│ └── versions/
|
||||
@@ -244,17 +247,19 @@ fabledassistant/
|
||||
│ ├── 0010_add_app_logs_table.py # App logs table for audit, usage, and error logging
|
||||
│ ├── 0011_add_password_reset_tokens.py # Password reset tokens table
|
||||
│ ├── 0012_add_invitation_tokens.py # Invitation tokens table
|
||||
│ └── 0013_add_tool_calls_to_messages.py # Add tool_calls JSONB column to messages
|
||||
│ ├── 0013_add_tool_calls_to_messages.py # Add tool_calls JSONB column to messages
|
||||
│ ├── 0014_add_note_embeddings.py # note_embeddings table for semantic search (note_id PK, user_id, embedding JSONB, updated_at)
|
||||
│ └── 0015_add_oauth_fields.py # Add oauth_sub UNIQUE column; DROP NOT NULL on password_hash
|
||||
├── src/
|
||||
│ └── fabledassistant/
|
||||
│ ├── __init__.py
|
||||
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging, security headers (after_request)
|
||||
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id — shared _check_auth() helper
|
||||
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES + TRUST_PROXY_HEADERS flags
|
||||
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES + TRUST_PROXY_HEADERS + OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/SCOPES + LOCAL_AUTH_ENABLED + oidc_enabled() classmethod
|
||||
│ ├── rate_limit.py # In-memory sliding-window rate limiter (asyncio.Lock + defaultdict); is_rate_limited(key, max, window)
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py # async_session factory, Base, imports all models
|
||||
│ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
|
||||
│ │ ├── user.py # User model (id, username, email, password_hash nullable, oauth_sub unique nullable, role, created_at); to_dict() includes has_password bool
|
||||
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, user_id, status, priority, due_date, timestamps)
|
||||
│ │ ├── conversation.py # Conversation + Message models with user_id
|
||||
│ │ ├── setting.py # Setting model (composite PK: user_id + key, value TEXT)
|
||||
@@ -263,14 +268,15 @@ fabledassistant/
|
||||
│ ├── routes/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── api.py # /api blueprint with /health endpoint (public)
|
||||
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status, password reset, invitation registration — rate limiting + _client_ip() helper
|
||||
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, password, email change, status, password reset, invitation registration, OAuth login+callback — rate limiting, LOCAL_AUTH_ENABLED guards, _client_ip() helper
|
||||
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only)
|
||||
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
|
||||
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks + tag suggestions (all @login_required)
|
||||
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
|
||||
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
|
||||
│ ├── services/
|
||||
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open, password reset tokens (reset_password_with_token returns int|None), invitation tokens (create, validate, register_with_invitation, list_pending, revoke)
|
||||
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user (password optional, oauth_sub kwarg), authenticate (returns None for OAuth-only users), get_user_by_id/username/email/oauth_sub, update_user_email, link_oauth_sub, is_registration_open, list_users, delete_user, password reset tokens, invitation tokens
|
||||
│ │ ├── oauth.py # OIDC/OAuth2 service: get_oidc_config (discovery, cached), build_auth_url (PKCE), exchange_code, get_userinfo, find_or_create_oauth_user (sub lookup → email auto-link → create)
|
||||
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
|
||||
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
|
||||
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming (stream_chat + stream_chat_with_tools), ChatChunk dataclass, URL fetching
|
||||
@@ -307,14 +313,14 @@ fabledassistant/
|
||||
│ │ ├── useAutocomplete.ts # Legacy textarea autocomplete (replaced by Tiptap suggestion extensions)
|
||||
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, two-step POST+SSE streaming (apiPost → apiSSEStream), accept/reject (accept() resets to idle on doc-change error), proofread (full-document), LCS line diff (DiffLine/computeDiff), isProofreading ref; watches body ref for auto-sync
|
||||
│ ├── stores/
|
||||
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
|
||||
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, oauthEnabled, localAuthEnabled, login/register/logout/checkAuth/checkHasUsers
|
||||
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
|
||||
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
|
||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (memory-leak-safe _pollUntilLoaded), running models, model warming, updateConversationModel (with toast errors)
|
||||
│ │ ├── settings.ts # App settings: assistantName, defaultModel, pullModel, deleteModel (with toast errors)
|
||||
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
|
||||
│ ├── types/
|
||||
│ │ ├── auth.ts # User interface, AuthStatus interface
|
||||
│ │ ├── auth.ts # User interface (incl. has_password bool), AuthStatus interface (incl. oauth_enabled, local_auth_enabled)
|
||||
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
|
||||
│ │ ├── chat.ts # ToolCallRecord, Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, RunningModel, OllamaStatus interfaces
|
||||
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
|
||||
@@ -331,13 +337,13 @@ fabledassistant/
|
||||
│ │ ├── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks
|
||||
│ │ └── sectionParser.ts # parseMarkdownSections() (heading-based) + parseFallbackSections() (paragraph/Q&A-style — single-line ≤120 char paragraphs become pseudo-headings; top-level bullet/numbered list items become individual sections)
|
||||
│ ├── views/
|
||||
│ │ ├── LoginView.vue # Login form with error display, link to register
|
||||
│ │ ├── LoginView.vue # Login form; "Login with Authentik" SSO button when oauth_enabled; hides password form when local_auth_disabled; handles ?error=oauth query param
|
||||
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
|
||||
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
|
||||
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
|
||||
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, persistent context sidebar (right panel, hidden mobile), model selector in header
|
||||
│ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount
|
||||
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
|
||||
│ │ ├── SettingsView.vue # Settings page: assistant name, email change (with password confirmation for local-auth users), change password, notifications, CalDAV, SMTP (admin), base URL (admin), data export/restore (admin)
|
||||
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
||||
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css
|
||||
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
|
||||
@@ -374,12 +380,15 @@ fabledassistant/
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check (public) |
|
||||
| GET | `/api/auth/status` | Check if any users exist + registration open status (public) |
|
||||
| POST | `/api/auth/register` | Register new user (first user becomes admin; returns 403 if registration closed) |
|
||||
| POST | `/api/auth/login` | Login with username/password |
|
||||
| GET | `/api/auth/status` | Returns `{has_users, registration_open, oauth_enabled, local_auth_enabled}` (public) |
|
||||
| POST | `/api/auth/register` | Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
|
||||
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled; returns None for OAuth-only users) |
|
||||
| POST | `/api/auth/logout` | Logout (clear session) |
|
||||
| GET | `/api/auth/me` | Get current user info |
|
||||
| GET | `/api/auth/me` | Get current user info (includes `has_password` bool) |
|
||||
| PUT | `/api/auth/password` | Change password (body: `{current_password, new_password}`) |
|
||||
| PUT | `/api/auth/email` | Change email (body: `{email, password?}`; password required only if user has local password) |
|
||||
| GET | `/api/auth/oauth/login` | Initiate OIDC flow — generates PKCE verifier + state, stores in session, redirects to provider |
|
||||
| GET | `/api/auth/oauth/callback` | OIDC callback — exchanges code, fetches userinfo, finds/creates user, sets session, redirects to `/` |
|
||||
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data, full requires admin) |
|
||||
| POST | `/api/admin/restore` | Restore from JSON backup (admin only) |
|
||||
| GET | `/api/admin/users` | List all users (admin only) |
|
||||
@@ -447,7 +456,7 @@ container startup.
|
||||
|
||||
### Migration Chain
|
||||
```
|
||||
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py → 0008_add_users_and_user_id.py → 0009_add_message_status.py → 0010_add_app_logs_table.py → 0011_add_password_reset_tokens.py → 0012_add_invitation_tokens.py → 0013_add_tool_calls_to_messages.py
|
||||
0001 → 0002 → 0003 → 0004 → 0005 → 0006 → 0007 → 0008 → 0009 → 0010 → 0011 → 0012 → 0013 → 0014 → 0015
|
||||
```
|
||||
|
||||
### How Migrations Run
|
||||
@@ -644,6 +653,15 @@ When adding a new migration, follow these conventions:
|
||||
- Password reset: email-based, SHA256-hashed tokens, 1-hour expiry
|
||||
- Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag
|
||||
- Admin user management: list, delete, invite, revoke
|
||||
- **OAuth/OIDC SSO (Phase 18):** Authorization Code + PKCE flow via any OIDC provider (Authentik, Keycloak, etc.)
|
||||
- `GET /api/auth/oauth/login` → generates state + PKCE verifier, stores in session, redirects to provider
|
||||
- `GET /api/auth/oauth/callback` → exchanges code, fetches userinfo, sets session, redirects to `/`
|
||||
- Account linking: sub lookup → email auto-link → auto-provision with collision-safe username
|
||||
- `LOCAL_AUTH_ENABLED=false` hides password form and blocks `POST /api/auth/login|register`
|
||||
- OAuth-only users have `password_hash = NULL`; `authenticate()` returns None for them
|
||||
- OIDC discovery response cached in-process after first fetch; no new Python dependencies (`httpx` already present)
|
||||
- Config: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (or `_FILE`), `OIDC_SCOPES`
|
||||
- **Email change:** `PUT /api/auth/email` — requires current password for local-auth users; OAuth-only users can change freely; checks email uniqueness; updates `authStore.user` in-place
|
||||
|
||||
### Notifications & Email
|
||||
- SMTP email service (aiosmtplib): STARTTLS (587) and implicit TLS (465)
|
||||
@@ -681,7 +699,7 @@ When adding a new migration, follow these conventions:
|
||||
- Security headers applied in `after_request`: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Content-Security-Policy`
|
||||
- In-memory sliding-window rate limiter on all auth endpoints (login, register, forgot/reset password); proxy-aware client IP with `TRUST_PROXY_HEADERS`
|
||||
- SQLAlchemy async engine configured with `pool_pre_ping=True` (tests connections before use, discards stale ones after Postgres restart) and `pool_recycle=1800` (recycles idle connections every 30 min to prevent TCP/firewall staleness)
|
||||
- Alembic migrations: 13 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
|
||||
- Alembic migrations: 15 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
|
||||
- Config from env vars + Docker secrets file support (`_read_secret`)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Reference in New Issue
Block a user