diff --git a/.github/policies/resourceManagement.yml b/.github/policies/resourceManagement.yml new file mode 100644 index 0000000..0f7b81e --- /dev/null +++ b/.github/policies/resourceManagement.yml @@ -0,0 +1,101 @@ +id: +name: GitOps.PullRequestIssueManagement +description: GitOps.PullRequestIssueManagement primitive +owner: +resource: repository +disabled: false +where: +configuration: + resourceManagementConfiguration: + scheduledSearches: + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isIssue + - isOpen + - hasLabel: + label: 'Needs: Author Feedback' + - hasLabel: + label: 'Status: No Recent Activity' + - noActivitySince: + days: 3 + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isIssue + - isOpen + - hasLabel: + label: 'Needs: Author Feedback' + - noActivitySince: + days: 4 + - isNotLabeledWith: + label: 'Status: No Recent Activity' + actions: + - addLabel: + label: 'Status: No Recent Activity' + - addReply: + reply: This issue has been automatically marked as stale because it has been marked as requiring author feedback but has not had any activity for **4 days**. It will be closed if no further activity occurs **within 3 days of this comment**. + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isIssue + - isOpen + - hasLabel: + label: 'Resolution: Duplicate' + - noActivitySince: + days: 1 + actions: + - addReply: + reply: This issue has been marked as duplicate and has not had any activity for **1 day**. It will be closed for housekeeping purposes. + - closeIssue + eventResponderTasks: + - if: + - payloadType: Issue_Comment + - isAction: + action: Created + - isActivitySender: + issueAuthor: True + - hasLabel: + label: 'Needs: Author Feedback' + - isOpen + then: + - addLabel: + label: 'Needs: Attention :wave:' + - removeLabel: + label: 'Needs: Author Feedback' + description: + - if: + - payloadType: Issues + - not: + isAction: + action: Closed + - hasLabel: + label: 'Status: No Recent Activity' + then: + - removeLabel: + label: 'Status: No Recent Activity' + description: + - if: + - payloadType: Issue_Comment + - hasLabel: + label: 'Status: No Recent Activity' + then: + - removeLabel: + label: 'Status: No Recent Activity' + description: + - if: + - payloadType: Pull_Request + then: + - inPrLabel: + label: WIP + description: +onFailure: +onSuccess: diff --git a/.github/workflows/build_publish.yml b/.github/workflows/build_publish.yml index 0c18925..990834f 100644 --- a/.github/workflows/build_publish.yml +++ b/.github/workflows/build_publish.yml @@ -15,7 +15,7 @@ jobs: python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -48,7 +48,7 @@ jobs: needs: [build] steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python 3.8 uses: actions/setup-python@v4 with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6b8dcaf..fd49305 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/CHANGELOG.md b/CHANGELOG.md index 286e66d..0a4182e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2023-09-01 + +### Added + +- Added support for continuous access evaluation. + +### Changed + ## [0.5.0] - 2023-07-27 ### Added diff --git a/kiota_http/_version.py b/kiota_http/_version.py index ca2ac7e..cdebff8 100644 --- a/kiota_http/_version.py +++ b/kiota_http/_version.py @@ -1 +1 @@ -VERSION: str = '0.5.0' +VERSION: str = '0.6.0' diff --git a/kiota_http/httpx_request_adapter.py b/kiota_http/httpx_request_adapter.py index dc5ff62..ff85665 100644 --- a/kiota_http/httpx_request_adapter.py +++ b/kiota_http/httpx_request_adapter.py @@ -1,5 +1,6 @@ """HTTPX client request adapter.""" - +import re +from collections.abc import AsyncIterable, Iterable from datetime import datetime from typing import Any, Dict, Generic, List, Optional, TypeVar, Union from urllib import parse @@ -56,6 +57,10 @@ class HttpxRequestAdapter(RequestAdapter, Generic[ModelType]): + CLAIMS_KEY = "claims" + BEARER_AUTHENTICATION_SCHEME = "Bearer" + RESPONSE_AUTH_HEADER = "WWW-Authenticate" + def __init__( self, authentication_provider: AuthenticationProvider, @@ -490,8 +495,19 @@ async def get_http_response_message( _get_http_resp_span = self._start_local_tracing_span( "get_http_response_message", parent_span ) + + async def get_http_response_message( + self, request_info: RequestInformation, claims: str = "" + ) -> httpx.Response: self.set_base_url_for_request_information(request_info) - await self._authentication_provider.authenticate_request(request_info) + + additional_authentication_context = None + if claims: + additional_authentication_context = {self.CLAIMS_KEY: claims} + + await self._authentication_provider.authenticate_request( + request_info, additional_authentication_context + ) request = self.get_request_from_request_information( request_info, _get_http_resp_span, parent_span @@ -507,6 +523,26 @@ async def get_http_response_message( if content_type := resp.headers.get("Content-Type", None): parent_span.set_attribute("http.response_content_type", content_type) _get_http_resp_span.end() + return await self.retry_cae_response_if_required(resp, request_info, claims) + + async def retry_cae_response_if_required( + self, resp: httpx.Response, request_info: RequestInformation, claims: str + ) -> httpx.Response: + if ( + resp.status_code == 401 + and not claims # previous claims exist. Means request has already been retried + and resp.headers.get(self.RESPONSE_AUTH_HEADER) + ): + auth_header_value = resp.headers.get(self.RESPONSE_AUTH_HEADER) + if auth_header_value.casefold().startswith( + self.BEARER_AUTHENTICATION_SCHEME.casefold() + ): + claims_match = re.search('claims="(.+)"', auth_header_value) + if not claims_match: + raise ValueError("Unable to parse claims from response") + response_claims = claims_match.group().split('="')[1] + return await self.get_http_response_message(request_info, response_claims) + return resp return resp def get_response_handler(self, request_info: RequestInformation) -> Any: diff --git a/requirements-dev.txt b/requirements-dev.txt index 7b399e0..752b404 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,15 +2,13 @@ astroid==2.15.6 -asyncmock==0.4.2 - certifi==2023.7.22 charset-normalizer==3.2.0 colorama==0.4.6 -coverage[toml]==7.3.0 +coverage[toml]==7.3.1 dill==0.3.7 @@ -34,7 +32,7 @@ mccabe==0.7.0 mock==5.1.0 -mypy==1.5.0 +mypy==1.5.1 mypy-extensions==1.0.0 @@ -42,11 +40,11 @@ packaging==23.1 platformdirs==3.10.0 -pluggy==1.2.0 +pluggy==1.3.0 pylint==2.17.5 -pytest==7.4.0 +pytest==7.4.2 pytest-asyncio==0.21.1 @@ -74,7 +72,7 @@ wrapt==1.15.0 yapf==0.40.1 -anyio==3.7.1 +anyio==4.0.0 h11==0.14.0 @@ -88,7 +86,7 @@ httpx[http2]==0.25.0 hyperframe==6.0.1 -microsoft-kiota-abstractions==0.7.1 +microsoft-kiota-abstractions==0.8.1 sniffio==1.3.0 diff --git a/tests/conftest.py b/tests/conftest.py index 33043e8..9e2aa52 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,8 +2,9 @@ import httpx import pytest -from asyncmock import AsyncMock +from unittest.mock import AsyncMock from kiota_abstractions.api_error import APIError +from kiota_abstractions.method import Method from kiota_abstractions.authentication import AnonymousAuthenticationProvider from kiota_abstractions.request_information import RequestInformation from opentelemetry import trace @@ -25,7 +26,11 @@ def request_info(): @pytest.fixture def request_info_mock(): - return RequestInformation() + request_info = RequestInformation() + request_info.content = b'Hello World' + request_info.http_method = Method.GET + request_info.url_template = "https://example.com" + return request_info @pytest.fixture @@ -185,10 +190,24 @@ def mock_primitive_response_bytes(mocker): def mock_no_content_response(mocker): return httpx.Response(204, json="Radom JSON", headers={"Content-Type": "application/json"}) - tracer = trace.get_tracer(__name__) @pytest.fixture def mock_otel_span(): return tracer.start_span("mock") + +@pytest.fixture +def mock_cae_failure_response(mocker): + auth_header = """Bearer authorization_uri="https://login.windows.net/common/oauth2/authorize", + client_id="00000003-0000-0000-c000-000000000000", + error="insufficient_claims", + claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwNDEwNjY1MSJ9fX0=" + """ + return httpx.Response( + 401, + headers={ + "Content-Type": "application/json", + "WWW-Authenticate": auth_header + } + ) diff --git a/tests/test_httpx_request_adapter.py b/tests/test_httpx_request_adapter.py index 584a7c9..24c303d 100644 --- a/tests/test_httpx_request_adapter.py +++ b/tests/test_httpx_request_adapter.py @@ -2,7 +2,7 @@ import httpx import pytest -from asyncmock import AsyncMock +from unittest.mock import AsyncMock, call from kiota_abstractions.api_error import APIError from kiota_abstractions.method import Method from kiota_abstractions.native_response_handler import NativeResponseHandler @@ -284,3 +284,15 @@ async def test_observability(request_adapter, request_info, mock_user_response, start_tracing_span.assert_called_once_with(request_info, "send_async") assert final_result.display_name == mock_user.display_name assert not trace.get_current_span().is_recording() + +@pytest.mark.asyncio +async def test_retries_on_cae_failure(request_adapter, request_info_mock, mock_cae_failure_response): + request_adapter._http_client.send = AsyncMock(return_value=mock_cae_failure_response) + request_adapter._authentication_provider.authenticate_request = AsyncMock() + resp = await request_adapter.get_http_response_message(request_info_mock) + assert isinstance(resp, httpx.Response) + calls = [ + call(request_info_mock, None), + call(request_info_mock, {'claims': 'eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwNDEwNjY1MSJ9fX0'}) + ] + request_adapter._authentication_provider.authenticate_request.assert_has_awaits(calls)