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
+20
View File
@@ -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")
+109
View File
@@ -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/<app-slug>/` |
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
| Authelia | `https://auth.example.com` |
| Zitadel | `https://your-instance.zitadel.cloud` |
| Google | `https://accounts.google.com` |
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be
publicly reachable from the Fabled container (server-to-server call).
+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,
+3
View File
@@ -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;
}
+59 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, computed, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
@@ -13,8 +13,13 @@ const password = ref("");
const error = ref("");
const submitting = ref(false);
const oauthError = computed(() => route.query.error === "oauth");
onMounted(async () => {
await authStore.checkHasUsers();
if (oauthError.value) {
error.value = "SSO login failed. Please try again.";
}
});
async function handleSubmit() {
@@ -35,6 +40,10 @@ async function handleSubmit() {
submitting.value = false;
}
}
function loginWithOAuth() {
window.location.href = "/api/auth/oauth/login";
}
</script>
<template>
@@ -46,7 +55,10 @@ async function handleSubmit() {
<router-link to="/register">Create the first account</router-link>
to get started.
</p>
<form @submit.prevent="handleSubmit">
<p v-if="error" class="error-msg">{{ error }}</p>
<form v-if="authStore.localAuthEnabled" @submit.prevent="handleSubmit">
<div class="field">
<label for="username">Username</label>
<input
@@ -72,12 +84,27 @@ async function handleSubmit() {
<p class="forgot-link">
<router-link to="/forgot-password">Forgot your password?</router-link>
</p>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? "Signing in..." : "Sign In" }}
</button>
</form>
<p class="auth-footer">
<div
v-if="authStore.localAuthEnabled && authStore.oauthEnabled"
class="divider"
>
<span>or</span>
</div>
<button
v-if="authStore.oauthEnabled"
class="btn-oauth"
@click="loginWithOAuth"
>
Login with Authentik
</button>
<p v-if="authStore.localAuthEnabled" class="auth-footer">
Don't have an account?
<router-link to="/register">Register</router-link>
</p>
@@ -167,6 +194,34 @@ async function handleSubmit() {
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
border-top: 1px solid var(--color-border);
}
.btn-oauth {
width: 100%;
padding: 0.6rem;
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-oauth:hover {
background: var(--color-bg-hover, var(--color-border));
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
+62
View File
@@ -10,6 +10,9 @@ const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
const intentModel = ref("");
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
@@ -61,6 +64,7 @@ const baseUrlSaved = ref(false);
onMounted(async () => {
await store.fetchSettings();
assistantName.value = store.assistantName;
newEmail.value = authStore.user?.email ?? "";
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
@@ -97,6 +101,29 @@ onMounted(async () => {
}
});
async function changeEmail() {
changingEmail.value = true;
try {
const body: Record<string, string> = { email: newEmail.value.trim() };
if (authStore.user?.has_password) {
body.password = emailPassword.value;
}
const updated = await apiPut<import("@/types/auth").User>("/api/auth/email", body);
authStore.user = updated;
emailPassword.value = "";
toastStore.show("Email updated successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const b = (e as { body?: { error?: string } }).body;
toastStore.show(b?.error || "Failed to update email", "error");
} else {
toastStore.show("Failed to update email", "error");
}
} finally {
changingEmail.value = false;
}
}
async function changePassword() {
if (newPassword.value !== confirmNewPassword.value) {
toastStore.show("New passwords do not match", "error");
@@ -339,6 +366,41 @@ async function handleRestoreFile(event: Event) {
</div>
</section>
<section class="settings-section">
<h2>Email Address</h2>
<p class="section-desc">Used for password resets and notifications.</p>
<div class="field">
<label for="new-email">Email</label>
<input
id="new-email"
v-model="newEmail"
type="email"
placeholder="you@example.com"
class="input"
/>
</div>
<div v-if="authStore.user?.has_password" class="field">
<label for="email-password">Current Password</label>
<input
id="email-password"
v-model="emailPassword"
type="password"
autocomplete="current-password"
class="input"
/>
<p class="field-hint">Required to confirm the change.</p>
</div>
<div class="actions">
<button
class="btn-save"
@click="changeEmail"
:disabled="changingEmail || (authStore.user?.has_password && !emailPassword)"
>
{{ changingEmail ? "Saving..." : "Save Email" }}
</button>
</div>
</section>
<section class="settings-section">
<h2>Change Password</h2>
<div class="field">
+11
View File
@@ -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)
+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,
}
+104 -2
View File
@@ -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("/")
+36 -3
View File
@@ -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(
+124
View File
@@ -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
+39 -21
View File
@@ -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