diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9d2cbba18..e2188f5bf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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 diff --git a/backend/api/auth.py b/backend/api/auth.py index bd188351c..06dfa8dac 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -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 ( @@ -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]] = {} @@ -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) @@ -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, }, ) @@ -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 @@ -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, @@ -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 @@ -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() diff --git a/backend/services/imap_worker.py b/backend/services/imap_worker.py index d618f1d3c..6a396ce29 100644 --- a/backend/services/imap_worker.py +++ b/backend/services/imap_worker.py @@ -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( diff --git a/backend/tests/test_auth_http_rate_limit.py b/backend/tests/test_auth_http_rate_limit.py new file mode 100644 index 000000000..7e6abcb49 --- /dev/null +++ b/backend/tests/test_auth_http_rate_limit.py @@ -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"] diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 11450683e..10606968c 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -946,14 +946,14 @@ def close(self) -> None: @pytest.mark.asyncio -async def test_signed_bearer_session_with_oidc(monkeypatch): +async def test_keyverse_oidc_session_with_verified_claims(monkeypatch): import jwt previous_issuer_url = settings.OIDC_ISSUER_URL previous_client_id = settings.OIDC_CLIENT_ID previous_secret = settings.AUTH_SESSION_HMAC_SECRET - settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon" - settings.OIDC_CLIENT_ID = "naruon-api" + settings.OIDC_ISSUER_URL = "https://keyverse.example.test/realms/cwl" + settings.OIDC_CLIENT_ID = "naruon-web" settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) class MockKey: @@ -970,14 +970,15 @@ def mock_jwt_decode(*args, **kwargs): decode_algorithms.append(list(kwargs["algorithms"])) decode_options.append(dict(kwargs["options"])) return { - "iss": "https://login.example.test/realms/naruon", - "aud": "naruon-api", + "iss": "https://keyverse.example.test/realms/cwl", + "aud": "naruon-web", "sub": "alice", "role": "member", "org": "org-acme", "groups": ["group-1", "group-2"], "workspace": "workspace-org-acme", "exp": int(time.time()) + 300, + "iat": int(time.time()), "_session_verifier": "hmac", } @@ -1000,10 +1001,57 @@ def mock_jwt_decode(*args, **kwargs): assert context.session_verifier == "oidc" assert decode_algorithms == [["RS256"]] assert decode_options == [ - {"require": ("exp", "iss", "aud"), "verify_signature": True} + {"require": ("exp", "iss", "aud", "iat"), "verify_signature": True} ] +@pytest.mark.asyncio +async def test_keyverse_oidc_session_rejects_missing_issued_at(monkeypatch): + import jwt + + previous_issuer_url = settings.OIDC_ISSUER_URL + previous_client_id = settings.OIDC_CLIENT_ID + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + settings.OIDC_ISSUER_URL = "https://keyverse.example.test/realms/cwl" + settings.OIDC_CLIENT_ID = "naruon-web" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + + class MockKey: + key_id = "keyverse-key" + key = "public_key" + + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (MockKey(),)) + + def mock_jwt_decode(*args, **kwargs): + return { + "iss": "https://keyverse.example.test/realms/cwl", + "aud": "naruon-web", + "sub": "alice", + "role": "member", + "org": "org-acme", + "groups": ["group-1"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + } + + monkeypatch.setattr(jwt, "decode", mock_jwt_decode) + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": "JWT", "kid": "keyverse-key"}, + ) + + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + settings.OIDC_ISSUER_URL = previous_issuer_url + settings.OIDC_CLIENT_ID = previous_client_id + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + + assert exc.value.status_code == 401 + + @pytest.mark.asyncio async def test_oidc_session_accepts_tuple_audience(monkeypatch): import jwt @@ -1032,6 +1080,7 @@ def mock_jwt_decode(*args, **kwargs): "groups": ["group-1", "group-2"], "workspace": "workspace-org-acme", "exp": int(time.time()) + 300, + "iat": int(time.time()), } monkeypatch.setattr(jwt, "decode", mock_jwt_decode) diff --git a/backend/tests/test_imap_worker.py b/backend/tests/test_imap_worker.py index d2798719a..410f487e6 100644 --- a/backend/tests/test_imap_worker.py +++ b/backend/tests/test_imap_worker.py @@ -27,6 +27,29 @@ def fail_connect(*args, **kwargs): assert connection_attempts == [] +@pytest.mark.asyncio +async def test_imap_worker_fetch_rejects_explicit_disallowed_destination(monkeypatch): + """Explicit fetch overrides must pass mail egress policy before networking.""" + worker = ImapSyncWorker() + config = TenantConfig( + user_id="testuser", + imap_server="imap.example.com", + imap_port=993, + ) + connection_attempts = [] + + def fail_connect(*args, **kwargs): + connection_attempts.append((args, kwargs)) + raise AssertionError("IMAP connection must not open before policy validation") + + monkeypatch.setattr("services.imap_worker.aioimaplib.IMAP4_SSL", fail_connect) + + with pytest.raises(ValueError, match="IMAP server is not allowed"): + await worker._fetch_messages(config, "127.0.0.1", 993) + + assert connection_attempts == [] + + @pytest.mark.asyncio async def test_imap_worker_sync_tenant_raises_when_connection_fails(monkeypatch): worker = ImapSyncWorker() diff --git a/docs/doctoring/http-session-throttling.md b/docs/doctoring/http-session-throttling.md new file mode 100644 index 000000000..de187f5fb --- /dev/null +++ b/docs/doctoring/http-session-throttling.md @@ -0,0 +1,41 @@ +# HTTP invalid-session throttling evidence + +## Decision + +Naruon's HTTP authentication boundary applies two bounded failed-verification budgets: + +1. an exact-token budget keyed by a SHA-256 digest of the bearer token; and +2. a coarser peer budget keyed by a SHA-256 digest of the server-observed ASGI `request.client.host` value. + +The application does not derive the peer budget from `Forwarded`, `X-Forwarded-For`, or other caller-controlled forwarding headers. Deployments behind a trusted reverse proxy must configure the proxy/server boundary so the ASGI client address has the intended operational meaning. The peer budget is deliberately looser than the exact-token budget because NAT gateways and reverse proxies can make many legitimate users share one observed peer. + +A failed HTTP verification increments both budgets. A successful verification clears only the exact-token failure bucket. The peer bucket expires naturally and is not reset by possession of a valid token, preventing a valid session from becoming an attacker-controlled reset primitive. Direct non-HTTP `build_auth_context()` calls retain only the exact-token budget because no trustworthy HTTP peer scope exists at that boundary. + +Both key families share the existing bounded in-memory bucket store, expiry window, and capacity limit. This coarse peer signal is defense in depth against varying invalid tokens; it is not a substitute for subscriber/authenticator-specific controls, identity-provider throttling, network perimeter controls, or tenant authorization. + +## Evidence and interpretation + +NIST SP 800-63B-4 requires verifiers to implement controls against online guessing and explicitly identifies IP address, geolocation, timing, and browser metadata as signals that can inform adaptive protections. Naruon uses the server-observed peer address only as one coarse abuse signal and keeps its existing cryptographic verification and exact-token budget. Because this peer signal can represent multiple users behind NAT or a reverse proxy, the product applies a higher threshold and bounded expiry rather than treating the address as subscriber identity. + +RFC 7519 defines JWT bearer-token claims and NumericDate semantics but does not make unsigned or unverified token contents trustworthy identity. Naruon therefore does not use an unverified `sub`, `iss`, `aud`, or other JWT claim to choose the aggregate throttle identity. OIDC issuer and audience validation remains a separate post-signature trust boundary. + +OpenID Connect Core requires the ID Token issuer to exactly match the issuer identifier and requires the relying party's `client_id` to be present in the `aud` claim; `aud` may be multi-valued. Naruon's Keyverse OIDC path therefore keeps exact issuer validation while accepting the configured client identifier as a member of the verified audience claim. + +## Verification contract + +Regression tests must prove that: + +- varying invalid bearer tokens from one server-observed HTTP peer exhaust one aggregate budget; +- changing `Forwarded` or `X-Forwarded-For` does not create a new application-level peer identity; +- independent trusted peer scopes retain independent budgets; +- a valid token cannot reset the coarse peer failure budget; +- the direct non-HTTP authentication entry point retains its exact-token-only contract; and +- failure-bucket memory remains bounded and time-limited. + +## References + +National Institute of Standards and Technology. (2025). *Digital identity guidelines: Authentication and authenticator management (NIST Special Publication 800-63B-4).* U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-63B-4 + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC 7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +OpenID Foundation. (2014). *OpenID Connect Core 1.0 incorporating errata set 1*. https://openid.net/specs/openid-connect-core-1_0.html diff --git a/docs/operations/auth-key-management.md b/docs/operations/auth-key-management.md index 63b2d59e0..79eac1535 100644 --- a/docs/operations/auth-key-management.md +++ b/docs/operations/auth-key-management.md @@ -5,15 +5,19 @@ - `backend/api/auth.py` no longer accepts public `X-User-*`, `X-Organization-*`, `X-Group-*`, or `X-Dev-Auth-Token` headers as runtime authentication material. -- Runtime authentication accepts only `Authorization: Bearer` compact session - envelopes whose protected header pins `alg=HS256` and whose `header.payload` - signing input is signed with HMAC-SHA256 by the configured - `AUTH_SESSION_HMAC_SECRET`. The secret must be explicitly configured, - high-entropy generated material, and at least 32 bytes. Settings fail at - startup in every runtime mode when this secret is missing, too short, or an - obvious repeated placeholder or known public fixture value; runtime - verification still fails closed with `401 Authentication required` when an - already-loaded configured value becomes absent, weak, or public. +- Runtime authentication accepts `Authorization: Bearer` compact sessions through + two fail-closed verification modes. The internal HMAC session envelope pins its + protected header to `alg=HS256` and signs the `header.payload` input with + HMAC-SHA256 by the configured `AUTH_SESSION_HMAC_SECRET`. When OIDC is + configured, the same bearer boundary also accepts OIDC sessions only after the + configured issuer, client audience membership, JOSE header constraints, JWKS + signature, `iat`, `exp`, and required tenant/role claims are verified as + described below. The HMAC secret must be explicitly configured, high-entropy + generated material, and at least 32 bytes. Settings fail at startup in every + runtime mode when this secret is missing, too short, or an obvious repeated + placeholder or known public fixture value; runtime HMAC verification still + fails closed with `401 Authentication required` when an already-loaded + configured value becomes absent, weak, or public. - The signed session payload is versioned and must include `iss=naruon-control-plane`, `aud=naruon-api`, `sub`, explicit `role`, `workspace`, `exp`, and organization/group scope claims. Tampered, expired, @@ -29,6 +33,17 @@ - Endpoint tests that need fixture identity use explicit FastAPI dependency overrides in `backend/tests/conftest.py`; those test overrides are not the production auth path. +- Failed HTTP bearer verification is bounded twice: an exact-token SHA-256 + bucket and a coarser SHA-256 bucket derived only from the server-observed ASGI + `request.client.host`. Application code ignores `Forwarded` and + `X-Forwarded-For` when selecting this abuse-control scope. The peer budget is + intentionally looser than the exact-token budget because one reverse proxy or + NAT address can represent many legitimate users; both key families share the + existing bounded-capacity, expiring in-memory store. A successful bearer + verification clears only its exact-token bucket and does not reset the coarse + peer budget. Direct non-HTTP `build_auth_context()` calls retain only the + exact-token budget. See `docs/doctoring/http-session-throttling.md` for the + threat, proxy/NAT, reset, bounded-memory, and standards rationale. - `backend/db/models.py` stores OAuth/OpenAI secret fields through an `EncryptedString` type backed by Fernet. - `backend/db/models.py` no longer contains a fallback Fernet key or SHA256 @@ -121,9 +136,20 @@ private-address resolution and DNS-rebinding bypasses. Development HTTP is limited to exact `localhost`, `127.0.0.1`, or `::1` loopback endpoints. - Browser-side OIDC support does not mint local roles. The IdP token must still - satisfy the backend's signed claim contract: verified issuer/audience, subject, - explicit non-platform role, organization, groups, workspace, expiry, and no - unsupported critical headers. + satisfy the backend's signed claim contract: verified issuer equality, a + verified audience claim containing the configured OIDC client ID (including + multi-valued audiences), subject, `iat` and `exp` NumericDate values, explicit + non-platform role, organization, groups, workspace, and no unsupported + critical headers. OpenID Connect Core 1.0 requires exact issuer validation and + requires the relying party's client identifier to be present in `aud`; RFC + 7519 defines `iat` and `exp` as NumericDate claims and the registered JWT claim + semantics. The formal OIDC analysis by Fett, Küsters, and Schmitz (2017) + demonstrates why relying parties must validate issuer/audience and protocol + bindings rather than treating token fields in isolation as sufficient trust. +- For a Keyverse deployment, configure the exact Keyverse issuer and JWKS URL, + the reviewed `naruon-web` audience, and the operator-owned OIDC host allowlist + together. The verified `org`, `workspace`, and `role` claims are inputs to + Naruon's deny-first ABAC/RBAC policy; their presence alone never grants access. ## Keycloak/Casdoor decision path @@ -136,6 +162,24 @@ organization, group/workspace, role, delegation, expiry, and provider/source ownership claims are required before production multi-user access is claimed. +## References + +Fett, D., Küsters, R., & Schmitz, G. (2017). The web SSO standard OpenID Connect: +In-depth formal security analysis and security guidelines. *2017 IEEE 30th +Computer Security Foundations Symposium (CSF)*, 189–202. +https://doi.org/10.1109/CSF.2017.20 + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC +7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +National Institute of Standards and Technology. (2025). *Digital identity +guidelines: Authentication and authenticator management (NIST Special +Publication 800-63B-4).* U.S. Department of Commerce. +https://doi.org/10.6028/NIST.SP.800-63B-4 + +OpenID Foundation. (2014). *OpenID Connect Core 1.0 incorporating errata set 1*. +https://openid.net/specs/openid-connect-core-1_0.html + ## 다음 결정 - Compare Keycloak and Casdoor on OIDC support, operational complexity, admin UX,