""" Tests for the core API endpoints (/auth/api/). These tests cover: - /auth/api/settings - Public settings endpoint - /auth/api/validate - Session validation - /auth/api/forward - Forward auth for reverse proxies - /auth/api/logout - Session logout - /auth/api/user-info - User information - /auth/api/set-session - Set session from bearer token """ import secrets from datetime import UTC, datetime, timedelta from urllib.parse import urlsplit from uuid import UUID import httpx import pytest from paskia import authcode from paskia.authsession import EXPIRES from paskia.db import delete_session from paskia.db.structs import Client from paskia.util import avatar, hostutil, oidjwt from paskia.util.passphrase import generate from tests.conftest import auth_headers, create_test_image_bytes, create_test_session class TestSettingsEndpoint: """Tests for GET /auth/api/settings""" @pytest.mark.asyncio async def test_get_settings_returns_rp_info(self, client: httpx.AsyncClient): """Settings endpoint should return RP configuration.""" response = await client.get("/auth/api/settings") assert response.status_code == 200 data = response.json() assert "rp_id" in data assert "rp_name" in data assert "session_cookie" in data assert data["rp_id"] == "localhost" assert data["rp_name"] == "Test RP" assert data["session_cookie"] == "__Host-paskia" @pytest.mark.asyncio async def test_settings_includes_ui_base_path(self, client: httpx.AsyncClient): """Settings should include UI base path.""" response = await client.get("/auth/api/settings") data = response.json() assert "ui_base_path" in data @pytest.mark.asyncio async def test_openid_configuration_includes_picture_claim( self, client: httpx.AsyncClient ): """Discovery document should advertise picture claim support.""" response = await client.get("/.well-known/openid-configuration") assert response.status_code == 200 assert "picture" in response.json()["claims_supported"] class TestAvatarUrls: """Tests for avatar URL helpers.""" def test_avatar_url_uses_canonical_public_path_in_auth_host_mode( self, tmp_path, monkeypatch ): """Absolute avatar URLs should preserve /auth/api even with an auth host.""" db_root = tmp_path / "test-avatar-db.paskiadb" monkeypatch.setenv("PASKIA_DB", str(db_root)) monkeypatch.setattr( hostutil, "api_url", lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}", ) user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5") path = db_root / "users" / str(test_uuid) / "profile.webp" path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"RIFF1234WEBP") assert avatar.avatar_url(user_uuid) == ( "https://auth.zi.fi/auth/api/user/" "019c6831-84cf-7b88-b66c-c8165890b7c5/profile.webp" ) class TestValidateEndpoint: """Tests for POST /auth/api/validate""" @pytest.mark.asyncio async def test_validate_without_auth_returns_401(self, client: httpx.AsyncClient): """Validate without session should return 401.""" response = await client.post("/auth/api/validate") assert response.status_code == 401 @pytest.mark.asyncio async def test_validate_with_invalid_token_returns_error( self, client: httpx.AsyncClient ): """Validate with invalid token should return 4xx error.""" response = await client.post( "/auth/api/validate", headers=auth_headers("invalid_token!!"), ) # Invalid token format returns 400, expired/missing returns 401 assert response.status_code in (400, 401) @pytest.mark.asyncio async def test_validate_with_valid_token_returns_200( self, client: httpx.AsyncClient, session_token: str ): """Validate with valid session should return success.""" response = await client.post( "/auth/api/validate", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert data["valid"] is True assert "ctx" in data assert "user" in data["ctx"] assert "uuid" in data["ctx"]["user"] @pytest.mark.asyncio async def test_validate_with_permission_check( self, client: httpx.AsyncClient, session_token: str ): """Validate should check permissions when provided.""" # Admin user should pass admin permission check response = await client.post( "/auth/api/validate?perm=auth:admin", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @pytest.mark.asyncio async def test_validate_permission_denied_for_regular_user( self, client: httpx.AsyncClient, regular_session_token: str ): """Regular user should fail admin permission check.""" response = await client.post( "/auth/api/validate?perm=auth:admin", headers={ **auth_headers(regular_session_token), "Host": "localhost:4401", }, ) assert response.status_code == 403 class TestForwardEndpoint: """Tests for GET /auth/api/forward (reverse proxy auth)""" @pytest.mark.asyncio async def test_forward_without_auth_returns_401(self, client: httpx.AsyncClient): """Forward auth without session should return 401.""" response = await client.get("/auth/api/forward") assert response.status_code == 401 @pytest.mark.asyncio async def test_forward_401_json_response(self, client: httpx.AsyncClient): """Forward auth 401 should include auth iframe info for JSON clients.""" response = await client.get( "/auth/api/forward", headers={"Accept": "application/json"}, ) assert response.status_code == 401 data = response.json() assert "auth" in data assert "iframe" in data["auth"] assert "mode" in data["auth"] assert data["auth"]["mode"] == "login" @pytest.mark.asyncio async def test_forward_with_valid_session_returns_204( self, client: httpx.AsyncClient, session_token: str ): """Forward auth with valid session should return 204 with headers.""" response = await client.get( "/auth/api/forward", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 204 # Check Remote-* headers assert "Remote-User" in response.headers assert "Remote-Name" in response.headers assert "Remote-Groups" in response.headers assert "Remote-Org" in response.headers @pytest.mark.asyncio async def test_forward_with_permission_returns_204( self, client: httpx.AsyncClient, session_token: str ): """Forward auth with valid permission should return 204.""" response = await client.get( "/auth/api/forward?perm=auth:admin", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 204 @pytest.mark.asyncio async def test_forward_permission_denied_returns_403( self, client: httpx.AsyncClient, regular_session_token: str ): """Forward auth with missing permission should return 403.""" response = await client.get( "/auth/api/forward?perm=auth:admin", headers={ **auth_headers(regular_session_token), "Host": "localhost:4401", }, ) assert response.status_code == 403 @pytest.mark.asyncio async def test_forward_403_json_includes_forbidden_mode( self, client: httpx.AsyncClient, regular_session_token: str ): """403 response should include forbidden mode for iframe.""" response = await client.get( "/auth/api/forward?perm=auth:admin", headers={ **auth_headers(regular_session_token), "Host": "localhost:4401", "Accept": "application/json", }, ) assert response.status_code == 403 data = response.json() assert "auth" in data assert data["auth"]["mode"] == "forbidden" class TestLogoutEndpoint: """Tests for POST /auth/api/logout""" @pytest.mark.asyncio async def test_logout_without_session_returns_message( self, client: httpx.AsyncClient ): """Logout without session should return already logged out message.""" response = await client.post("/auth/api/logout") assert response.status_code == 200 data = response.json() assert "message" in data assert "Already logged out" in data["message"] @pytest.mark.asyncio async def test_logout_with_valid_session( self, client: httpx.AsyncClient, session_token: str ): """Logout with valid session should succeed and clear session.""" response = await client.post( "/auth/api/logout", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert "Logged out successfully" in data["message"] # Verify session is no longer valid response2 = await client.post( "/auth/api/validate", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response2.status_code == 401 class TestUserInfoEndpoint: """Tests for GET /auth/api/user-info""" @pytest.mark.asyncio async def test_user_info_without_auth_returns_401(self, client: httpx.AsyncClient): """User info without session should return 401.""" response = await client.get("/auth/api/user-info") assert response.status_code == 401 @pytest.mark.asyncio async def test_user_info_with_valid_session( self, client: httpx.AsyncClient, session_token: str, test_user ): """User info with valid session should return user data.""" response = await client.get( "/auth/api/user-info", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert "user" in data assert data["user"]["uuid"] == str(test_user.uuid) assert data["user"]["display_name"] == test_user.display_name @pytest.mark.asyncio async def test_user_info_includes_credentials( self, client: httpx.AsyncClient, session_token: str ): """User info should include user's credentials.""" response = await client.get( "/auth/api/user-info", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert "credentials" in data assert len(data["credentials"]) >= 1 @pytest.mark.asyncio async def test_user_info_includes_sessions( self, client: httpx.AsyncClient, session_token: str ): """User info should include user's active sessions.""" response = await client.get( "/auth/api/user-info", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert "sessions" in data assert len(data["sessions"]) >= 1 @pytest.mark.asyncio async def test_user_info_includes_permissions( self, client: httpx.AsyncClient, session_token: str ): """User info should include user's permissions.""" response = await client.get( "/auth/api/user-info", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert "permissions" in data @pytest.mark.asyncio async def test_user_info_includes_avatar_url( self, client: httpx.AsyncClient, session_token: str, test_user, tmp_path, monkeypatch, ): """User info should include the canonical avatar URL when present.""" monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) upload = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert upload.status_code == 200 response = await client.get( "/auth/api/user-info", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() avatar_url = data["user"]["avatar_url"] parts = urlsplit(avatar_url) assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp") assert parts.query == "" @pytest.mark.asyncio async def test_avatar_route_returns_304_for_matching_etag( self, client: httpx.AsyncClient, session_token: str, test_user, tmp_path, monkeypatch, ): """Avatar route should honor If-None-Match for unchanged avatars.""" monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) upload = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert upload.status_code == 200 parts = urlsplit(upload.json()["avatar_url"]) first = await client.get(parts.path, headers={"Host": "localhost:4401"}) assert first.status_code == 200 response = await client.get( f"/auth/api/user/{test_user.uuid}/profile.webp", headers={ "Host": "localhost:4401", "If-None-Match": first.headers["etag"], }, ) assert response.status_code == 304 assert response.headers["etag"] == first.headers["etag"] class TestOidcUserInfoEndpoint: """Tests for OIDC userinfo metadata relevant to avatars.""" @pytest.mark.asyncio async def test_userinfo_includes_picture_claim( self, client: httpx.AsyncClient, test_db, session_token: str, test_user, tmp_path, monkeypatch, ): """OIDC userinfo should expose picture when profile scope is granted.""" monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb")) upload = await client.put( f"/auth/api/user/{test_user.uuid}/profile.webp", files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert upload.status_code == 200 avatar_url = upload.json()["avatar_url"] oidc_client, _secret = Client.create( name="Test Client", redirect_uris=["https://client.example/callback"], client_secret="topsecret", ) store = test_db._store if store is None: raise RuntimeError("Test DB store is not initialized") with store.transaction("create_test_oidc_client"): test_db.oidc.clients[oidc_client.uuid] = oidc_client access_token = oidjwt.create_access_token( issuer="http://localhost:4401", subject=test_user.uuid, audience=str(oidc_client.uuid), scope="openid profile", ) response = await client.get( "/auth/oidc/userinfo", headers={ "Authorization": f"Bearer {access_token}", "Host": "localhost:4401", }, ) assert response.status_code == 200 data = response.json() assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path assert data["picture"].startswith("http") class TestSetSessionEndpoint: """Tests for POST /auth/api/set-session""" @pytest.mark.asyncio async def test_set_session_without_bearer_returns_400( self, client: httpx.AsyncClient ): """Set session without bearer token should return 400.""" response = await client.post("/auth/api/set-session") assert response.status_code == 400 @pytest.mark.asyncio async def test_set_session_with_valid_bearer_token( self, client: httpx.AsyncClient, session_token: str ): """Set session with valid auth code as bearer should set cookie.""" code = authcode.store_cookie( authcode.CookieCode( session_key=session_token, created=datetime.now(UTC), ) ) response = await client.post( "/auth/api/set-session", headers={ "Authorization": f"Bearer {code}", "Host": "localhost:4401", }, ) assert response.status_code == 200 data = response.json() assert "user" in data # Check that Set-Cookie header is present assert "set-cookie" in response.headers class TestErrorHandling: """Tests for API error handling""" @pytest.mark.asyncio async def test_invalid_endpoint_returns_404(self, client: httpx.AsyncClient): """Request to non-existent endpoint should return 404.""" response = await client.get("/auth/api/nonexistent") assert response.status_code == 404 @pytest.mark.asyncio async def test_error_response_on_bad_token(self, client: httpx.AsyncClient): """Bad token should return error response.""" response = await client.post( "/auth/api/validate", headers=auth_headers("expired_token!"), ) # Malformed token returns 400, expired returns 401 assert response.status_code in (400, 401) class TestForwardAuthHtmlResponse: """Tests for forward auth HTML responses""" @pytest.mark.asyncio async def test_forward_with_expired_session_clears_cookie( self, client: httpx.AsyncClient ): """Forward auth with expired session should trigger clear_session path.""" # Use a well-formed but non-existent session token fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters response = await client.get( "/auth/api/forward", headers={ **auth_headers(fake_token), "Host": "localhost:4401", "Accept": "application/json", }, ) assert response.status_code == 401 # Verify the response contains auth info for re-login data = response.json() assert "auth" in data assert data["auth"]["mode"] == "login" class TestTokenInfoEndpoint: """Tests for token-info endpoint with reset tokens""" @pytest.mark.asyncio async def test_token_info_with_invalid_token(self, client: httpx.AsyncClient): """Token info with invalid token format should return 400.""" response = await client.get( "/auth/api/token-info", headers={"Authorization": "Bearer invalid-token-format"}, ) assert response.status_code == 400 @pytest.mark.asyncio async def test_token_info_with_nonexistent_token(self, client: httpx.AsyncClient): """Token info with well-formed but non-existent token should return 401.""" fake_token = generate() response = await client.get( "/auth/api/token-info", headers={"Authorization": f"Bearer {fake_token}"}, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_token_info_with_valid_token( self, client: httpx.AsyncClient, reset_token: str, test_user ): """Token info with valid reset token should return token type and display name.""" response = await client.get( "/auth/api/token-info", headers={"Authorization": f"Bearer {reset_token}"}, ) assert response.status_code == 200 data = response.json() assert "token_type" in data assert "display_name" in data assert data["display_name"] == test_user.display_name class TestSetSessionErrors: """Tests for set-session error cases""" @pytest.mark.asyncio async def test_set_session_with_invalid_bearer_token( self, client: httpx.AsyncClient ): """Set session with invalid (malformed) bearer token should return 401.""" response = await client.post( "/auth/api/set-session", headers={ "Authorization": "Bearer invalid_token_here", # Wrong length (18 chars) "Host": "localhost:4401", }, ) # Invalid token returns 401 (session not found) assert response.status_code == 401 @pytest.mark.asyncio async def test_set_session_with_nonexistent_token(self, client: httpx.AsyncClient): """Set session with valid format but non-existent token should fail.""" # Use a well-formed 16-char token that doesn't exist in DB fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters response = await client.post( "/auth/api/set-session", headers={ "Authorization": f"Bearer {fake_token}", "Host": "localhost:4401", }, ) # Non-existent session returns 401 (session expired) assert response.status_code == 401 class TestValidateSessionRefresh: """Tests for session refresh behavior in validate endpoint""" @pytest.mark.asyncio async def test_validate_does_not_refresh_within_interval( self, client: httpx.AsyncClient, session_token: str ): """Validate should not refresh session if within refresh interval.""" # First call - may or may not refresh depending on session age response1 = await client.post( "/auth/api/validate", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response1.status_code == 200 # Second call immediately after - should NOT refresh (within 5 min interval) response2 = await client.post( "/auth/api/validate", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response2.status_code == 200 data = response2.json() # Session shouldn't be renewed since we're within the refresh interval assert data["renewed"] is False @pytest.mark.asyncio async def test_validate_with_expired_session_during_refresh( self, client: httpx.AsyncClient, test_db ): """Validate should handle session expiry during refresh attempt.""" # Create a token but don't create a session for it token = secrets.token_urlsafe(12) response = await client.post( "/auth/api/validate", headers={**auth_headers(token), "Host": "localhost:4401"}, ) # Should return 401 for non-existent session assert response.status_code == 401 @pytest.mark.asyncio async def test_validate_session_refresh_fails_concurrent_logout( self, client: httpx.AsyncClient, test_db, test_user, test_credential, ): """Validate should return 401 if session disappears during refresh.""" # Create a session with a short remaining duration to trigger refresh db_key, secret = create_test_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, host="localhost", ip="127.0.0.1", user_agent="pytest", duration=EXPIRES - timedelta(minutes=10), ) # Delete the session right before validate tries to refresh delete_session(db_key) response = await client.post( "/auth/api/validate", headers={**auth_headers(secret), "Host": "localhost:4401"}, ) # Session was found initially but disappeared during refresh assert response.status_code == 401 class TestForwardAuthMaxAge: """Tests for forward auth max_age parameter""" @pytest.mark.asyncio async def test_forward_with_max_age_recent_auth( self, client: httpx.AsyncClient, session_token: str ): """Forward auth with max_age should pass for recent authentication.""" response = await client.get( "/auth/api/forward?max_age=1h", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) # Recently authenticated session should pass assert response.status_code == 204 @pytest.mark.asyncio async def test_forward_with_invalid_max_age_format( self, client: httpx.AsyncClient, session_token: str ): """Forward auth with invalid max_age format should log warning but succeed.""" response = await client.get( "/auth/api/forward?max_age=invalid", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) # Invalid format is logged but request proceeds assert response.status_code == 204 class TestValidateWithMaxAge: """Tests for validate endpoint with max_age parameter""" @pytest.mark.asyncio async def test_validate_with_max_age( self, client: httpx.AsyncClient, session_token: str ): """Validate with max_age should check authentication age.""" response = await client.post( "/auth/api/validate?max_age=1h", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) # This exercises the max_age path - but isn't defined in validate # Actually validate doesn't have max_age - this tests that unknown params are ignored assert response.status_code == 200