# SPDX-License-Identifier: Apache-2.0 """Tests for API key authentication.""" import pytest from fastapi.testclient import TestClient from unittest.mock import MagicMock # Note: These tests need a mock server setup since the actual server requires models def _mock_request(headers=None): """Create a mock FastAPI request with given headers.""" req = MagicMock() req.headers = headers or {} return req class TestVerifyApiKey: """Tests for verify_api_key function.""" def test_verify_api_key_no_auth_required(self): """Test that no auth is required when api_key is None.""" from omlx.server import verify_api_key, _server_state import asyncio original_key = _server_state.api_key _server_state.api_key = None try: # Should return True without any credentials result = asyncio.run(verify_api_key(request=_mock_request(), credentials=None)) assert result is True finally: _server_state.api_key = original_key def test_verify_api_key_missing_credentials(self): """Test that missing credentials raises 401 when api_key is set.""" from omlx.server import verify_api_key, _server_state from fastapi import HTTPException import asyncio original_key = _server_state.api_key _server_state.api_key = "test-key" try: with pytest.raises(HTTPException) as exc_info: asyncio.run(verify_api_key(request=_mock_request(), credentials=None)) assert exc_info.value.status_code == 401 assert "required" in exc_info.value.detail.lower() finally: _server_state.api_key = original_key def test_verify_api_key_invalid_key(self): """Test that invalid key raises 401.""" from omlx.server import verify_api_key, _server_state from fastapi import HTTPException from fastapi.security import HTTPAuthorizationCredentials import asyncio original_key = _server_state.api_key _server_state.api_key = "correct-key" try: credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="wrong-key") with pytest.raises(HTTPException) as exc_info: asyncio.run(verify_api_key(request=_mock_request(), credentials=credentials)) assert exc_info.value.status_code == 401 assert "invalid" in exc_info.value.detail.lower() finally: _server_state.api_key = original_key def test_verify_api_key_valid_key(self): """Test that valid key passes.""" from omlx.server import verify_api_key, _server_state from fastapi.security import HTTPAuthorizationCredentials import asyncio original_key = _server_state.api_key _server_state.api_key = "correct-key" try: credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="correct-key") result = asyncio.run(verify_api_key(request=_mock_request(), credentials=credentials)) assert result is True finally: _server_state.api_key = original_key class TestXApiKeyHeader: """Tests for x-api-key header authentication (Anthropic SDK compatibility).""" def test_x_api_key_header_accepted(self): """Test that x-api-key header is accepted when no Bearer token.""" from omlx.server import verify_api_key, _server_state import asyncio original_key = _server_state.api_key _server_state.api_key = "correct-key" try: request = _mock_request(headers={"x-api-key": "correct-key"}) result = asyncio.run(verify_api_key(request=request, credentials=None)) assert result is True finally: _server_state.api_key = original_key def test_x_api_key_header_invalid(self): """Test that invalid x-api-key raises 401.""" from omlx.server import verify_api_key, _server_state from fastapi import HTTPException import asyncio original_key = _server_state.api_key _server_state.api_key = "correct-key" try: request = _mock_request(headers={"x-api-key": "wrong-key"}) with pytest.raises(HTTPException) as exc_info: asyncio.run(verify_api_key(request=request, credentials=None)) assert exc_info.value.status_code == 401 assert "invalid" in exc_info.value.detail.lower() finally: _server_state.api_key = original_key def test_bearer_takes_priority_over_x_api_key(self): """Test that Bearer token takes priority when both are present.""" from omlx.server import verify_api_key, _server_state from fastapi.security import HTTPAuthorizationCredentials import asyncio original_key = _server_state.api_key _server_state.api_key = "bearer-key" try: # Bearer has correct key, x-api-key has wrong key request = _mock_request(headers={"x-api-key": "wrong-key"}) credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="bearer-key") result = asyncio.run(verify_api_key(request=request, credentials=credentials)) assert result is True finally: _server_state.api_key = original_key def test_x_api_key_with_sub_keys(self): """Test that x-api-key works with sub keys.""" from omlx.server import verify_api_key, _server_state from omlx.settings import SubKeyEntry import asyncio original_key = _server_state.api_key original_gs = _server_state.global_settings _server_state.api_key = "main-key" mock_gs = MagicMock() mock_gs.auth.sub_keys = [ SubKeyEntry(key="sub-key-1", name="Test Sub Key"), ] mock_gs.auth.skip_api_key_verification = False mock_gs.server.host = "0.0.0.0" _server_state.global_settings = mock_gs try: request = _mock_request(headers={"x-api-key": "sub-key-1"}) result = asyncio.run(verify_api_key(request=request, credentials=None)) assert result is True finally: _server_state.api_key = original_key _server_state.global_settings = original_gs class TestSubKeyVerification: """Tests for sub key API authentication.""" def test_sub_key_accepted_for_api(self): """Test that a sub key is accepted for API authentication.""" from omlx.server import verify_api_key, _server_state from omlx.settings import SubKeyEntry from fastapi.security import HTTPAuthorizationCredentials import asyncio original_key = _server_state.api_key original_gs = _server_state.global_settings _server_state.api_key = "main-key" # Create mock global_settings with sub keys mock_gs = MagicMock() mock_gs.auth.sub_keys = [ SubKeyEntry(key="sub-key-1", name="Test Sub Key"), ] mock_gs.auth.skip_api_key_verification = False mock_gs.server.host = "127.0.0.1" _server_state.global_settings = mock_gs try: credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="sub-key-1") result = asyncio.run(verify_api_key(request=_mock_request(), credentials=credentials)) assert result is True finally: _server_state.api_key = original_key _server_state.global_settings = original_gs def test_invalid_sub_key_rejected(self): """Test that an invalid sub key is rejected.""" from omlx.server import verify_api_key, _server_state from omlx.settings import SubKeyEntry from fastapi.security import HTTPAuthorizationCredentials from fastapi import HTTPException import asyncio original_key = _server_state.api_key original_gs = _server_state.global_settings _server_state.api_key = "main-key" mock_gs = MagicMock() mock_gs.auth.sub_keys = [ SubKeyEntry(key="sub-key-1", name="Test Sub Key"), ] mock_gs.auth.skip_api_key_verification = False mock_gs.server.host = "0.0.0.0" _server_state.global_settings = mock_gs try: credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="wrong-key") with pytest.raises(HTTPException) as exc_info: asyncio.run(verify_api_key(request=_mock_request(), credentials=credentials)) assert exc_info.value.status_code == 401 finally: _server_state.api_key = original_key _server_state.global_settings = original_gs def test_main_key_still_works_for_api(self): """Test that the main key still works for API authentication.""" from omlx.server import verify_api_key, _server_state from omlx.settings import SubKeyEntry from fastapi.security import HTTPAuthorizationCredentials import asyncio original_key = _server_state.api_key original_gs = _server_state.global_settings _server_state.api_key = "main-key" mock_gs = MagicMock() mock_gs.auth.sub_keys = [ SubKeyEntry(key="sub-key-1", name="Test Sub Key"), ] mock_gs.auth.skip_api_key_verification = False mock_gs.server.host = "0.0.0.0" _server_state.global_settings = mock_gs try: credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="main-key") result = asyncio.run(verify_api_key(request=_mock_request(), credentials=credentials)) assert result is True finally: _server_state.api_key = original_key _server_state.global_settings = original_gs class TestSkipApiKeyVerification: """Tests for skip_api_key_verification feature.""" def _make_global_settings(self, host="127.0.0.1", skip=True): from omlx.settings import GlobalSettings, ServerSettings, AuthSettings from dataclasses import dataclass gs = GlobalSettings.__new__(GlobalSettings) gs.server = ServerSettings(host=host) gs.auth = AuthSettings(api_key="test-key", skip_api_key_verification=skip) return gs def test_skip_verification_when_localhost(self): """Skip API key verification when enabled.""" from omlx.server import verify_api_key, _server_state import asyncio original_key = _server_state.api_key original_gs = _server_state.global_settings _server_state.api_key = "test-key" _server_state.global_settings = self._make_global_settings( host="127.0.0.1", skip=True ) try: result = asyncio.run(verify_api_key(request=_mock_request(), credentials=None)) assert result is True finally: _server_state.api_key = original_key _server_state.global_settings = original_gs def test_skip_verification_on_any_host(self): """Skip verification when enabled regardless of host.""" from omlx.server import verify_api_key, _server_state import asyncio original_key = _server_state.api_key original_gs = _server_state.global_settings _server_state.api_key = "test-key" _server_state.global_settings = self._make_global_settings( host="0.0.0.0", skip=True ) try: result = asyncio.run(verify_api_key(request=_mock_request(), credentials=None)) assert result is True finally: _server_state.api_key = original_key _server_state.global_settings = original_gs def test_skip_verification_disabled_by_default(self): """Default skip_api_key_verification is False.""" from omlx.settings import AuthSettings auth = AuthSettings() assert auth.skip_api_key_verification is False class TestAdminAuth: """Tests for admin authentication functions.""" def test_create_session_token(self): """Test session token creation.""" from omlx.admin.auth import create_session_token token = create_session_token() assert token is not None assert isinstance(token, str) assert len(token) > 0 def test_verify_session_token_valid(self): """Test valid session token verification.""" from omlx.admin.auth import create_session_token, verify_session_token token = create_session_token() assert verify_session_token(token) is True def test_verify_session_token_invalid(self): """Test invalid session token verification.""" from omlx.admin.auth import verify_session_token assert verify_session_token("invalid-token") is False def test_verify_session_token_expired(self): """Test expired session token verification.""" from omlx.admin.auth import create_session_token, verify_session_token import time token = create_session_token() # Wait a moment and verify with very short max_age time.sleep(0.1) # With max_age=0, token should be expired after any delay # Note: itsdangerous rounds to nearest second, so we use a small delay assert verify_session_token(token, max_age=-1) is False def test_verify_api_key_constant_time(self): """Test that API key comparison uses constant time.""" from omlx.admin.auth import verify_api_key import secrets server_key = "test-api-key-12345" # Valid key assert verify_api_key("test-api-key-12345", server_key) is True # Invalid key assert verify_api_key("wrong-key", server_key) is False # Empty key assert verify_api_key("", server_key) is False class TestNonAsciiApiKeys: """Regression tests for #1717: non-ASCII keys must yield 401, not 500. secrets.compare_digest raises TypeError for str arguments containing non-ASCII characters, which surfaced as an unhandled 500 on every authenticated endpoint. compare_keys compares UTF-8 bytes instead. """ def test_compare_keys_non_ascii_mismatch(self): """Non-ASCII client key against ASCII server key returns False.""" from omlx.admin.auth import compare_keys assert compare_keys("café-key", "secret123") is False def test_compare_keys_non_ascii_match(self): """Matching non-ASCII keys compare equal.""" from omlx.admin.auth import compare_keys assert compare_keys("clé-secrète-héhé", "clé-secrète-héhé") is True def test_verify_api_key_non_ascii_client_key(self): """verify_api_key must not raise on a non-ASCII client key.""" from omlx.admin.auth import verify_api_key assert verify_api_key("café", "secret123") is False def test_verify_api_key_non_ascii_server_key(self): """A configured non-ASCII key compares without raising. Function-level only: over HTTP, Starlette decodes header values as latin-1, so a client sending UTF-8 non-ASCII bytes will not match a configured non-ASCII key anyway. The point here is no TypeError. """ from omlx.admin.auth import verify_api_key assert verify_api_key("pässwörd", "pässwörd") is True assert verify_api_key("password", "pässwörd") is False def test_compare_keys_lone_surrogate(self): """Lone surrogates (json.loads can produce them) must not raise. Strict UTF-8 encoding rejects lone surrogates, which would revive the 500 on any JSON route that compares keys without validating printability first (delete_sub_key). """ import json from omlx.admin.auth import compare_keys surrogate_key = json.loads('"\\ud800abcd"') assert compare_keys(surrogate_key, "secret123") is False assert compare_keys("secret123", surrogate_key) is False assert compare_keys(surrogate_key, surrogate_key) is True def test_verify_any_api_key_non_ascii_sub_keys(self): """verify_any_api_key must not raise when sub keys are checked.""" from omlx.admin.auth import verify_any_api_key from unittest.mock import MagicMock sub_key = MagicMock() sub_key.key = "sub-key-1" assert verify_any_api_key("café", "main-key", [sub_key]) is False sub_key.key = "clé-única" assert verify_any_api_key("clé-única", "main-key", [sub_key]) is True def test_server_dependency_non_ascii_bearer_returns_401(self): """The server auth dependency turns a non-ASCII bearer into 401, not 500.""" from omlx.server import verify_api_key, _server_state from fastapi import HTTPException from fastapi.security import HTTPAuthorizationCredentials import asyncio original_key = _server_state.api_key _server_state.api_key = "correct-key" try: credentials = HTTPAuthorizationCredentials( scheme="Bearer", credentials="smart-quote-’key’" ) with pytest.raises(HTTPException) as exc_info: asyncio.run( verify_api_key(request=_mock_request(), credentials=credentials) ) assert exc_info.value.status_code == 401 finally: _server_state.api_key = original_key class TestRejectedKeyFingerprint: """Regression tests for #1440 (item 4): a rejected API key must never be logged verbatim. The auth path logs a short, non-reversible SHA-256 fingerprint instead, so operators can correlate repeated bad keys without the secret landing in server.log. """ def test_fingerprint_key_short_hex(self): """fingerprint_key returns 8 lowercase hex characters.""" from omlx.admin.auth import fingerprint_key fp = fingerprint_key("super-secret-key") assert len(fp) == 8 assert all(c in "0123456789abcdef" for c in fp) def test_fingerprint_key_deterministic(self): """The same key always fingerprints to the same value.""" from omlx.admin.auth import fingerprint_key assert fingerprint_key("abc123") == fingerprint_key("abc123") def test_fingerprint_key_does_not_contain_secret(self): """The fingerprint never leaks the raw key material.""" from omlx.admin.auth import fingerprint_key secret = "sk-live-0123456789abcdef" fp = fingerprint_key(secret) assert secret not in fp assert fp not in secret def test_fingerprint_key_distinguishes_keys(self): """Different keys produce different fingerprints.""" from omlx.admin.auth import fingerprint_key assert fingerprint_key("key-a") != fingerprint_key("key-b") def test_fingerprint_key_non_ascii_and_surrogate(self): """fingerprint_key tolerates any str the auth path accepts (no raise). Mirrors compare_keys: non-ASCII and lone surrogates (which json.loads can produce) must fingerprint without a UnicodeEncodeError. """ import json from omlx.admin.auth import fingerprint_key assert len(fingerprint_key("clé-secrète-héhé")) == 8 assert len(fingerprint_key("")) == 8 surrogate_key = json.loads('"\\ud800abcd"') assert len(fingerprint_key(surrogate_key)) == 8 def test_rejected_key_logged_as_fingerprint_not_verbatim(self, caplog): """The auth dependency logs the fingerprint, not the raw rejected key.""" import asyncio import logging from fastapi import HTTPException from fastapi.security import HTTPAuthorizationCredentials from omlx.admin.auth import fingerprint_key from omlx.server import verify_api_key, _server_state original_key = _server_state.api_key _server_state.api_key = "correct-key" bad_key = "sk-leaky-supersecret-0xCAFE" try: credentials = HTTPAuthorizationCredentials( scheme="Bearer", credentials=bad_key ) with caplog.at_level(logging.WARNING, logger="omlx.server"): with pytest.raises(HTTPException) as exc_info: asyncio.run( verify_api_key(request=_mock_request(), credentials=credentials) ) assert exc_info.value.status_code == 401 rejection_logs = "\n".join( r.getMessage() for r in caplog.records if "Rejected API key" in r.getMessage() ) assert rejection_logs, "expected a rejection log line" assert bad_key not in rejection_logs assert fingerprint_key(bad_key) in rejection_logs finally: _server_state.api_key = original_key