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:
@@ -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")
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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