From bc3e3d27979d14f78d7f3f374ec6d7b08a9583db Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 12 Oct 2026 17:40:47 -0400 Subject: [PATCH] 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 --- app.py | 5 +- blueprints/beli/__init__.py | 16 +-- blueprints/conversation/__init__.py | 24 ++-- blueprints/rag/__init__.py | 5 +- blueprints/users/__init__.py | 22 ++-- blueprints/users/decorators.py | 41 +++++- blueprints/users/models.py | 16 +++ blueprints/users/oidc_state.py | 36 ++++++ .../16_20261012173826_add_oidc_states.py | 101 +++++++++++++++ tests/unit/test_auth_decorators.py | 119 ++++++++++++++++++ tests/unit/test_oidc_state.py | 57 +++++++++ 11 files changed, 400 insertions(+), 42 deletions(-) create mode 100644 blueprints/users/oidc_state.py create mode 100644 migrations/models/16_20261012173826_add_oidc_states.py create mode 100644 tests/unit/test_auth_decorators.py create mode 100644 tests/unit/test_oidc_state.py diff --git a/app.py b/app.py index 1558f2a..7b3ba5a 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/blueprints/beli/__init__.py b/blueprints/beli/__init__.py index 8b1bc39..25d2743 100644 --- a/blueprints/beli/__init__.py +++ b/blueprints/beli/__init__.py @@ -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/", 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//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//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) diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index 54e2c2f..1df322e 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -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/") -@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("/") -@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("/") -@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("/") -@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) diff --git a/blueprints/rag/__init__.py b/blueprints/rag/__init__.py index 196aea9..acd0b17 100644 --- a/blueprints/rag/__init__.py +++ b/blueprints/rag/__init__.py @@ -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() diff --git a/blueprints/users/__init__.py b/blueprints/users/__init__.py index ea9cfe0..6a39d03 100644 --- a/blueprints/users/__init__.py +++ b/blueprints/users/__init__.py @@ -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) diff --git a/blueprints/users/decorators.py b/blueprints/users/decorators.py index ac9a3a3..7e208eb 100644 --- a/blueprints/users/decorators.py +++ b/blueprints/users/decorators.py @@ -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) diff --git a/blueprints/users/models.py b/blueprints/users/models.py index f6b1902..49d7d1c 100644 --- a/blueprints/users/models.py +++ b/blueprints/users/models.py @@ -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" diff --git a/blueprints/users/oidc_state.py b/blueprints/users/oidc_state.py new file mode 100644 index 0000000..eb32b9d --- /dev/null +++ b/blueprints/users/oidc_state.py @@ -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 diff --git a/migrations/models/16_20261012173826_add_oidc_states.py b/migrations/models/16_20261012173826_add_oidc_states.py new file mode 100644 index 0000000..85e5016 --- /dev/null +++ b/migrations/models/16_20261012173826_add_oidc_states.py @@ -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" +) diff --git a/tests/unit/test_auth_decorators.py b/tests/unit/test_auth_decorators.py new file mode 100644 index 0000000..d52a4b4 --- /dev/null +++ b/tests/unit/test_auth_decorators.py @@ -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 diff --git a/tests/unit/test_oidc_state.py b/tests/unit/test_oidc_state.py new file mode 100644 index 0000000..a9a1672 --- /dev/null +++ b/tests/unit/test_oidc_state.py @@ -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"] -- 2.52.0