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:
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user