Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:21 +02:00
commit 7ca726caf2
659 files changed
+98171

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
"""/auth/login, /auth/callback, /auth/logout: cookies, rotation and CSRF."""
from __future__ import annotations
from uuid import uuid4
from fastapi.testclient import TestClient
from ragcore.application.auth import SessionService, SignedTokenCodec, decode_session
from ragcore.application.auth.ports import AuthorizationChallenge
from ragcore.domain.auth import OidcClaims
from ragcore.domain.control import Role
from ragcore.main import create_app
from ragcore.web.auth.dependencies import (
PENDING_LOGIN_COOKIE_NAME,
SESSION_COOKIE_NAME,
get_session_codec,
get_session_service,
)
SECRET = b"0" * 32
class FakeOidcProvider:
def __init__(self, claims: OidcClaims) -> None:
self.claims = claims
async def build_authorization_challenge(self, *, redirect_uri: str) -> AuthorizationChallenge:
return AuthorizationChallenge(
redirect_url="https://idp.example.test/authorize?mock=1",
state="fixed-state-for-tests",
nonce="fixed-nonce-for-tests",
code_verifier="fixed-verifier-for-tests",
)
async def complete_login(
self, *, code: str, code_verifier: str, nonce: str, redirect_uri: str
) -> OidcClaims:
return self.claims
def _fixture(*, https: bool = False) -> tuple[TestClient, SignedTokenCodec]:
provider = FakeOidcProvider(
OidcClaims(subject="alice", email="alice@example.test", groups=("ragcore-editors",))
)
service = SessionService(
provider=provider, group_roles={"ragcore-editors": Role.EDITOR}, workspace_id=uuid4()
)
codec = SignedTokenCodec(secret=SECRET)
api = create_app()
api.dependency_overrides[get_session_service] = lambda: service
api.dependency_overrides[get_session_codec] = lambda: codec
base_url = "https://testserver" if https else "http://testserver"
return TestClient(api, base_url=base_url, follow_redirects=False), codec
def test_login_redirects_and_sets_a_pending_login_cookie() -> None:
client, _ = _fixture()
response = client.get("/auth/login")
assert response.status_code == 302
assert response.headers["location"].startswith("https://idp.example.test/")
set_cookie = response.headers.get("set-cookie", "")
assert PENDING_LOGIN_COOKIE_NAME in set_cookie
assert "HttpOnly" in set_cookie
assert "samesite=lax" in set_cookie.lower()
def test_pending_login_cookie_is_marked_secure_over_https() -> None:
client, _ = _fixture(https=True)
response = client.get("/auth/login")
assert "Secure" in response.headers.get("set-cookie", "")
def test_pending_login_cookie_is_not_marked_secure_over_plain_http() -> None:
client, _ = _fixture(https=False)
response = client.get("/auth/login")
assert "Secure" not in response.headers.get("set-cookie", "")
def test_full_login_then_callback_sets_a_rotated_session_cookie() -> None:
client, _ = _fixture()
login_response = client.get("/auth/login")
callback_response = client.get(
"/auth/callback",
params={"code": "auth-code", "state": "fixed-state-for-tests"},
)
assert callback_response.status_code == 302
assert callback_response.headers["location"] == "/admin/dashboard"
set_cookie = callback_response.headers.get("set-cookie", "")
assert SESSION_COOKIE_NAME in set_cookie
# The pending-login cookie is cleared once login completes.
assert f"{PENDING_LOGIN_COOKIE_NAME}=" in set_cookie or any(
PENDING_LOGIN_COOKIE_NAME in header
for header in callback_response.headers.get_list("set-cookie")
)
session_cookie_value = client.cookies.get(SESSION_COOKIE_NAME)
pending_cookie_value = client.cookies.get(PENDING_LOGIN_COOKIE_NAME)
assert session_cookie_value
assert not pending_cookie_value
assert login_response.headers["set-cookie"] != callback_response.headers["set-cookie"]
def test_callback_with_mismatched_state_is_rejected() -> None:
client, _ = _fixture()
client.get("/auth/login")
response = client.get(
"/auth/callback",
params={"code": "auth-code", "state": "an-attacker-supplied-state"},
)
assert response.status_code == 400
assert response.json()["code"] == "INVALID_STATE"
def test_callback_without_a_pending_login_cookie_is_rejected() -> None:
client, _ = _fixture()
response = client.get(
"/auth/callback",
params={"code": "auth-code", "state": "fixed-state-for-tests"},
)
assert response.status_code == 400
assert response.json()["code"] == "LOGIN_EXPIRED"
def test_logout_without_csrf_token_is_rejected() -> None:
client, _ = _fixture()
client.get("/auth/login")
client.get("/auth/callback", params={"code": "auth-code", "state": "fixed-state-for-tests"})
response = client.post("/auth/logout")
assert response.status_code == 403
assert response.json()["code"] == "CSRF_TOKEN_INVALID"
def test_logout_with_a_forged_csrf_token_is_rejected() -> None:
client, _ = _fixture()
client.get("/auth/login")
client.get("/auth/callback", params={"code": "auth-code", "state": "fixed-state-for-tests"})
response = client.post("/auth/logout", headers={"x-csrf-token": "not-the-real-token"})
assert response.status_code == 403
assert response.json()["code"] == "CSRF_TOKEN_INVALID"
def test_logout_with_the_correct_csrf_token_succeeds_and_clears_the_session() -> None:
client, codec = _fixture()
client.get("/auth/login")
client.get("/auth/callback", params={"code": "auth-code", "state": "fixed-state-for-tests"})
session = decode_session(client.cookies[SESSION_COOKIE_NAME], codec=codec)
response = client.post("/auth/logout", headers={"x-csrf-token": session.csrf_token})
assert response.status_code == 302
assert response.headers["location"] == "/login"
assert not client.cookies.get(SESSION_COOKIE_NAME)
def test_logout_requires_authentication_before_csrf_is_even_checked() -> None:
client, _ = _fixture()
response = client.post("/auth/logout", headers={"x-csrf-token": "anything"})
assert response.status_code == 401
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
def test_auth_routes_are_503_when_oidc_is_not_configured() -> None:
api = create_app()
client = TestClient(api, follow_redirects=False)
response = client.get("/auth/login")
assert response.status_code == 503
assert response.json()["code"] == "AUTH_UNAVAILABLE"
@@ -0,0 +1,270 @@
"""AuthentikOidcProvider: PKCE, discovery, and real ID token signature/claim
validation against a JWKS (issuer, audience, expiry, nonce)."""
from __future__ import annotations
import base64
import hashlib
import time
from collections.abc import Callable
from urllib.parse import parse_qs, urlsplit
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt.algorithms import RSAAlgorithm
from ragcore.application.auth.ports import (
IdTokenValidationError,
InvalidNonceError,
TokenExchangeError,
)
from ragcore.infrastructure.auth.oidc import AuthentikOidcProvider, OidcClientConfig
ISSUER = "https://auth.example.test/application/o/ragcore/"
CLIENT_ID = "ragcore"
REDIRECT_URI = "https://ragcore.example.test/auth/callback"
KID = "test-signing-key"
_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _jwk_public() -> dict[str, object]:
jwk = RSAAlgorithm.to_jwk(_private_key.public_key(), as_dict=True)
jwk["kid"] = KID
jwk["use"] = "sig"
jwk["alg"] = "RS256"
return jwk
def _sign_id_token(claims: dict[str, object]) -> str:
return jwt.encode(claims, _private_key, algorithm="RS256", headers={"kid": KID})
def _base_claims(*, nonce: str, aud: str = CLIENT_ID, iss: str = ISSUER) -> dict[str, object]:
now = int(time.time())
return {
"iss": iss,
"aud": aud,
"sub": "user-123",
"exp": now + 300,
"iat": now,
"nonce": nonce,
"email": "user@example.test",
"groups": ["ragcore-editors"],
}
def _handler_factory(
id_token_claims_override: dict[str, object] | None = None,
) -> tuple[Callable[[httpx.Request], httpx.Response], dict[str, object]]:
state = {"token_calls": 0, "code_verifier": None}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
json={
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}authorize",
"token_endpoint": f"{ISSUER}token",
"jwks_uri": f"{ISSUER}jwks",
"end_session_endpoint": f"{ISSUER}logout",
},
)
if request.url.path.endswith("/jwks"):
return httpx.Response(200, json={"keys": [_jwk_public()]})
if request.url.path.endswith("/token"):
state["token_calls"] += 1
form = dict(
pair.split("=", 1) for pair in request.content.decode().split("&") if "=" in pair
)
state["code_verifier"] = form.get("code_verifier")
claims = id_token_claims_override or _base_claims(nonce="expected-nonce")
return httpx.Response(200, json={"id_token": _sign_id_token(claims)})
return httpx.Response(404)
return handler, state
def _provider(handler: Callable[[httpx.Request], httpx.Response]) -> AuthentikOidcProvider:
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return AuthentikOidcProvider(
config=OidcClientConfig(issuer=ISSUER, client_id=CLIENT_ID, client_secret="secret"),
client=client,
)
async def test_build_authorization_challenge_has_valid_pkce_s256_and_oidc_params() -> None:
handler, _ = _handler_factory()
provider = _provider(handler)
challenge = await provider.build_authorization_challenge(redirect_uri=REDIRECT_URI)
parsed = urlsplit(challenge.redirect_url)
query = parse_qs(parsed.query)
assert query["response_type"] == ["code"]
assert query["client_id"] == [CLIENT_ID]
assert query["redirect_uri"] == [REDIRECT_URI]
assert query["state"] == [challenge.state]
assert query["nonce"] == [challenge.nonce]
assert query["code_challenge_method"] == ["S256"]
expected_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(challenge.code_verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
assert query["code_challenge"] == [expected_challenge]
async def test_two_challenges_never_reuse_state_nonce_or_verifier() -> None:
handler, _ = _handler_factory()
provider = _provider(handler)
first = await provider.build_authorization_challenge(redirect_uri=REDIRECT_URI)
second = await provider.build_authorization_challenge(redirect_uri=REDIRECT_URI)
assert first.state != second.state
assert first.nonce != second.nonce
assert first.code_verifier != second.code_verifier
async def test_complete_login_validates_signature_issuer_audience_and_nonce() -> None:
handler, state = _handler_factory()
provider = _provider(handler)
claims = await provider.complete_login(
code="auth-code",
code_verifier="a-verifier",
nonce="expected-nonce",
redirect_uri=REDIRECT_URI,
)
assert claims.subject == "user-123"
assert claims.email == "user@example.test"
assert claims.groups == ("ragcore-editors",)
assert state["code_verifier"] == "a-verifier" # PKCE verifier reaches the token exchange
async def test_wrong_nonce_is_rejected() -> None:
handler, _ = _handler_factory(_base_claims(nonce="actual-nonce"))
provider = _provider(handler)
with pytest.raises(InvalidNonceError):
await provider.complete_login(
code="auth-code",
code_verifier="a-verifier",
nonce="a-different-nonce-entirely",
redirect_uri=REDIRECT_URI,
)
async def test_wrong_audience_is_rejected() -> None:
handler, _ = _handler_factory(_base_claims(nonce="n", aud="some-other-client"))
provider = _provider(handler)
with pytest.raises(IdTokenValidationError):
await provider.complete_login(
code="auth-code", code_verifier="v", nonce="n", redirect_uri=REDIRECT_URI
)
async def test_wrong_issuer_is_rejected() -> None:
handler, _ = _handler_factory(_base_claims(nonce="n", iss="https://not-the-real-idp.test/"))
provider = _provider(handler)
with pytest.raises(IdTokenValidationError):
await provider.complete_login(
code="auth-code", code_verifier="v", nonce="n", redirect_uri=REDIRECT_URI
)
async def test_expired_id_token_is_rejected() -> None:
now = int(time.time())
expired_claims = {
**_base_claims(nonce="n"),
"exp": now - 300,
"iat": now - 600,
}
handler, _ = _handler_factory(expired_claims)
provider = _provider(handler)
with pytest.raises(IdTokenValidationError):
await provider.complete_login(
code="auth-code", code_verifier="v", nonce="n", redirect_uri=REDIRECT_URI
)
async def test_signature_from_an_untrusted_key_is_rejected() -> None:
other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
json={
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}authorize",
"token_endpoint": f"{ISSUER}token",
"jwks_uri": f"{ISSUER}jwks",
},
)
if request.url.path.endswith("/jwks"):
return httpx.Response(200, json={"keys": [_jwk_public()]})
if request.url.path.endswith("/token"):
forged = jwt.encode(
_base_claims(nonce="n"), other_key, algorithm="RS256", headers={"kid": KID}
)
return httpx.Response(200, json={"id_token": forged})
return httpx.Response(404)
provider = _provider(handler)
with pytest.raises(IdTokenValidationError):
await provider.complete_login(
code="auth-code", code_verifier="v", nonce="n", redirect_uri=REDIRECT_URI
)
async def test_token_endpoint_failure_raises_a_stable_typed_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
json={
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}authorize",
"token_endpoint": f"{ISSUER}token",
"jwks_uri": f"{ISSUER}jwks",
},
)
if request.url.path.endswith("/token"):
return httpx.Response(400, json={"error": "invalid_grant"})
return httpx.Response(404)
provider = _provider(handler)
with pytest.raises(TokenExchangeError):
await provider.complete_login(
code="bad-code", code_verifier="v", nonce="n", redirect_uri=REDIRECT_URI
)
async def test_build_logout_url_uses_the_discovered_end_session_endpoint() -> None:
handler, _ = _handler_factory()
provider = _provider(handler)
url = await provider.build_logout_url(post_logout_redirect_uri="https://ragcore.example.test/")
assert url is not None
assert url.startswith(f"{ISSUER}logout")
assert "post_logout_redirect_uri" in url
def test_issuer_must_be_an_absolute_http_url() -> None:
with pytest.raises(ValueError, match="issuer"):
OidcClientConfig(issuer="not-a-url", client_id=CLIENT_ID, client_secret="secret")
def test_client_id_must_not_be_empty() -> None:
with pytest.raises(ValueError, match="client_id"):
OidcClientConfig(issuer=ISSUER, client_id="", client_secret="secret")
+74
View File
@@ -0,0 +1,74 @@
"""Role mapping is deny-by-default: unmapped groups grant nothing."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from ragcore.application.authorization import AuthorizationService
from ragcore.domain.auth import OidcClaims, Session, map_roles
from ragcore.domain.authorization import Permission, PermissionDenied, Principal, PrincipalType
from ragcore.domain.control import Role
def test_recognized_groups_map_to_their_configured_role() -> None:
roles = map_roles(("ragcore-admins",), {"ragcore-admins": Role.PLATFORM_ADMIN})
assert roles == frozenset({Role.PLATFORM_ADMIN})
def test_unrecognized_groups_grant_nothing() -> None:
roles = map_roles(("some-other-app-group",), {"ragcore-admins": Role.PLATFORM_ADMIN})
assert roles == frozenset()
def test_no_groups_at_all_grants_nothing() -> None:
roles = map_roles((), {"ragcore-admins": Role.PLATFORM_ADMIN})
assert roles == frozenset()
def test_mixed_recognized_and_unrecognized_groups_only_map_the_known_ones() -> None:
roles = map_roles(
("ragcore-editors", "some-unrelated-group", "ragcore-viewers"),
{"ragcore-editors": Role.EDITOR, "ragcore-viewers": Role.VIEWER},
)
assert roles == frozenset({Role.EDITOR, Role.VIEWER})
def test_oidc_claims_bounds_are_enforced() -> None:
with pytest.raises(ValueError, match="subject"):
OidcClaims(subject="", email=None, groups=())
with pytest.raises(ValueError, match="groups"):
OidcClaims(subject="alice", email=None, groups=tuple(f"g{i}" for i in range(101)))
def test_a_session_with_no_mapped_roles_is_denied_everywhere() -> None:
"""Ties role mapping's deny-by-default guarantee to the real, already-
established authorization system: an authenticated identity with an
empty roles set (because none of its groups were recognized) is denied
exactly like any other roleless principal -- there is no separate
"authenticated but unmapped" bypass anywhere in the authorization path.
"""
workspace_id = uuid4()
claims = OidcClaims(subject="alice", email="alice@example.test", groups=("unmapped-group",))
roles = map_roles(claims.groups, {"ragcore-admins": Role.PLATFORM_ADMIN})
now = datetime.now(UTC)
session = Session(
id=uuid4(),
subject=claims.subject,
workspace_id=workspace_id,
issued_at=now,
expires_at=now + timedelta(hours=1),
csrf_token="a" * 32,
roles=roles,
)
assert session.roles == frozenset()
principal = Principal(uuid4(), workspace_id, PrincipalType.HUMAN, session.roles)
with pytest.raises(PermissionDenied):
AuthorizationService().authorize(
principal, Permission.SEARCH, resource_workspace_id=workspace_id
)
@@ -0,0 +1,138 @@
"""SessionAuthenticationMiddleware: populates request.state.principal from
a verified session cookie, and only ever adds one -- never rejects a request
itself, matching get_current_session's own null-on-any-failure behavior."""
from __future__ import annotations
import secrets
from datetime import UTC, datetime, timedelta
from uuid import UUID, uuid4
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from ragcore.application.auth import SignedTokenCodec, encode_session
from ragcore.domain.auth import Session, principal_id_for_subject
from ragcore.domain.authorization import PrincipalType
from ragcore.domain.control import Role
from ragcore.web.auth import SessionAuthenticationMiddleware
from ragcore.web.auth.dependencies import SESSION_COOKIE_NAME
SECRET = b"0" * 32
def _session(
*,
subject: str = "alice",
workspace_id: UUID | None = None,
roles: frozenset[Role] = frozenset({Role.EDITOR}),
issued_at: datetime | None = None,
expires_at: datetime | None = None,
) -> Session:
now = issued_at or datetime.now(UTC)
return Session(
id=uuid4(),
subject=subject,
workspace_id=workspace_id or uuid4(),
issued_at=now,
expires_at=expires_at or (now + timedelta(hours=1)),
csrf_token=secrets.token_urlsafe(32),
roles=roles,
)
def _app(*, codec: SignedTokenCodec | None) -> FastAPI:
app = FastAPI()
@app.get("/whoami")
async def whoami(request: Request) -> dict[str, object]:
principal = getattr(request.state, "principal", None)
if principal is None:
return {"authenticated": False}
return {
"authenticated": True,
"id": str(principal.id),
"workspace_id": str(principal.workspace_id),
"principal_type": principal.principal_type.value,
"roles": sorted(role.value for role in principal.roles),
}
app.add_middleware(SessionAuthenticationMiddleware)
if codec is not None:
app.state.auth_session_codec = codec
return app
def test_valid_session_cookie_populates_a_matching_principal() -> None:
codec = SignedTokenCodec(secret=SECRET)
session = _session(subject="alice", roles=frozenset({Role.EDITOR, Role.VIEWER}))
client = TestClient(_app(codec=codec))
client.cookies.set(SESSION_COOKIE_NAME, encode_session(session, codec=codec))
response = client.get("/whoami")
assert response.status_code == 200
body = response.json()
assert body["authenticated"] is True
assert body["id"] == str(principal_id_for_subject("alice"))
assert body["workspace_id"] == str(session.workspace_id)
assert body["principal_type"] == PrincipalType.HUMAN.value
assert body["roles"] == sorted(role.value for role in session.roles)
def test_no_cookie_leaves_the_principal_unset() -> None:
codec = SignedTokenCodec(secret=SECRET)
client = TestClient(_app(codec=codec))
response = client.get("/whoami")
assert response.json() == {"authenticated": False}
def test_tampered_cookie_leaves_the_principal_unset_rather_than_erroring() -> None:
codec = SignedTokenCodec(secret=SECRET)
other_codec = SignedTokenCodec(secret=b"1" * 32)
session = _session()
client = TestClient(_app(codec=codec))
client.cookies.set(SESSION_COOKIE_NAME, encode_session(session, codec=other_codec))
response = client.get("/whoami")
assert response.status_code == 200
assert response.json() == {"authenticated": False}
def test_expired_session_cookie_leaves_the_principal_unset() -> None:
codec = SignedTokenCodec(secret=SECRET)
now = datetime.now(UTC)
session = _session(issued_at=now - timedelta(hours=2), expires_at=now - timedelta(hours=1))
client = TestClient(_app(codec=codec))
client.cookies.set(SESSION_COOKIE_NAME, encode_session(session, codec=codec))
response = client.get("/whoami")
assert response.json() == {"authenticated": False}
def test_missing_codec_is_a_silent_noop_even_with_a_cookie_present() -> None:
"""OIDC not configured (no auth_session_codec on app.state) must not crash."""
codec = SignedTokenCodec(secret=SECRET)
session = _session()
client = TestClient(_app(codec=None))
client.cookies.set(SESSION_COOKIE_NAME, encode_session(session, codec=codec))
response = client.get("/whoami")
assert response.status_code == 200
assert response.json() == {"authenticated": False}
def test_malformed_cookie_value_leaves_the_principal_unset() -> None:
codec = SignedTokenCodec(secret=SECRET)
client = TestClient(_app(codec=codec))
client.cookies.set(SESSION_COOKIE_NAME, "not-a-valid-token")
response = client.get("/whoami")
assert response.json() == {"authenticated": False}
+150
View File
@@ -0,0 +1,150 @@
"""SessionService: state validation and session rotation."""
from __future__ import annotations
from collections.abc import Mapping
from uuid import uuid4
import pytest
from ragcore.application.auth import InvalidStateError, PendingLogin, SessionService
from ragcore.application.auth.ports import AuthorizationChallenge
from ragcore.domain.auth import OidcClaims
from ragcore.domain.control import Role
class FakeOidcProvider:
def __init__(self, claims: OidcClaims) -> None:
self.claims = claims
self.challenge_calls: list[str] = []
self.login_calls: list[tuple[str, str, str, str]] = []
async def build_authorization_challenge(self, *, redirect_uri: str) -> AuthorizationChallenge:
self.challenge_calls.append(redirect_uri)
return AuthorizationChallenge(
redirect_url="https://idp.example.test/authorize?...",
state=f"state-{len(self.challenge_calls)}",
nonce=f"nonce-{len(self.challenge_calls)}",
code_verifier=f"verifier-{len(self.challenge_calls)}",
)
async def complete_login(
self, *, code: str, code_verifier: str, nonce: str, redirect_uri: str
) -> OidcClaims:
self.login_calls.append((code, code_verifier, nonce, redirect_uri))
return self.claims
def _service(
provider: FakeOidcProvider, *, group_roles: Mapping[str, Role] | None = None
) -> SessionService:
return SessionService(
provider=provider,
group_roles=group_roles or {"ragcore-editors": Role.EDITOR},
workspace_id=uuid4(),
)
async def test_start_login_returns_a_redirect_and_pending_login() -> None:
provider = FakeOidcProvider(OidcClaims(subject="alice", email=None, groups=()))
service = _service(provider)
start = await service.start_login(redirect_uri="https://ragcore.example.test/auth/callback")
assert start.redirect_url.startswith("https://idp.example.test/")
assert start.pending.state
assert start.pending.nonce
assert start.pending.code_verifier
assert provider.challenge_calls == ["https://ragcore.example.test/auth/callback"]
async def test_complete_login_mints_a_session_with_mapped_roles() -> None:
provider = FakeOidcProvider(
OidcClaims(subject="alice", email="alice@example.test", groups=("ragcore-editors",))
)
service = _service(provider)
start = await service.start_login(redirect_uri="https://ragcore.example.test/auth/callback")
session = await service.complete_login(
code="auth-code",
state=start.pending.state,
pending=start.pending,
redirect_uri="https://ragcore.example.test/auth/callback",
)
assert session.subject == "alice"
assert session.roles == frozenset({Role.EDITOR})
assert session.csrf_token
assert not session.is_expired
assert provider.login_calls == [
("auth-code", start.pending.code_verifier, start.pending.nonce, "https://ragcore.example.test/auth/callback")
]
async def test_mismatched_state_is_rejected() -> None:
provider = FakeOidcProvider(OidcClaims(subject="alice", email=None, groups=()))
service = _service(provider)
start = await service.start_login(redirect_uri="https://ragcore.example.test/auth/callback")
with pytest.raises(InvalidStateError):
await service.complete_login(
code="auth-code",
state="a-completely-different-state",
pending=start.pending,
redirect_uri="https://ragcore.example.test/auth/callback",
)
# The provider is never even asked to exchange the code once state fails.
assert provider.login_calls == []
async def test_two_logins_never_reuse_the_same_session_id_session_rotation() -> None:
"""Session rotation's standard security meaning: the post-authentication
session identifier is never the pending (pre-authentication) one, and a
fresh login always mints a brand-new session -- never resurrects or
reuses a previous one, defeating session-fixation attacks.
"""
provider = FakeOidcProvider(OidcClaims(subject="alice", email=None, groups=()))
service = _service(provider)
first_start = await service.start_login(redirect_uri="https://ragcore.example.test/auth/callback")
first_session = await service.complete_login(
code="code-1",
state=first_start.pending.state,
pending=first_start.pending,
redirect_uri="https://ragcore.example.test/auth/callback",
)
second_start = await service.start_login(redirect_uri="https://ragcore.example.test/auth/callback")
second_session = await service.complete_login(
code="code-2",
state=second_start.pending.state,
pending=second_start.pending,
redirect_uri="https://ragcore.example.test/auth/callback",
)
assert first_session.id != second_session.id
assert first_session.csrf_token != second_session.csrf_token
async def test_unrecognized_groups_produce_a_roleless_session() -> None:
provider = FakeOidcProvider(
OidcClaims(subject="mallory", email=None, groups=("not-a-real-group",))
)
service = _service(provider, group_roles={"ragcore-admins": Role.PLATFORM_ADMIN})
start = await service.start_login(redirect_uri="https://ragcore.example.test/auth/callback")
session = await service.complete_login(
code="auth-code",
state=start.pending.state,
pending=start.pending,
redirect_uri="https://ragcore.example.test/auth/callback",
)
assert session.roles == frozenset()
def test_pending_login_is_an_immutable_value() -> None:
pending = PendingLogin(state="s", nonce="n", code_verifier="v")
with pytest.raises(AttributeError):
pending.state = "other" # type: ignore[misc]
+88
View File
@@ -0,0 +1,88 @@
"""SignedTokenCodec: tamper-evidence and expiry."""
from __future__ import annotations
import base64
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from ragcore.application.auth import SignedTokenCodec, TokenExpiredError, TokenTamperedError
SECRET = b"0" * 32
def _codec(clock: Callable[[], datetime] | None = None) -> SignedTokenCodec:
kwargs = {} if clock is None else {"clock": clock}
return SignedTokenCodec(secret=SECRET, **kwargs)
def test_round_trips_a_payload() -> None:
codec = _codec()
token = codec.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5))
body = codec.decode(token)
assert body["sub"] == "alice"
def test_tampered_payload_is_rejected() -> None:
codec = _codec()
token = codec.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5))
body_b64, signature_b64 = token.split(".", 1)
tampered = f"{body_b64}x.{signature_b64}"
with pytest.raises(TokenTamperedError):
codec.decode(tampered)
def test_tampered_signature_is_rejected() -> None:
codec = _codec()
token = codec.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5))
body_b64, signature_b64 = token.split(".", 1)
# Flip a bit in the *decoded* signature bytes and re-encode, rather than
# editing the base64 text directly: replacing only the last character of
# a base64 string can, depending on the byte length, land on padding
# bits that don't change the decoded value at all -- flipping a real
# byte always changes the signature.
padding = "=" * (-len(signature_b64) % 4)
raw_signature = bytearray(base64.urlsafe_b64decode(signature_b64 + padding))
raw_signature[0] ^= 0xFF
tampered_signature_b64 = base64.urlsafe_b64encode(bytes(raw_signature)).rstrip(b"=").decode()
tampered = f"{body_b64}.{tampered_signature_b64}"
with pytest.raises(TokenTamperedError):
codec.decode(tampered)
def test_malformed_token_shapes_are_rejected() -> None:
codec = _codec()
for malformed in ("", "no-dot-at-all", "a.b.c", "."):
with pytest.raises(TokenTamperedError):
codec.decode(malformed)
def test_different_secret_cannot_decode_another_codecs_token() -> None:
codec_a = SignedTokenCodec(secret=b"a" * 32)
codec_b = SignedTokenCodec(secret=b"b" * 32)
token = codec_a.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5))
with pytest.raises(TokenTamperedError):
codec_b.decode(token)
def test_expired_token_is_rejected_distinctly_from_tampering() -> None:
clock_time = {"now": datetime(2026, 1, 1, tzinfo=UTC)}
codec = _codec(clock=lambda: clock_time["now"])
token = codec.encode({"sub": "alice"}, expires_at=clock_time["now"] + timedelta(minutes=5))
clock_time["now"] = clock_time["now"] + timedelta(minutes=6)
with pytest.raises(TokenExpiredError):
codec.decode(token)
def test_secret_must_be_at_least_32_bytes() -> None:
with pytest.raises(ValueError, match="32 bytes"):
SignedTokenCodec(secret=b"too-short")
def test_expires_at_must_be_timezone_aware() -> None:
codec = _codec()
naive = datetime.fromisoformat("2026-01-01T00:00:00")
with pytest.raises(ValueError, match="timezone-aware"):
codec.encode({"sub": "alice"}, expires_at=naive)