Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Binary file modified .coverage
Binary file not shown.
155 changes: 154 additions & 1 deletion coverage.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" ?>
<coverage version="7.10.4" timestamp="1755646992301" lines-valid="3" lines-covered="3" line-rate="1" branches-valid="0" branches-covered="0" branch-rate="1" complexity="0">
<coverage version="7.10.4" timestamp="1755783599582" lines-valid="103" lines-covered="103" line-rate="1" branches-valid="4" branches-covered="4" branch-rate="1" complexity="0">
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.10.4 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources>
Expand All @@ -11,9 +11,162 @@
<class name="__init__.py" filename="__init__.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="8" hits="1"/>
</lines>
</class>
<class name="_version.py" filename="_version.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
</lines>
</class>
<class name="config.py" filename="config.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
</lines>
</class>
<class name="errors.py" filename="errors.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="10" hits="1"/>
<line number="14" hits="1"/>
<line number="18" hits="1"/>
<line number="21" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="41" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="auth" line-rate="1" branch-rate="1" complexity="0">
<classes>
<class name="__init__.py" filename="auth/__init__.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="7" hits="1"/>
</lines>
</class>
<class name="basic.py" filename="auth/basic.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="17" hits="1"/>
<line number="20" hits="1"/>
<line number="27" hits="1"/>
<line number="30" hits="1"/>
<line number="32" hits="1"/>
<line number="39" hits="1"/>
<line number="41" hits="1"/>
<line number="48" hits="1"/>
</lines>
</class>
<class name="bearer.py" filename="auth/bearer.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="5" hits="1"/>
<line number="7" hits="1"/>
<line number="54" hits="1"/>
<line number="57" hits="1"/>
<line number="64" hits="1"/>
<line number="66" hits="1"/>
<line number="73" hits="1"/>
<line number="74" hits="1"/>
<line number="75" hits="1"/>
<line number="77" hits="1"/>
<line number="84" hits="1"/>
<line number="85" hits="1"/>
<line number="86" hits="1"/>
<line number="87" hits="1"/>
</lines>
</class>
<class name="providers.py" filename="auth/providers.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="12" hits="1"/>
<line number="32" hits="1"/>
<line number="38" hits="1"/>
<line number="42" hits="1"/>
<line number="43" hits="1"/>
<line number="45" hits="1"/>
<line number="46" hits="1"/>
<line number="49" hits="1"/>
<line number="55" hits="1"/>
<line number="56" hits="1"/>
<line number="57" hits="1"/>
<line number="58" hits="1"/>
<line number="60" hits="1"/>
<line number="67" hits="1"/>
<line number="68" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="69" hits="1"/>
<line number="70" hits="1"/>
<line number="71" hits="1"/>
<line number="73" hits="1"/>
<line number="80" hits="1"/>
<line number="81" hits="1"/>
<line number="82" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="checkers" line-rate="1" branch-rate="1" complexity="0">
<classes>
<class name="__init__.py" filename="checkers/__init__.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="5" hits="1"/>
</lines>
</class>
<class name="_pin.py" filename="checkers/_pin.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
</lines>
</class>
</classes>
Expand Down
48 changes: 48 additions & 0 deletions gavaconnect/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Authentication Design — `gavaconnect` Python SDK

## Introduction

The SDK implements authentication as a **pluggable policy** so each endpoint family (`checkers`, `tax`, `payments`, `authorization`) can use the scheme it requires while sharing a common transport layer. The SDK supports:

* **Basic** (static header from `client_id:client_secret`)
* **Bearer** (OAuth2 Client Credentials) with **concurrency-safe caching**, **early refresh**, and **401-triggered single retry**

Design goals: **credential isolation per resource**, **safe token lifecycle**, **consistent retries/timeouts**, and **extensibility** (e.g., API-Key, HMAC, mTLS) without changing call sites.

---

## High-Level Architecture

* Each resource client is constructed with an **`AuthPolicy`**: `BasicAuthPolicy` or `BearerAuthPolicy(TokenProvider)`.
* The shared **AsyncTransport**:

* Calls `authorize(request)` before send.
* On **401**, calls `on_unauthorized()` (Bearer refresh), then **retries once**.
* Applies **timeouts** and **retry/backoff** for **429/5xx** (honors `Retry-After`).
* Hooks provide **logging** (with redaction) and **OpenTelemetry** spans.

```mermaid
flowchart LR
A[Your code] -->|calls| R[Resource Client (e.g., payments)]
R -->|build request| T[AsyncTransport]
T -->|authorize(request)| AP[AuthPolicy<br/>Basic or Bearer]
AP -->|add Authorization header| T
T -->|HTTP send| API[(Service API)]
API -- 200/2xx --> T
T -- return --> R --> A

API -- 401 Unauthorized --> T
T -->|on_unauthorized()| AP
AP -->|refresh token (Bearer only)| T
T -->|retry once| API
```

---

## Why Per-Resource Auth?

* **Safety by construction:** Credentials for `payments` cannot be sent to `tax` endpoints (and vice versa). This prevents cross-tenant or scope leakage.
* **Clarity & DX:** The chosen auth scheme is explicit at the resource constructor—no hidden URL regex routing or magic defaults.
* **Heterogeneous schemes:** Some families can remain on **Basic** while others adopt **Bearer** with scopes/rotation, without affecting call sites.
* **Testability:** You can unit-test each resource with its auth policy, mock token refresh, and assert no credential cross-talk.
* **Compliance & least privilege:** Bind the **minimal** credentials/scopes to only the endpoints that require them, simplifying audits and rotation.
13 changes: 13 additions & 0 deletions gavaconnect/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Authentication module for GavaConnect SDK."""

from .basic import BasicAuthPolicy, BasicCredentials
from .bearer import BearerAuthPolicy, TokenProvider
from .providers import ClientCredentialsProvider

__all__ = [
"BasicAuthPolicy",
"BasicCredentials",
"BearerAuthPolicy",
"TokenProvider",
"ClientCredentialsProvider",
]
48 changes: 48 additions & 0 deletions gavaconnect/auth/basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Basic authentication implementation for GavaConnect SDK."""

import base64
from dataclasses import dataclass

import httpx


@dataclass(frozen=True, slots=True)
class BasicCredentials:
"""Basic authentication credentials."""

client_id: str
client_secret: str


class BasicAuthPolicy:
"""HTTP Basic authentication policy."""

def __init__(self, creds: BasicCredentials) -> None:
"""Initialize the basic auth policy.

Args:
creds: Basic authentication credentials.

"""
token = base64.b64encode(
f"{creds.client_id}:{creds.client_secret}".encode()
).decode()
self._header = f"Basic {token}"

async def authorize(self, request: httpx.Request) -> None:
"""Add basic authentication header to the request.

Args:
request: The HTTP request to authorize.

"""
request.headers["authorization"] = self._header

async def on_unauthorized(self) -> bool:
"""Handle unauthorized response.

Returns:
False, as basic auth cannot refresh credentials.

"""
return False
87 changes: 87 additions & 0 deletions gavaconnect/auth/bearer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Bearer token authentication implementation for GavaConnect SDK."""

from __future__ import annotations

from typing import Protocol

import httpx


class AuthPolicy(Protocol):
"""Protocol for authentication policies."""

async def authorize(self, request: httpx.Request) -> None:
"""Add authentication to the request.

Args:
request: The HTTP request to authorize.

"""
...

async def on_unauthorized(self) -> bool:
"""Handle unauthorized response.

Returns:
True if authentication was refreshed, False otherwise.

"""
return False


class TokenProvider(Protocol):
"""Protocol for token providers."""

async def get_token(self) -> str:
"""Get the current access token.

Returns:
The access token.

"""
...

async def refresh(self) -> str:
"""Refresh and return a new access token.

Returns:
The new access token.

"""
...


class BearerAuthPolicy:
"""Bearer token authentication policy."""

def __init__(self, provider: TokenProvider) -> None:
"""Initialize the bearer auth policy.

Args:
provider: Token provider for obtaining access tokens.

"""
self._p, self._last = provider, ""

async def authorize(self, request: httpx.Request) -> None:
"""Add bearer token to the request.

Args:
request: The HTTP request to authorize.

"""
token = await self._p.get_token()
self._last = token
request.headers["authorization"] = f"Bearer {token}"

async def on_unauthorized(self) -> bool:
"""Handle unauthorized response by refreshing the token.

Returns:
True if the token was refreshed, False otherwise.

"""
new_token = await self._p.refresh()
changed = new_token != self._last
self._last = new_token
return changed
Loading
Loading