Accept access tokens on protected routes; persist OIDC state #57
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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