Accept access tokens on protected routes; persist OIDC state

- Add transitional @jwt_access_required (accepts access tokens, and for now
  also refresh tokens) and use it on every protected route and inside
  admin_required. /api/user/refresh stays refresh-only.
- Move pending OIDC state/PKCE verifiers from an in-process dict to an
  oidc_states table (10 min TTL, single-use) so logins survive a blue/green
  flip. Drop the unused redirect_after_login.
- Unit tests for the decorator and the state store.

Deploy this commit on its own, before the access-token frontend: the old
backend answers access tokens with 422, so the new frontend only works once
this backend is live on both colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:40:47 -04:00
parent fde8208afe
commit bc3e3d2797
11 changed files with 400 additions and 42 deletions
+3 -2
View File
@@ -13,7 +13,7 @@ from quart import (
request,
send_from_directory,
)
from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required
from quart_jwt_extended import JWTManager, get_jwt_identity
from tortoise import Tortoise
import blueprints.conversation
@@ -27,6 +27,7 @@ import blueprints.scheduled_messages
import blueprints.cron
import blueprints.beli
import blueprints.users.models
from blueprints.users.decorators import jwt_access_required
from config.db import TORTOISE_CONFIG
from metrics import render_metrics
from blueprints.conversation.agents import build_agent
@@ -205,7 +206,7 @@ async def health():
@app.route("/api/messages", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def get_messages():
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
+9 -7
View File
@@ -3,7 +3,9 @@ import logging
import os
from quart import Blueprint, jsonify, request
from quart_jwt_extended import get_jwt_identity, jwt_refresh_token_required
from quart_jwt_extended import get_jwt_identity
from blueprints.users.decorators import jwt_access_required
from .logic import (
MAX_PROFILES_PER_USER,
@@ -60,7 +62,7 @@ def _serialize_restaurant(r: BeliRestaurant) -> dict:
@beli_blueprint.route("/profiles", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def list_profiles():
user_id = get_jwt_identity()
profiles = await BeliProfile.filter(user_id=user_id).order_by("created_at").all()
@@ -68,7 +70,7 @@ async def list_profiles():
@beli_blueprint.route("/profiles", methods=["POST"])
@jwt_refresh_token_required
@jwt_access_required
async def create_profile():
user_id = get_jwt_identity()
@@ -112,7 +114,7 @@ async def create_profile():
@beli_blueprint.route("/profiles/<profile_id>", methods=["DELETE"])
@jwt_refresh_token_required
@jwt_access_required
async def delete_profile(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
@@ -125,7 +127,7 @@ async def delete_profile(profile_id: str):
@beli_blueprint.route("/profiles/<profile_id>/refresh", methods=["POST"])
@jwt_refresh_token_required
@jwt_access_required
async def refresh_profile_endpoint(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
@@ -137,7 +139,7 @@ async def refresh_profile_endpoint(profile_id: str):
@beli_blueprint.route("/restaurants", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def list_restaurants():
user_id = get_jwt_identity()
profile_ids = [
@@ -161,7 +163,7 @@ async def list_restaurants():
@beli_blueprint.route("/profiles/<profile_id>/refresh-friends", methods=["POST"])
@jwt_refresh_token_required
@jwt_access_required
async def refresh_friends_endpoint(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
+11 -13
View File
@@ -7,12 +7,10 @@ import time
import uuid
from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import (
get_jwt_identity,
jwt_refresh_token_required,
)
from quart_jwt_extended import get_jwt_identity
import blueprints.users.models
from blueprints.users.decorators import jwt_access_required
from metrics import count_result, observe_response, time_response
from utils.image_process import image_content_block
from utils.strip_markdown import extract_agent_text
@@ -126,7 +124,7 @@ def _build_user_message(
@conversation_blueprint.post("/query")
@jwt_refresh_token_required
@jwt_access_required
async def query():
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
@@ -170,7 +168,7 @@ async def query():
@conversation_blueprint.post("/upload-image")
@jwt_refresh_token_required
@jwt_access_required
async def upload_image():
current_user_uuid = get_jwt_identity()
await blueprints.users.models.User.get(id=current_user_uuid)
@@ -202,14 +200,14 @@ async def upload_image():
@conversation_blueprint.get("/image/<path:image_key>")
@jwt_refresh_token_required
@jwt_access_required
async def serve_image(image_key: str):
url = await s3_presigned_url(image_key)
return jsonify({"url": url})
@conversation_blueprint.post("/stream-query")
@jwt_refresh_token_required
@jwt_access_required
async def stream_query():
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
@@ -448,7 +446,7 @@ async def stream_query():
@conversation_blueprint.route("/<conversation_id>")
@jwt_refresh_token_required
@jwt_access_required
async def get_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid)
@@ -483,7 +481,7 @@ async def get_conversation(conversation_id: str):
@conversation_blueprint.post("/")
@jwt_refresh_token_required
@jwt_access_required
async def create_conversation():
user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid)
@@ -497,7 +495,7 @@ async def create_conversation():
@conversation_blueprint.get("/")
@jwt_refresh_token_required
@jwt_access_required
async def get_all_conversations():
user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid)
@@ -523,7 +521,7 @@ async def get_all_conversations():
@conversation_blueprint.patch("/<conversation_id>")
@jwt_refresh_token_required
@jwt_access_required
async def rename_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
body = await request.get_json()
@@ -543,7 +541,7 @@ async def rename_conversation(conversation_id: str):
@conversation_blueprint.delete("/<conversation_id>")
@jwt_refresh_token_required
@jwt_access_required
async def delete_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid)
+2 -3
View File
@@ -1,5 +1,4 @@
from quart import Blueprint, jsonify
from quart_jwt_extended import jwt_refresh_token_required
from .logic import (
delete_all_documents,
@@ -7,13 +6,13 @@ from .logic import (
index_documents,
sync_obsidian_documents,
)
from blueprints.users.decorators import admin_required
from blueprints.users.decorators import admin_required, jwt_access_required
rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag")
@rag_blueprint.get("/stats")
@jwt_refresh_token_required
@jwt_access_required
async def get_stats():
"""Get vector store statistics."""
stats = get_vector_store_stats()
+7 -15
View File
@@ -7,7 +7,8 @@ from quart_jwt_extended import (
)
from .models import User
from .oidc_service import OIDCUserService
from .decorators import admin_required
from .oidc_state import consume_oidc_state, save_oidc_state
from .decorators import admin_required, jwt_access_required
from config.oidc_config import oidc_config
import os
import secrets
@@ -19,10 +20,6 @@ import base64
user_blueprint = Blueprint("user_api", __name__, url_prefix="/api/user")
# In-memory storage for OIDC state/PKCE (production: use Redis or database)
# Format: {state: {"pkce_verifier": str, "redirect_after_login": str}}
_oidc_sessions = {}
@user_blueprint.route("/oidc/login", methods=["GET"])
async def oidc_login():
@@ -47,11 +44,8 @@ async def oidc_login():
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Store PKCE verifier and state for callback validation
_oidc_sessions[state] = {
"pkce_verifier": code_verifier,
"redirect_after_login": request.args.get("redirect", "/"),
}
# Persist PKCE verifier keyed by state for callback validation
await save_oidc_state(state, code_verifier)
# Get authorization endpoint from discovery
discovery = await oidc_config.get_discovery_document()
@@ -93,12 +87,10 @@ async def oidc_callback():
return jsonify({"error": "Missing code or state"}), 400
# Validate state and retrieve PKCE verifier
session = _oidc_sessions.pop(state, None)
if not session:
pkce_verifier = await consume_oidc_state(state)
if not pkce_verifier:
return jsonify({"error": "Invalid or expired state"}), 400
pkce_verifier = session["pkce_verifier"]
# Exchange authorization code for tokens
discovery = await oidc_config.get_discovery_document()
token_endpoint = discovery.get("token_endpoint")
@@ -206,7 +198,7 @@ async def login():
@user_blueprint.route("/me", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def me():
user_id = get_jwt_identity()
user = await User.get_or_none(id=user_id)
+39 -2
View File
@@ -4,10 +4,47 @@ Authentication decorators for role-based access control.
from functools import wraps
from quart import jsonify
from quart_jwt_extended import jwt_refresh_token_required, get_jwt_identity
from quart_jwt_extended import get_jwt_identity
from quart_jwt_extended.exceptions import WrongTokenError
from quart_jwt_extended.view_decorators import (
verify_jwt_in_request,
verify_jwt_refresh_token_in_request,
)
from .models import User
def jwt_access_required(fn):
"""
Protect an endpoint with the (1h) access token.
TRANSITIONAL: this also accepts the refresh token, so an old frontend
(which sends the refresh token on every call) keeps working against this
backend, including tabs that stay open across deploys.
It does NOT make a new frontend work against an older backend: routes
still on `@jwt_refresh_token_required` answer an access token with 422,
which the frontend does not retry. So this backend must be live on both
colors before the access-token frontend ships (deploy in two releases).
TODO: once the access-token frontend has been live for a while, replace
the body with `await verify_jwt_in_request()` (i.e. plain `@jwt_required`)
and map WrongTokenError to 401, so a stale tab still sending the refresh
token refreshes and retries instead of failing with 422.
"""
@wraps(fn)
async def wrapper(*args, **kwargs):
try:
await verify_jwt_in_request()
except WrongTokenError:
# Legacy client sent the refresh token. Expired/invalid tokens
# still raise from verify_jwt_in_request and are not retried here.
await verify_jwt_refresh_token_in_request()
return await fn(*args, **kwargs)
return wrapper
def admin_required(fn):
"""
Decorator that requires the user to be an admin (member of lldap_admin group).
@@ -15,7 +52,7 @@ def admin_required(fn):
"""
@wraps(fn)
@jwt_refresh_token_required
@jwt_access_required
async def wrapper(*args, **kwargs):
user_id = get_jwt_identity()
user = await User.get_or_none(id=user_id)
+16
View File
@@ -56,3 +56,19 @@ class User(Model):
if not self.password:
return False
return bcrypt.checkpw(plain_password.encode("utf-8"), self.password)
class OIDCState(Model):
"""Pending OIDC login: maps the `state` param to its PKCE verifier.
Stored in Postgres (not process memory) so a login that starts on one
blue/green color and finishes on the other still validates.
"""
state = fields.CharField(max_length=128, primary_key=True)
pkce_verifier = fields.CharField(max_length=128)
expires_at = fields.DatetimeField(index=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "oidc_states"
+36
View File
@@ -0,0 +1,36 @@
"""
Persistent, single-use store for pending OIDC logins (state -> PKCE verifier).
Lives in Postgres so a login started on one blue/green color can finish on
the other.
"""
from datetime import datetime, timedelta, timezone
from .models import OIDCState
OIDC_STATE_TTL = timedelta(minutes=10)
async def save_oidc_state(
state: str, pkce_verifier: str, ttl: timedelta = OIDC_STATE_TTL
) -> None:
now = datetime.now(timezone.utc)
# Opportunistic cleanup of abandoned logins
await OIDCState.filter(expires_at__lte=now).delete()
await OIDCState.create(
state=state, pkce_verifier=pkce_verifier, expires_at=now + ttl
)
async def consume_oidc_state(state: str) -> str | None:
"""Return the PKCE verifier for `state` and delete it, or None if the
state is unknown, expired, or was already consumed."""
row = await OIDCState.get_or_none(state=state)
if row is None:
return None
# The delete is the single-use guard: only one concurrent caller wins.
deleted = await OIDCState.filter(state=state).delete()
if not deleted or row.expires_at <= datetime.now(timezone.utc):
return None
return row.pkce_verifier
@@ -0,0 +1,101 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "oidc_states" (
"state" VARCHAR(128) NOT NULL PRIMARY KEY,
"pkce_verifier" VARCHAR(128) NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "idx_oidc_states_expires_c461d9" ON "oidc_states" ("expires_at");
COMMENT ON TABLE "oidc_states" IS 'Pending OIDC login: maps the `state` param to its PKCE verifier.';
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
DROP TABLE IF EXISTS "oidc_states";"""
MODELS_STATE = (
"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"
"wS81"
)
+119
View File
@@ -0,0 +1,119 @@
"""Tests for jwt_access_required / admin_required in blueprints/users/decorators.py."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from quart import Quart, jsonify
from quart_jwt_extended import (
JWTManager,
create_access_token,
create_refresh_token,
get_jwt_identity,
jwt_refresh_token_required,
)
from blueprints.users.decorators import admin_required, jwt_access_required
@pytest.fixture
def app():
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
JWTManager(app)
@app.route("/protected")
@jwt_access_required
async def protected():
return jsonify(user=get_jwt_identity())
@app.route("/admin")
@admin_required
async def admin():
return jsonify(ok=True)
@app.route("/refresh", methods=["POST"])
@jwt_refresh_token_required
async def refresh():
return jsonify(user=get_jwt_identity())
return app
async def _token(app, kind, identity="user-1", expires_delta=None):
async with app.app_context():
if kind == "access":
return create_access_token(identity=identity, expires_delta=expires_delta)
return create_refresh_token(identity=identity, expires_delta=expires_delta)
def _auth(token):
return {"Authorization": f"Bearer {token}"}
class TestJwtAccessRequired:
async def test_accepts_access_token(self, app):
token = await _token(app, "access")
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 200
assert (await resp.get_json())["user"] == "user-1"
async def test_transitionally_accepts_refresh_token(self, app):
token = await _token(app, "refresh")
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 200
assert (await resp.get_json())["user"] == "user-1"
async def test_missing_token_is_401(self, app):
resp = await app.test_client().get("/protected")
assert resp.status_code == 401
async def test_expired_access_token_is_401(self, app):
token = await _token(app, "access", expires_delta=timedelta(seconds=-10))
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 401
async def test_expired_refresh_token_is_401(self, app):
token = await _token(app, "refresh", expires_delta=timedelta(seconds=-10))
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 401
async def test_garbage_token_rejected(self, app):
resp = await app.test_client().get("/protected", headers=_auth("not.a.jwt"))
assert resp.status_code == 422
async def test_refresh_endpoint_still_rejects_access_token(self, app):
token = await _token(app, "access")
resp = await app.test_client().post("/refresh", headers=_auth(token))
assert resp.status_code == 422
class TestAdminRequired:
def _user(self, is_admin):
user = MagicMock()
user.is_admin.return_value = is_admin
return user
async def test_admin_with_access_token(self, app):
token = await _token(app, "access")
with patch(
"blueprints.users.decorators.User.get_or_none",
AsyncMock(return_value=self._user(True)),
):
resp = await app.test_client().get("/admin", headers=_auth(token))
assert resp.status_code == 200
async def test_non_admin_is_403(self, app):
token = await _token(app, "access")
with patch(
"blueprints.users.decorators.User.get_or_none",
AsyncMock(return_value=self._user(False)),
):
resp = await app.test_client().get("/admin", headers=_auth(token))
assert resp.status_code == 403
async def test_admin_without_token_is_401(self, app):
resp = await app.test_client().get("/admin")
assert resp.status_code == 401
+57
View File
@@ -0,0 +1,57 @@
"""Tests for the persistent OIDC state store in blueprints/users/oidc_state.py."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from tortoise import Tortoise
from blueprints.users.models import OIDCState
from blueprints.users.oidc_state import consume_oidc_state, save_oidc_state
@pytest.fixture
async def db():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={"models": ["blueprints.users.models"]},
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
class TestOIDCStateStore:
async def test_roundtrip(self, db):
await save_oidc_state("state-1", "verifier-1")
assert await consume_oidc_state("state-1") == "verifier-1"
async def test_single_use(self, db):
await save_oidc_state("state-1", "verifier-1")
assert await consume_oidc_state("state-1") == "verifier-1"
assert await consume_oidc_state("state-1") is None
async def test_unknown_state(self, db):
assert await consume_oidc_state("nope") is None
async def test_expired_state_rejected_and_removed(self, db):
await save_oidc_state("state-1", "verifier-1", ttl=timedelta(seconds=-1))
assert await consume_oidc_state("state-1") is None
assert await OIDCState.filter(state="state-1").count() == 0
async def test_save_purges_expired_rows(self, db):
await OIDCState.create(
state="old",
pkce_verifier="v",
expires_at=datetime.now(timezone.utc) - timedelta(minutes=1),
)
await save_oidc_state("new", "verifier")
assert await OIDCState.filter(state="old").count() == 0
assert await OIDCState.filter(state="new").count() == 1
async def test_concurrent_consume_only_one_wins(self, db):
await save_oidc_state("state-1", "verifier-1")
results = await asyncio.gather(
consume_oidc_state("state-1"), consume_oidc_state("state-1")
)
assert sorted(results, key=str) == [None, "verifier-1"]