Change Password
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py
index 0c3f72f..f4ae9b4 100644
--- a/src/fabledassistant/config.py
+++ b/src/fabledassistant/config.py
@@ -45,3 +45,14 @@ class Config:
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
+
+ # OIDC / OAuth2 SSO (e.g. Authentik)
+ OIDC_ISSUER: str = os.environ.get("OIDC_ISSUER", "")
+ OIDC_CLIENT_ID: str = os.environ.get("OIDC_CLIENT_ID", "")
+ OIDC_CLIENT_SECRET: str = _read_secret("OIDC_CLIENT_SECRET", "OIDC_CLIENT_SECRET_FILE", "")
+ OIDC_SCOPES: str = os.environ.get("OIDC_SCOPES", "openid profile email")
+ LOCAL_AUTH_ENABLED: bool = os.environ.get("LOCAL_AUTH_ENABLED", "true").lower() not in ("0", "false", "no")
+
+ @classmethod
+ def oidc_enabled(cls) -> bool:
+ return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
diff --git a/src/fabledassistant/models/user.py b/src/fabledassistant/models/user.py
index 3a24570..703a52e 100644
--- a/src/fabledassistant/models/user.py
+++ b/src/fabledassistant/models/user.py
@@ -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,
}
diff --git a/src/fabledassistant/routes/auth.py b/src/fabledassistant/routes/auth.py
index ec0374f..7b4ccaf 100644
--- a/src/fabledassistant/routes/auth.py
+++ b/src/fabledassistant/routes/auth.py
@@ -1,6 +1,10 @@
import asyncio
+import base64
+import hashlib
+import os
+import secrets
-from quart import Blueprint, g, jsonify, request, session
+from quart import Blueprint, g, jsonify, redirect, request, session
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
@@ -17,7 +21,9 @@ from fabledassistant.services.auth import (
is_registration_open,
register_with_invitation,
reset_password_with_token,
+ update_user_email,
validate_invitation_token,
+ verify_password,
)
from fabledassistant.services.logging import log_audit
from fabledassistant.services.notifications import (
@@ -26,6 +32,12 @@ from fabledassistant.services.notifications import (
send_password_reset_success_email,
)
from fabledassistant.services.email import get_base_url, is_smtp_configured
+from fabledassistant.services.oauth import (
+ build_auth_url,
+ exchange_code,
+ get_userinfo,
+ find_or_create_oauth_user,
+)
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@@ -41,6 +53,8 @@ def _client_ip() -> str:
@auth_bp.route("/register", methods=["POST"])
async def register():
+ if not Config.LOCAL_AUTH_ENABLED:
+ return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
if await is_rate_limited(f"register:{_client_ip()}", max_requests=5, window_seconds=300):
return jsonify({"error": "Too many registration attempts. Try again later."}), 429
if not await is_registration_open():
@@ -68,6 +82,8 @@ async def register():
@auth_bp.route("/login", methods=["POST"])
async def login():
+ if not Config.LOCAL_AUTH_ENABLED:
+ return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
if await is_rate_limited(f"login:{_client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"error": "Too many login attempts. Try again later."}), 429
data = await request.get_json()
@@ -146,6 +162,37 @@ async def update_password():
return jsonify({"status": "ok"})
+@auth_bp.route("/email", methods=["PUT"])
+@login_required
+async def update_email():
+ data = await request.get_json()
+ new_email = (data.get("email") or "").strip().lower() or None
+ password = data.get("password") or ""
+
+ uid = get_current_user_id()
+ user = await get_user_by_id(uid)
+ if not user:
+ return jsonify({"error": "User not found"}), 404
+
+ # Require password confirmation only for local-auth users
+ if user.password_hash is not None:
+ if not password:
+ return jsonify({"error": "Password is required to change your email"}), 400
+ if not verify_password(password, user.password_hash):
+ return jsonify({"error": "Incorrect password"}), 403
+
+ # Check the new email isn't taken by a different account
+ if new_email:
+ existing = await get_user_by_email(new_email)
+ if existing and existing.id != uid:
+ return jsonify({"error": "Email already in use by another account"}), 409
+
+ await update_user_email(uid, new_email)
+ await log_audit("email_change", user_id=uid, username=user.username, ip_address=_client_ip())
+ updated = await get_user_by_id(uid)
+ return jsonify(updated.to_dict())
+
+
@auth_bp.route("/forgot-password", methods=["POST"])
async def forgot_password():
if await is_rate_limited(f"forgot:{_client_ip()}", max_requests=5, window_seconds=300):
@@ -245,4 +292,59 @@ async def register_with_invite():
async def status():
count = await get_user_count()
reg_open = await is_registration_open()
- return jsonify({"has_users": count > 0, "registration_open": reg_open})
+ return jsonify({
+ "has_users": count > 0,
+ "registration_open": reg_open,
+ "oauth_enabled": Config.oidc_enabled(),
+ "local_auth_enabled": Config.LOCAL_AUTH_ENABLED,
+ })
+
+
+@auth_bp.route("/oauth/login", methods=["GET"])
+async def oauth_login():
+ if not Config.oidc_enabled():
+ return jsonify({"error": "SSO is not configured"}), 404
+
+ state = secrets.token_hex(32)
+ code_verifier = secrets.token_urlsafe(64)
+
+ digest = hashlib.sha256(code_verifier.encode()).digest()
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
+
+ session["oauth_state"] = state
+ session["oauth_code_verifier"] = code_verifier
+
+ url = await build_auth_url(state, code_challenge)
+ return redirect(url)
+
+
+@auth_bp.route("/oauth/callback", methods=["GET"])
+async def oauth_callback():
+ error = request.args.get("error")
+ code = request.args.get("code")
+ state = request.args.get("state")
+
+ stored_state = session.pop("oauth_state", None)
+ code_verifier = session.pop("oauth_code_verifier", None)
+
+ if error or not code or state != stored_state:
+ return redirect("/login?error=oauth")
+
+ redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
+ try:
+ token_response = await exchange_code(code, redirect_uri, code_verifier)
+ claims = await get_userinfo(token_response["access_token"])
+ except Exception:
+ return redirect("/login?error=oauth")
+
+ sub = claims.get("sub", "")
+ email = claims.get("email", "")
+ preferred_username = claims.get("preferred_username", "")
+
+ if not sub:
+ return redirect("/login?error=oauth")
+
+ user = await find_or_create_oauth_user(sub, email, preferred_username)
+ session["user_id"] = user.id
+ await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip())
+ return redirect("/")
diff --git a/src/fabledassistant/services/auth.py b/src/fabledassistant/services/auth.py
index b4f74a9..e290381 100644
--- a/src/fabledassistant/services/auth.py
+++ b/src/fabledassistant/services/auth.py
@@ -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(
diff --git a/src/fabledassistant/services/oauth.py b/src/fabledassistant/services/oauth.py
new file mode 100644
index 0000000..81fd8fe
--- /dev/null
+++ b/src/fabledassistant/services/oauth.py
@@ -0,0 +1,124 @@
+import logging
+import urllib.parse
+
+import httpx
+
+from fabledassistant.config import Config
+from fabledassistant.models import async_session
+from fabledassistant.models.user import User
+from sqlalchemy import select
+
+logger = logging.getLogger(__name__)
+
+_oidc_config: dict | None = None
+
+
+async def get_oidc_config() -> dict:
+ global _oidc_config
+ if _oidc_config is not None:
+ return _oidc_config
+
+ discovery_url = Config.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(discovery_url, follow_redirects=True)
+ resp.raise_for_status()
+ _oidc_config = resp.json()
+ logger.info("OIDC config loaded from %s", discovery_url)
+ return _oidc_config
+
+
+async def build_auth_url(state: str, code_challenge: str) -> str:
+ oidc = await get_oidc_config()
+ redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
+ params = {
+ "response_type": "code",
+ "client_id": Config.OIDC_CLIENT_ID,
+ "redirect_uri": redirect_uri,
+ "scope": Config.OIDC_SCOPES,
+ "state": state,
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ }
+ return oidc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
+
+
+async def exchange_code(code: str, redirect_uri: str, code_verifier: str) -> dict:
+ oidc = await get_oidc_config()
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ oidc["token_endpoint"],
+ data={
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirect_uri,
+ "client_id": Config.OIDC_CLIENT_ID,
+ "client_secret": Config.OIDC_CLIENT_SECRET,
+ "code_verifier": code_verifier,
+ },
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def get_userinfo(access_token: str) -> dict:
+ oidc = await get_oidc_config()
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ oidc["userinfo_endpoint"],
+ headers={"Authorization": f"Bearer {access_token}"},
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def find_or_create_oauth_user(
+ sub: str, email: str, preferred_username: str
+) -> User:
+ async with async_session() as session:
+ # 1. Look up by oauth_sub
+ result = await session.execute(select(User).where(User.oauth_sub == sub))
+ user = result.scalars().first()
+ if user:
+ return user
+
+ # 2. Look up by email — auto-link
+ if email:
+ from sqlalchemy import func
+ result = await session.execute(
+ select(User).where(func.lower(User.email) == email.lower())
+ )
+ user = result.scalars().first()
+ if user:
+ user.oauth_sub = sub
+ await session.commit()
+ await session.refresh(user)
+ logger.info(
+ "Linked OAuth sub %s to existing user %d (%s) via email",
+ sub, user.id, user.username,
+ )
+ return user
+
+ # 3. Create new user — pick a non-colliding username
+ base_username = preferred_username or (email.split("@")[0] if email else "user")
+ candidate = base_username
+ suffix = 2
+ while True:
+ result = await session.execute(
+ select(User).where(User.username == candidate)
+ )
+ if not result.scalars().first():
+ break
+ candidate = f"{base_username}_{suffix}"
+ suffix += 1
+
+ user = User(
+ username=candidate,
+ email=email or None,
+ password_hash=None,
+ oauth_sub=sub,
+ )
+ session.add(user)
+ await session.commit()
+ await session.refresh(user)
+ logger.info("Auto-provisioned OAuth user %d (%s) for sub %s", user.id, user.username, sub)
+ return user
diff --git a/summary.md b/summary.md
index 51fc03e..8680f54 100644
--- a/summary.md
+++ b/summary.md
@@ -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