Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user