Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8db9575
fix(auth): require issued-at in Keyverse OIDC sessions
Aug 11, 2026
6a5cf11
fix(security): strip orphaned html comment terminators
Aug 11, 2026
ca6ccba
merge: sync Keyverse OIDC fix with develop
seonghobae Aug 12, 2026
0c360f2
Merge branch 'develop' into codex/keyverse-oidc-iat
opencode-agent[bot] Aug 12, 2026
94db8c5
Merge protected develop into Keyverse OIDC claim fix
seonghobae Aug 14, 2026
78edae8
Merge protected develop into Keyverse OIDC claim fix
seonghobae Aug 14, 2026
2a8afe3
test(auth): reproduce unique-token throttle bypass
seonghobae Aug 15, 2026
8be5eca
fix(auth): aggregate invalid-session failures by HTTP peer
seonghobae Aug 15, 2026
62ef5fd
Merge branch 'develop' into codex/keyverse-oidc-iat
opencode-agent[bot] Aug 15, 2026
c7ab796
test(auth): cover aggregate failure scope boundaries
seonghobae Aug 15, 2026
d09b6b6
docs(auth): record aggregate throttle trust boundary
seonghobae Aug 15, 2026
8dc2431
docs(auth): align OIDC and throttle trust boundaries
seonghobae Aug 15, 2026
e83395f
merge(auth): sync OIDC and throttle fixes with develop
seonghobae Aug 15, 2026
a7bdaad
docs(auth): describe HMAC and OIDC verification modes
seonghobae Aug 15, 2026
ffb2363
test(security): reject unvalidated IMAP fetch destinations
seonghobae Aug 15, 2026
2790a7e
fix(security): validate IMAP fetch destinations
seonghobae Aug 15, 2026
b24be45
Merge branch 'develop' into codex/keyverse-oidc-iat
seonghobae Aug 17, 2026
320ad0e
Merge branch 'develop' into codex/keyverse-oidc-iat
opencode-agent[bot] Aug 17, 2026
57f4218
Merge branch 'develop' into codex/keyverse-oidc-iat
seonghobae Aug 17, 2026
091e9f8
Merge branch 'develop' into codex/keyverse-oidc-iat
opencode-agent[bot] Aug 20, 2026
d06eff3
Merge branch 'develop' into codex/keyverse-oidc-iat
opencode-agent[bot] Aug 22, 2026
e2c7017
Merge branch 'develop' into codex/keyverse-oidc-iat
seonghobae Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,12 @@ compact session tokens whose protected header pins `alg=HS256` and whose
`header.payload` signing input is signed by the configured
`AUTH_SESSION_HMAC_SECRET`; missing, weak, malformed, legacy two-segment,
wrong-algorithm, tampered, expired, or public fixture-secret tokens fail closed
with 401. The signed session envelope must carry explicit identity, role,
organization/group, and workspace claims, so user ids such as `admin` do not
imply elevated privileges.
with 401. OIDC tokens additionally require a verified `iat` NumericDate, exact
configured issuer equality, and a verified audience claim containing the
configured OIDC client ID (including multi-valued audiences) before their tenant
and role claims are used. The signed session envelope must carry explicit
identity, role, organization/group, and workspace claims, so user ids such as
`admin` do not imply elevated privileges.
Endpoint tests use FastAPI dependency overrides for fixture identity only through
explicit opt-in pytest fixtures, while a full Keycloak/Casdoor/OIDC provider and
audited mailbox-owner migration remain required before production multi-user
Expand All @@ -292,8 +295,9 @@ provider endpoints additionally require an operator-owned egress allowlist so an
organization admin cannot point LLM traffic at localhost, private networks, or
cloud metadata services.

The browser API client reads `naruon_session_token` from local storage and sends
it as the bearer session on signed routes. It does not synthesize or forward
The browser API client uses the HttpOnly `naruon_session` cookie through the
same-origin `/api/*` proxy, which sends the verified bearer session on signed
routes. It does not read browser-readable bearer tokens or synthesize/forward
public identity headers such as `X-User-Id`, `X-Organization-Id`, `X-Group-Id`,
`X-Group-Ids`, `X-User-Role`, or `X-Dev-Auth-Token`; any local development
identity-header flow is limited to explicit unsigned/test harness paths and is
Expand Down
109 changes: 89 additions & 20 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import jwt
from jwt import PyJWKClient
from fastapi import Depends, Header, HTTPException
from fastapi import Depends, Header, HTTPException, Request

from core.config import settings, validate_auth_session_hmac_secret_value
from core.url_validation import (
Expand Down Expand Up @@ -148,11 +148,13 @@ def _build_oidc_jwks_client() -> PyJWKClient | None:
SESSION_ISSUER = "naruon-control-plane"
SESSION_AUDIENCE = "naruon-api"
JWT_DECODE_REQUIRED_CLAIMS = ("exp", "iss", "aud")
OIDC_JWT_DECODE_REQUIRED_CLAIMS = (*JWT_DECODE_REQUIRED_CLAIMS, "iat")
MIN_SESSION_SECRET_BYTES = 32
MAX_SIGNED_SESSION_EXPIRATION_SECONDS = 12 * 60 * 60
MAX_SIGNED_SESSION_CLOCK_SKEW_SECONDS = 60
SESSION_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60
SESSION_AUTH_RATE_LIMIT_MAX_FAILURES = 10
SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES = 100
SESSION_AUTH_RATE_LIMIT_MAX_BUCKETS = 4096
_session_auth_failure_buckets: dict[str, tuple[int, float]] = {}

Expand Down Expand Up @@ -188,19 +190,39 @@ def is_admin_role(role: str) -> bool:

async def get_auth_context(
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
request: Request = None,
) -> AuthContext:
return build_auth_context(authorization=authorization)
"""Build HTTP identity while applying aggregate invalid-session throttling.

The aggregate scope comes only from the ASGI peer address exposed by
``request.client``. Application code deliberately ignores Forwarded and
X-Forwarded-For so arbitrary request headers cannot mint fresh throttle
identities. Operators behind a trusted reverse proxy must configure that
proxy/server boundary so the ASGI client address has the intended meaning.
"""
failure_scope = _http_session_auth_failure_scope(request)
payload, session_verifier = _verify_signed_session_payload(
authorization,
failure_scope=failure_scope,
)
return _auth_context_from_session_payload(payload, session_verifier)


def build_auth_context(authorization: str | None = None) -> AuthContext:
"""
Build runtime identity from verified signed session material.

Client-supplied identity metadata is not authentication material. Only a
bearer token signed by the configured control-plane HMAC secret can supply
identity, role, organization, group, and workspace claims in the runtime
dependency path. Endpoint tests that need fixture identities must continue to
use explicit FastAPI dependency overrides.
Build runtime identity from a verified signed bearer session.

Client-supplied identity metadata is not authentication material. A bearer
token must be verified by the configured OIDC/JWKS provider or the
control-plane HMAC secret before it can supply identity, role, organization,
group, and workspace claims in the runtime dependency path. Endpoint tests
that need fixture identities must continue to use explicit FastAPI
dependency overrides.

This direct non-HTTP entry point intentionally has no peer-derived aggregate
rate-limit scope; it retains the exact-token failure budget only. HTTP
callers should use ``get_auth_context`` so varying invalid tokens from one
observed peer share an additional coarse abuse budget.
"""
payload, session_verifier = _verify_signed_session_payload(authorization)
return _auth_context_from_session_payload(payload, session_verifier)
Expand Down Expand Up @@ -253,7 +275,7 @@ def _decode_cached_oidc_session_payload(token: str) -> dict[str, Any]:
audience=settings.OIDC_CLIENT_ID,
issuer=settings.OIDC_ISSUER_URL,
options={
"require": JWT_DECODE_REQUIRED_CLAIMS,
"require": OIDC_JWT_DECODE_REQUIRED_CLAIMS,
"verify_signature": True,
},
)
Expand Down Expand Up @@ -302,6 +324,21 @@ def _session_auth_failure_key(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()


def _session_auth_scope_failure_key(scope: str) -> str:
digest = hashlib.sha256(scope.encode("utf-8")).hexdigest()
return f"scope:{digest}"


def _http_session_auth_failure_scope(request: Request | None) -> str | None:
"""Return the server-observed HTTP peer scope without trusting headers."""
if request is None:
return None
client = request.client
if client is None or not client.host:
return "peer:unavailable"
return f"peer:{client.host}"


def _prune_session_auth_failure_buckets(now: float) -> None:
expired_keys = [
key
Expand All @@ -321,22 +358,34 @@ def _ensure_session_auth_failure_bucket_capacity(key: str) -> None:
_session_auth_failure_buckets.pop(next(iter(_session_auth_failure_buckets)), None)


def _reject_if_session_auth_rate_limited(token: str) -> None:
now = time.monotonic()
_prune_session_auth_failure_buckets(now)
key = _session_auth_failure_key(token)
def _session_auth_failure_count(key: str, now: float) -> int:
failure_count, _reset_at = _session_auth_failure_buckets.get(
key,
(0, now + SESSION_AUTH_RATE_LIMIT_WINDOW_SECONDS),
)
if failure_count >= SESSION_AUTH_RATE_LIMIT_MAX_FAILURES:
raise _authentication_error()
return failure_count


def _record_session_auth_failure(token: str) -> None:
def _reject_if_session_auth_rate_limited(
token: str,
failure_scope: str | None = None,
) -> None:
now = time.monotonic()
_prune_session_auth_failure_buckets(now)
key = _session_auth_failure_key(token)
token_key = _session_auth_failure_key(token)
if _session_auth_failure_count(token_key, now) >= SESSION_AUTH_RATE_LIMIT_MAX_FAILURES:
raise _authentication_error()
if failure_scope is None:
return
scope_key = _session_auth_scope_failure_key(failure_scope)
if (
_session_auth_failure_count(scope_key, now)
>= SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES
):
raise _authentication_error()


def _increment_session_auth_failure(key: str, now: float) -> None:
_ensure_session_auth_failure_bucket_capacity(key)
failure_count, reset_at = _session_auth_failure_buckets.get(
key,
Expand All @@ -348,20 +397,38 @@ def _record_session_auth_failure(token: str) -> None:
_session_auth_failure_buckets[key] = (failure_count + 1, reset_at)


def _record_session_auth_failure(
token: str,
failure_scope: str | None = None,
) -> None:
now = time.monotonic()
_prune_session_auth_failure_buckets(now)
_increment_session_auth_failure(_session_auth_failure_key(token), now)
if failure_scope is not None:
_increment_session_auth_failure(
_session_auth_scope_failure_key(failure_scope),
now,
)


def _clear_session_auth_failures(token: str) -> None:
# Successful verification clears only the exact-token failure bucket. The
# coarse HTTP peer bucket is an abuse signal, not subscriber identity, and
# expires naturally so possession of one valid token cannot reset it.
_session_auth_failure_buckets.pop(_session_auth_failure_key(token), None)


def _verify_signed_session_payload(
authorization: str | None,
failure_scope: str | None = None,
) -> tuple[dict[str, Any], SessionVerifier]:
token = _extract_bearer_token(authorization)
_reject_if_session_auth_rate_limited(token)
_reject_if_session_auth_rate_limited(token, failure_scope)

try:
payload, session_verifier = _verify_signed_session_token(token)
except HTTPException:
_record_session_auth_failure(token)
_record_session_auth_failure(token, failure_scope)
raise
_clear_session_auth_failures(token)
return payload, session_verifier
Expand Down Expand Up @@ -491,6 +558,8 @@ def _validate_session_metadata(
if expires_at > now + MAX_SIGNED_SESSION_EXPIRATION_SECONDS:
raise _authentication_error()
issued_at = payload.get("iat")
if session_verifier == "oidc" and issued_at is None:
raise _authentication_error()
if issued_at is not None:
if isinstance(issued_at, bool) or not isinstance(issued_at, (int, float)):
raise _authentication_error()
Expand Down
2 changes: 2 additions & 0 deletions backend/services/imap_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ async def _fetch_messages(
) -> list[bytes]:
if imap_server is None or imap_port is None:
imap_server, imap_port = self._validated_destination(config)
else:
imap_server, imap_port = validate_imap_destination(imap_server, imap_port)
import ssl
ssl_context = ssl.create_default_context()
imap_client = aioimaplib.IMAP4_SSL(
Expand Down
148 changes: 148 additions & 0 deletions backend/tests/test_auth_http_rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import pytest
from fastapi.testclient import TestClient

from api import auth as auth_module
from api.auth import get_auth_context, get_current_user
from main import app


def test_http_auth_limits_varying_invalid_tokens_within_one_client_scope(monkeypatch):
"""A single HTTP client scope must share one invalid-session attempt budget."""
original_overrides = dict(app.dependency_overrides)
app.dependency_overrides.pop(get_auth_context, None)
app.dependency_overrides.pop(get_current_user, None)
auth_module._session_auth_failure_buckets.clear()
monkeypatch.setattr(
auth_module,
"SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES",
3,
)
decode_attempts = 0

def reject_header(token: str):
nonlocal decode_attempts
decode_attempts += 1
raise auth_module.jwt.PyJWTError("invalid")

monkeypatch.setattr(auth_module.jwt, "get_unverified_header", reject_header)

try:
with TestClient(app, raise_server_exceptions=False) as client:
for index in range(4):
response = client.get(
"/api/auth/session",
headers={
"Authorization": f"Bearer invalid.jwt.token-{index}",
"Forwarded": f"for=198.51.100.{index}",
"X-Forwarded-For": f"198.51.100.{index}",
},
)
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
finally:
app.dependency_overrides.clear()
app.dependency_overrides.update(original_overrides)
auth_module._session_auth_failure_buckets.clear()

assert decode_attempts == 3


def test_session_auth_aggregate_failure_scopes_are_isolated(monkeypatch):
"""Independent server-observed peer scopes retain independent abuse budgets."""
auth_module._session_auth_failure_buckets.clear()
monkeypatch.setattr(
auth_module,
"SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES",
1,
)
verification_attempts: list[str] = []

def reject_token(token: str):
verification_attempts.append(token)
raise auth_module._authentication_error()

monkeypatch.setattr(auth_module, "_verify_signed_session_token", reject_token)

with pytest.raises(auth_module.HTTPException):
auth_module._verify_signed_session_payload(
"Bearer invalid-a-1",
failure_scope="peer:scope-a",
)
with pytest.raises(auth_module.HTTPException):
auth_module._verify_signed_session_payload(
"Bearer invalid-a-2",
failure_scope="peer:scope-a",
)
with pytest.raises(auth_module.HTTPException):
auth_module._verify_signed_session_payload(
"Bearer invalid-b-1",
failure_scope="peer:scope-b",
)

assert verification_attempts == ["invalid-a-1", "invalid-b-1"]


def test_valid_session_does_not_reset_aggregate_failure_scope(monkeypatch):
"""A valid bearer token cannot reset the coarse peer abuse-control budget."""
auth_module._session_auth_failure_buckets.clear()
monkeypatch.setattr(
auth_module,
"SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES",
2,
)
verification_attempts: list[str] = []

def verify_token(token: str):
verification_attempts.append(token)
if token == "valid-token":
return {"role": "member"}, "hmac"
raise auth_module._authentication_error()

monkeypatch.setattr(auth_module, "_verify_signed_session_token", verify_token)

with pytest.raises(auth_module.HTTPException):
auth_module._verify_signed_session_payload(
"Bearer invalid-1",
failure_scope="peer:shared",
)
payload, verifier = auth_module._verify_signed_session_payload(
"Bearer valid-token",
failure_scope="peer:shared",
)
with pytest.raises(auth_module.HTTPException):
auth_module._verify_signed_session_payload(
"Bearer invalid-2",
failure_scope="peer:shared",
)
with pytest.raises(auth_module.HTTPException):
auth_module._verify_signed_session_payload(
"Bearer invalid-3",
failure_scope="peer:shared",
)

assert payload == {"role": "member"}
assert verifier == "hmac"
assert verification_attempts == ["invalid-1", "valid-token", "invalid-2"]


def test_direct_auth_context_keeps_exact_token_only_failure_budget(monkeypatch):
"""The non-HTTP verifier stays independent of peer-scoped throttling."""
auth_module._session_auth_failure_buckets.clear()
monkeypatch.setattr(
auth_module,
"SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES",
1,
)
verification_attempts: list[str] = []

def reject_token(token: str):
verification_attempts.append(token)
raise auth_module._authentication_error()

monkeypatch.setattr(auth_module, "_verify_signed_session_token", reject_token)

for token in ("direct-invalid-1", "direct-invalid-2"):
with pytest.raises(auth_module.HTTPException):
auth_module._verify_signed_session_payload(f"Bearer {token}")

assert verification_attempts == ["direct-invalid-1", "direct-invalid-2"]
Loading
Loading