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
+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"]