diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62aa304 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.venv +venv/ +ENV/ +env/ +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..290d990 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# gnexus-gauth + +Framework-agnostic Python client library for integrating services with `gnexus-auth`. + +## Status + +Alpha — early scaffold with working core pieces. + +Current scaffold includes: + +- package metadata; +- high-level `GAuthClient`; +- configuration object; +- DTO set; +- storage and service contracts; +- exception family; +- PKCE and authorization URL helpers; +- HTTP token endpoint client; +- HTTP userinfo client; +- webhook HMAC verifier; +- webhook JSON parser; +- package-level unit tests; +- plain Python integration example. + +## Requirements + +- Python 3.11+ +- `httpx` + +## Installation + +```bash +pip install gnexus-gauth +``` + +Or from source: + +```bash +pip install git+https://git.gnexus.space/git/root/gnexus-auth-client-py.git +``` + +## Usage Shape + +The package is designed around one high-level client: + +```python +from gnexus_gauth.client import GAuthClient +``` + +It expects: + +- `GAuthConfig` +- token endpoint implementation +- runtime user provider +- webhook verifier +- webhook parser +- state store +- PKCE store + +### Quick Example + +```python +from gnexus_gauth.client import GAuthClient +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.oauth import HttpTokenEndpoint +from gnexus_gauth.runtime import HttpRuntimeUserProvider +from gnexus_gauth.webhook import HmacWebhookVerifier, JsonWebhookParser +from gnexus_gauth.support import InMemoryStateStore, InMemoryPkceStore + +config = GAuthConfig( + base_url="https://gnexus-auth.local", + client_id="my-service", + client_secret="my-secret", + redirect_uri="https://my-service.local/callback", +) + +client = GAuthClient( + config=config, + token_endpoint=HttpTokenEndpoint(config), + runtime_user_provider=HttpRuntimeUserProvider(config), + webhook_verifier=HmacWebhookVerifier(config), + webhook_parser=JsonWebhookParser(), + state_store=InMemoryStateStore(), + pkce_store=InMemoryPkceStore(), +) + +# Build authorization URL +auth_request = client.build_authorization_request( + return_to="/dashboard", + scopes=["openid", "email", "profile"], +) +print(auth_request.authorization_url) + +# Exchange callback code +token_set = client.exchange_authorization_code("code_from_callback", "state_from_callback") +user = client.fetch_user(token_set.access_token) + +# Verify and parse webhook +event = client.verify_and_parse_webhook(raw_body, headers, "webhook_secret") +``` + +See `examples/plain/example.py` for a more complete demonstration. + +## Package Structure + +```text +src/gnexus_gauth/ + __init__.py + client.py # GAuthClient + config.py # GAuthConfig + contracts.py # Abstract interfaces + dto.py # Data transfer objects + exceptions.py # Exception hierarchy + oauth.py # PKCE, URL builder, token endpoint + runtime.py # Userinfo client + support.py # In-memory stores, system clock + webhook.py # HMAC verifier, JSON parser +``` + +## Development + +```bash +# Install with dev dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Run linter +ruff check src tests + +# Run type checker +mypy src +``` + +## Related + +- PHP version: `gnexus/auth-client` +- Auth server: `gnexus-auth` diff --git a/examples/plain/example.py b/examples/plain/example.py new file mode 100644 index 0000000..1b1f257 --- /dev/null +++ b/examples/plain/example.py @@ -0,0 +1,142 @@ +"""Plain Python integration example for gnexus-gauth. + +This example shows: +- redirect to gnexus-auth +- callback exchange +- userinfo fetch +- webhook verification and parsing +""" + +import json +from datetime import datetime, timezone + +from gnexus_gauth.client import GAuthClient +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.contracts import PkceStoreInterface, StateStoreInterface +from gnexus_gauth.exceptions import WebhookVerificationException +from gnexus_gauth.oauth import AuthorizationUrlBuilder, HttpTokenEndpoint, PkceGenerator +from gnexus_gauth.runtime import HttpRuntimeUserProvider +from gnexus_gauth.support import SystemClock +from gnexus_gauth.webhook import HmacWebhookVerifier, JsonWebhookParser + + +class SimpleStateStore(StateStoreInterface): + """Simple in-memory state store for demonstration.""" + + def __init__(self) -> None: + self._items: dict[str, dict] = {} + + def put(self, state: str, expires_at: datetime, context: dict | None = None) -> None: + self._items[state] = {"expires_at": expires_at, "context": context or {}} + + def has(self, state: str) -> bool: + record = self._items.get(state) + if record is None: + return False + if record["expires_at"] < datetime.now(timezone.utc): + del self._items[state] + return False + return True + + def get_context(self, state: str) -> dict: + if not self.has(state): + return {} + return self._items[state].get("context", {}) + + def forget(self, state: str) -> None: + self._items.pop(state, None) + + +class SimplePkceStore(PkceStoreInterface): + """Simple in-memory PKCE store for demonstration.""" + + def __init__(self) -> None: + self._items: dict[str, dict] = {} + + def put(self, state: str, verifier: str, expires_at: datetime) -> None: + self._items[state] = {"verifier": verifier, "expires_at": expires_at} + + def get(self, state: str) -> str | None: + record = self._items.get(state) + if record is None: + return None + if record["expires_at"] < datetime.now(timezone.utc): + del self._items[state] + return None + return record["verifier"] + + def forget(self, state: str) -> None: + self._items.pop(state, None) + + +def make_client() -> GAuthClient: + config = GAuthConfig( + base_url="https://gnexus-auth.local", + client_id="my-service", + client_secret="my-secret", + redirect_uri="https://my-service.local/callback", + ) + + token_endpoint = HttpTokenEndpoint(config) + runtime_provider = HttpRuntimeUserProvider(config) + webhook_verifier = HmacWebhookVerifier(config) + webhook_parser = JsonWebhookParser() + + return GAuthClient( + config=config, + token_endpoint=token_endpoint, + runtime_user_provider=runtime_provider, + webhook_verifier=webhook_verifier, + webhook_parser=webhook_parser, + state_store=SimpleStateStore(), + pkce_store=SimplePkceStore(), + ) + + +def redirect_example() -> None: + client = make_client() + auth_request = client.build_authorization_request( + return_to="/dashboard", + scopes=["openid", "email", "profile", "roles", "permissions"], + ) + print(f"Redirect user to: {auth_request.authorization_url}") + + +def callback_example(code: str, state: str) -> None: + client = make_client() + try: + token_set = client.exchange_authorization_code(code, state) + user = client.fetch_user(token_set.access_token) + except Exception as exc: + print(f"Authorization failed: {exc}") + return + + print(f"User ID: {user.user_id}") + print(f"Email: {user.email}") + print(f"Access token: {token_set.access_token[:10]}...") + + +def webhook_example(raw_body: str, headers: dict, secret: str) -> None: + client = make_client() + try: + event = client.verify_and_parse_webhook(raw_body, headers, secret) + except WebhookVerificationException: + print("Invalid webhook signature.") + return + + print(f"Accepted event: {event.event_type} (id={event.event_id})") + + +if __name__ == "__main__": + # Demonstrate redirect URL generation + redirect_example() + + # Demonstrate webhook handling with a dummy payload + dummy_body = json.dumps({"type": "user.updated", "id": "evt_1"}) + dummy_headers = { + "X-Gnexus-Event-Id": "evt_1", + "X-Gnexus-Event-Type": "user.updated", + "X-Gnexus-Event-Timestamp": "2024-01-01T00:00:00Z", + "X-Gnexus-Signature": "t=1704067200,v1=abc123", + } + webhook_example(dummy_body, dummy_headers, "webhook_secret") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4c69372 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gnexus-gauth" +version = "0.1.0" +description = "Framework-agnostic Python client library for gnexus-auth integrations" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "Proprietary"} +authors = [ + {name = "GNexus"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "httpx>=0.24.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.1.0", + "mypy>=1.0", +] + +[project.urls] +Homepage = "https://git.gnexus.space/git/root/gnexus-auth-client-py" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_return_any = true +warn_unused_ignores = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/gnexus_gauth/__init__.py b/src/gnexus_gauth/__init__.py new file mode 100644 index 0000000..d706a6e --- /dev/null +++ b/src/gnexus_gauth/__init__.py @@ -0,0 +1,47 @@ +"""gnexus-gauth — Python client library for gnexus-auth integrations.""" + +from gnexus_gauth.client import GAuthClient +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.dto import ( + AuthenticatedUser, + AuthorizationRequest, + ClientAccess, + TokenSet, + VerifiedWebhook, + WebhookEvent, +) +from gnexus_gauth.exceptions import ( + ConfigurationException, + GAuthException, + PkceException, + RuntimeApiException, + StateValidationException, + TokenExchangeException, + TokenRefreshException, + TokenRevokeException, + TransportException, + WebhookPayloadException, + WebhookVerificationException, +) + +__all__ = [ + "GAuthClient", + "GAuthConfig", + "TokenSet", + "AuthenticatedUser", + "ClientAccess", + "AuthorizationRequest", + "VerifiedWebhook", + "WebhookEvent", + "GAuthException", + "ConfigurationException", + "StateValidationException", + "PkceException", + "TokenExchangeException", + "TokenRefreshException", + "TokenRevokeException", + "TransportException", + "RuntimeApiException", + "WebhookVerificationException", + "WebhookPayloadException", +] diff --git a/src/gnexus_gauth/client.py b/src/gnexus_gauth/client.py new file mode 100644 index 0000000..3456911 --- /dev/null +++ b/src/gnexus_gauth/client.py @@ -0,0 +1,117 @@ +"""High-level client for gnexus-gauth integrations.""" + +from datetime import datetime, timedelta, timezone + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.contracts import ( + ClockInterface, + PkceStoreInterface, + RuntimeUserProviderInterface, + StateStoreInterface, + TokenEndpointInterface, + WebhookParserInterface, + WebhookVerifierInterface, +) +from gnexus_gauth.dto import ( + AuthenticatedUser, + AuthorizationRequest, + TokenSet, + VerifiedWebhook, + WebhookEvent, +) +from gnexus_gauth.exceptions import PkceException, StateValidationException +from gnexus_gauth.oauth import AuthorizationUrlBuilder, PkceGenerator +from gnexus_gauth.support import SystemClock + + +class GAuthClient: + """Main integration surface for consuming applications.""" + + def __init__( + self, + config: GAuthConfig, + token_endpoint: TokenEndpointInterface, + runtime_user_provider: RuntimeUserProviderInterface, + webhook_verifier: WebhookVerifierInterface, + webhook_parser: WebhookParserInterface, + state_store: StateStoreInterface, + pkce_store: PkceStoreInterface, + clock: ClockInterface | None = None, + authorization_url_builder: AuthorizationUrlBuilder | None = None, + ) -> None: + self._config = config + self._token_endpoint = token_endpoint + self._runtime_user_provider = runtime_user_provider + self._webhook_verifier = webhook_verifier + self._webhook_parser = webhook_parser + self._state_store = state_store + self._pkce_store = pkce_store + self._clock = clock or SystemClock() + self._authorization_url_builder = authorization_url_builder or AuthorizationUrlBuilder(config) + + def build_authorization_request( + self, + return_to: str | None = None, + scopes: list[str] | None = None, + ) -> AuthorizationRequest: + state = PkceGenerator.generate_state() + verifier = PkceGenerator.generate_verifier() + challenge = PkceGenerator.generate_challenge(verifier) + expires_at = self._clock.now() + timedelta(seconds=self._config.state_ttl_seconds) + + self._state_store.put( + state, + expires_at, + {"return_to": return_to, "scopes": list(scopes) if scopes else []}, + ) + self._pkce_store.put(state, verifier, expires_at) + + url = self._authorization_url_builder.build( + state=state, + pkce_challenge=challenge, + return_to=return_to, + scopes=scopes, + ) + + return AuthorizationRequest( + authorization_url=url, + state=state, + pkce_verifier=verifier, + pkce_challenge=challenge, + scopes=list(scopes) if scopes else [], + return_to=return_to, + ) + + def exchange_authorization_code(self, code: str, state: str) -> TokenSet: + if not self._state_store.has(state): + raise StateValidationException("Unknown or expired authorization state.") + + verifier = self._pkce_store.get(state) + if not verifier: + raise PkceException("Missing PKCE verifier for authorization callback.") + + token_set = self._token_endpoint.exchange_authorization_code(code, verifier) + + self._state_store.forget(state) + self._pkce_store.forget(state) + + return token_set + + def refresh_token(self, refresh_token: str) -> TokenSet: + return self._token_endpoint.refresh_token(refresh_token) + + def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: + self._token_endpoint.revoke_token(token, token_type_hint) + + def fetch_user(self, access_token: str) -> AuthenticatedUser: + return self._runtime_user_provider.fetch_user(access_token) + + def verify_webhook(self, raw_body: str, headers: dict, secret: str) -> VerifiedWebhook: + return self._webhook_verifier.verify(raw_body, headers, secret) + + def parse_webhook(self, raw_body: str) -> WebhookEvent: + return self._webhook_parser.parse(raw_body) + + def verify_and_parse_webhook(self, raw_body: str, headers: dict, secret: str) -> WebhookEvent: + self.verify_webhook(raw_body, headers, secret) + return self.parse_webhook(raw_body) diff --git a/src/gnexus_gauth/config.py b/src/gnexus_gauth/config.py new file mode 100644 index 0000000..5d566ce --- /dev/null +++ b/src/gnexus_gauth/config.py @@ -0,0 +1,129 @@ +"""Configuration for gnexus-gauth client.""" + +from urllib.parse import urlparse + +from gnexus_gauth.exceptions import ConfigurationException + + +class GAuthConfig: + """Package configuration. + + Holds base URL, client credentials, redirect URI and endpoint paths. + """ + + def __init__( + self, + base_url: str, + client_id: str, + client_secret: str, + redirect_uri: str, + authorize_path: str = "/oauth/authorize", + token_path: str = "/oauth/token", + refresh_path: str = "/oauth/refresh", + revoke_path: str = "/oauth/revoke", + user_info_path: str = "/oauth/userinfo", + state_ttl_seconds: int = 300, + webhook_tolerance_seconds: int = 300, + user_agent: str | None = None, + ) -> None: + base_url = base_url.rstrip("/") + if not base_url: + raise ConfigurationException("Invalid gnexus-auth base URL.") + parsed = urlparse(base_url) + if not parsed.scheme or not parsed.netloc: + raise ConfigurationException("Invalid gnexus-auth base URL.") + + if not client_id: + raise ConfigurationException("client_id must not be empty.") + if not client_secret: + raise ConfigurationException("client_secret must not be empty.") + + parsed_redirect = urlparse(redirect_uri) + if not parsed_redirect.scheme or not parsed_redirect.netloc: + raise ConfigurationException("Invalid redirect URI.") + + if state_ttl_seconds < 60: + raise ConfigurationException("state TTL must be at least 60 seconds.") + if webhook_tolerance_seconds < 0: + raise ConfigurationException("webhook tolerance must be zero or greater.") + + self._base_url = base_url + self._client_id = client_id + self._client_secret = client_secret + self._redirect_uri = redirect_uri + self._authorize_path = authorize_path + self._token_path = token_path + self._refresh_path = refresh_path + self._revoke_path = revoke_path + self._user_info_path = user_info_path + self._state_ttl_seconds = state_ttl_seconds + self._webhook_tolerance_seconds = webhook_tolerance_seconds + self._user_agent = user_agent + + @property + def base_url(self) -> str: + return self._base_url + + @property + def client_id(self) -> str: + return self._client_id + + @property + def client_secret(self) -> str: + return self._client_secret + + @property + def redirect_uri(self) -> str: + return self._redirect_uri + + @property + def authorize_path(self) -> str: + return self._authorize_path + + @property + def token_path(self) -> str: + return self._token_path + + @property + def refresh_path(self) -> str: + return self._refresh_path + + @property + def revoke_path(self) -> str: + return self._revoke_path + + @property + def user_info_path(self) -> str: + return self._user_info_path + + @property + def state_ttl_seconds(self) -> int: + return self._state_ttl_seconds + + @property + def webhook_tolerance_seconds(self) -> int: + return self._webhook_tolerance_seconds + + @property + def user_agent(self) -> str | None: + return self._user_agent + + @property + def authorize_url(self) -> str: + return self._base_url + self._authorize_path + + @property + def token_url(self) -> str: + return self._base_url + self._token_path + + @property + def refresh_url(self) -> str: + return self._base_url + self._refresh_path + + @property + def revoke_url(self) -> str: + return self._base_url + self._revoke_path + + @property + def user_info_url(self) -> str: + return self._base_url + self._user_info_path diff --git a/src/gnexus_gauth/contracts.py b/src/gnexus_gauth/contracts.py new file mode 100644 index 0000000..0fcbb21 --- /dev/null +++ b/src/gnexus_gauth/contracts.py @@ -0,0 +1,114 @@ +"""Storage and service contracts for gnexus-gauth.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any + +from gnexus_gauth.dto import ( + AuthenticatedUser, + TokenSet, + VerifiedWebhook, + WebhookEvent, +) + + +class StateStoreInterface(ABC): + """Authorization state storage contract.""" + + @abstractmethod + def put(self, state: str, expires_at: datetime, context: dict | None = None) -> None: + """Persist authorization state with optional context.""" + + @abstractmethod + def has(self, state: str) -> bool: + """Check if state exists and is not expired.""" + + @abstractmethod + def get_context(self, state: str) -> dict: + """Retrieve context for the given state.""" + + @abstractmethod + def forget(self, state: str) -> None: + """Remove the state.""" + + +class PkceStoreInterface(ABC): + """PKCE verifier storage contract.""" + + @abstractmethod + def put(self, state: str, verifier: str, expires_at: datetime) -> None: + """Persist PKCE verifier by state.""" + + @abstractmethod + def get(self, state: str) -> str | None: + """Retrieve PKCE verifier for callback exchange.""" + + @abstractmethod + def forget(self, state: str) -> None: + """Remove consumed verifier.""" + + +class TokenStoreInterface(ABC): + """Optional token set persistence contract.""" + + @abstractmethod + def put(self, key: str, token_set: TokenSet) -> None: + """Persist token set.""" + + @abstractmethod + def get(self, key: str) -> TokenSet | None: + """Retrieve token set.""" + + @abstractmethod + def forget(self, key: str) -> None: + """Delete token set on logout/revoke.""" + + +class ClockInterface(ABC): + """Clock abstraction for deterministic testing.""" + + @abstractmethod + def now(self) -> datetime: + """Return current time.""" + + +class TokenEndpointInterface(ABC): + """Token endpoint operations.""" + + @abstractmethod + def exchange_authorization_code(self, code: str, pkce_verifier: str) -> TokenSet: + """Exchange authorization code for tokens.""" + + @abstractmethod + def refresh_token(self, refresh_token: str) -> TokenSet: + """Refresh access token.""" + + @abstractmethod + def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: + """Revoke a token.""" + + +class RuntimeUserProviderInterface(ABC): + """Runtime user data provider.""" + + @abstractmethod + def fetch_user(self, access_token: str) -> AuthenticatedUser: + """Fetch authenticated user data from runtime API.""" + + +class WebhookVerifierInterface(ABC): + """Webhook signature verifier.""" + + @abstractmethod + def verify(self, raw_body: str, headers: dict[str, Any], secret: str) -> VerifiedWebhook: + """Verify webhook signature.""" + + +class WebhookParserInterface(ABC): + """Webhook payload parser.""" + + @abstractmethod + def parse(self, raw_body: str) -> WebhookEvent: + """Parse webhook payload into structured event.""" diff --git a/src/gnexus_gauth/dto.py b/src/gnexus_gauth/dto.py new file mode 100644 index 0000000..7cb57f9 --- /dev/null +++ b/src/gnexus_gauth/dto.py @@ -0,0 +1,78 @@ +"""Data Transfer Objects for gnexus-gauth.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +@dataclass(frozen=True) +class TokenSet: + """OAuth token response.""" + + access_token: str + refresh_token: str | None + token_type: str + expires_in: int + expires_at: datetime | None = None + refresh_expires_in: int | None = None + scopes: list[str] = field(default_factory=list) + raw_payload: dict[str, Any] = field(default_factory=dict, repr=False) + + +@dataclass(frozen=True) +class ClientAccess: + """Client-level access data for a user.""" + + client_id: str + access_status: str = "granted" + role_ids: list[str] = field(default_factory=list) + permission_ids: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class AuthenticatedUser: + """Normalized authenticated user payload from runtime API.""" + + user_id: str + email: str + email_verified: bool + system_role: str | None = None + status: str | None = None + profile: dict[str, Any] = field(default_factory=dict) + client_access_list: list[ClientAccess] = field(default_factory=list) + raw_payload: dict[str, Any] = field(default_factory=dict, repr=False) + + +@dataclass(frozen=True) +class AuthorizationRequest: + """Authorization URL request with PKCE parameters.""" + + authorization_url: str + state: str + pkce_verifier: str + pkce_challenge: str + scopes: list[str] = field(default_factory=list) + return_to: str | None = None + + +@dataclass(frozen=True) +class VerifiedWebhook: + """Result of successful webhook signature verification.""" + + raw_body: str + normalized_headers: dict[str, str] + signature_id: str + verified_at: datetime + + +@dataclass(frozen=True) +class WebhookEvent: + """Parsed webhook event.""" + + event_id: str | None + event_type: str + occurred_at: datetime | None = None + target_identifiers: dict[str, Any] = field(default_factory=dict) + actor_identifiers: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + raw_payload: dict[str, Any] = field(default_factory=dict, repr=False) diff --git a/src/gnexus_gauth/exceptions.py b/src/gnexus_gauth/exceptions.py new file mode 100644 index 0000000..c508449 --- /dev/null +++ b/src/gnexus_gauth/exceptions.py @@ -0,0 +1,45 @@ +"""Exception hierarchy for gnexus-gauth.""" + + +class GAuthException(RuntimeError): + """Root package exception.""" + + +class ConfigurationException(GAuthException): + """Invalid configuration.""" + + +class StateValidationException(GAuthException): + """Invalid or missing authorization state.""" + + +class PkceException(GAuthException): + """Invalid or missing PKCE verifier.""" + + +class TokenExchangeException(GAuthException): + """Token exchange failed.""" + + +class TokenRefreshException(GAuthException): + """Token refresh failed.""" + + +class TokenRevokeException(GAuthException): + """Token revoke failed.""" + + +class TransportException(GAuthException): + """HTTP transport failure.""" + + +class RuntimeApiException(GAuthException): + """Runtime API returned an error.""" + + +class WebhookVerificationException(GAuthException): + """Invalid webhook signature.""" + + +class WebhookPayloadException(GAuthException): + """Malformed webhook payload.""" diff --git a/src/gnexus_gauth/oauth.py b/src/gnexus_gauth/oauth.py new file mode 100644 index 0000000..11b174d --- /dev/null +++ b/src/gnexus_gauth/oauth.py @@ -0,0 +1,198 @@ +"""OAuth components for gnexus-gauth.""" + +import base64 +import hashlib +import secrets +from urllib.parse import quote, urlencode + +import httpx + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.contracts import TokenEndpointInterface +from gnexus_gauth.dto import TokenSet +from gnexus_gauth.exceptions import ( + TokenExchangeException, + TokenRefreshException, + TokenRevokeException, + TransportException, +) + + +class PkceGenerator: + """PKCE parameter generator.""" + + @staticmethod + def generate_verifier(length: int = 64) -> str: + """Generate a PKCE code verifier.""" + token = secrets.token_urlsafe(length) + return base64.urlsafe_b64encode(token.encode()).decode().rstrip("=") + + @staticmethod + def generate_challenge(verifier: str) -> str: + """Generate S256 challenge from verifier.""" + digest = hashlib.sha256(verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + @staticmethod + def generate_state(length: int = 32) -> str: + """Generate a random state parameter.""" + return secrets.token_urlsafe(length) + + +class AuthorizationUrlBuilder: + """Builds authorization URLs with PKCE.""" + + def __init__(self, config: GAuthConfig) -> None: + self._config = config + + def build( + self, + state: str, + pkce_challenge: str, + return_to: str | None = None, + scopes: list[str] | None = None, + ) -> str: + query = { + "response_type": "code", + "client_id": self._config.client_id, + "redirect_uri": self._config.redirect_uri, + "state": state, + "code_challenge": pkce_challenge, + "code_challenge_method": "S256", + } + if scopes: + query["scope"] = " ".join(scopes) + if return_to: + query["return_to"] = return_to + return f"{self._config.authorize_url}?{urlencode(query, safe='', quote_via=quote)}" + + +class HttpTokenEndpoint(TokenEndpointInterface): + """HTTP token endpoint client.""" + + def __init__( + self, + config: GAuthConfig, + http_client: httpx.Client | None = None, + ) -> None: + self._config = config + self._http = http_client or httpx.Client() + self._own_client = http_client is None + + def __del__(self) -> None: + if getattr(self, "_own_client", False) and hasattr(self, "_http"): + self._http.close() + + def exchange_authorization_code(self, code: str, pkce_verifier: str) -> TokenSet: + payload = { + "grant_type": "authorization_code", + "client_id": self._config.client_id, + "client_secret": self._config.client_secret, + "redirect_uri": self._config.redirect_uri, + "code": code, + "code_verifier": pkce_verifier, + } + data = self._send_form_request(self._config.token_url, payload, TokenExchangeException) + return self._map_token_set(data) + + def refresh_token(self, refresh_token: str) -> TokenSet: + payload = { + "grant_type": "refresh_token", + "client_id": self._config.client_id, + "client_secret": self._config.client_secret, + "refresh_token": refresh_token, + } + data = self._send_form_request(self._config.refresh_url, payload, TokenRefreshException) + return self._map_token_set(data) + + def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: + payload = { + "client_id": self._config.client_id, + "client_secret": self._config.client_secret, + "token": token, + } + if token_type_hint: + payload["token_type_hint"] = token_type_hint + self._send_form_request(self._config.revoke_url, payload, TokenRevokeException, expect_json=False) + + def _send_form_request( + self, + url: str, + payload: dict, + exception_class: type, + *, + expect_json: bool = True, + ) -> dict: + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + if self._config.user_agent: + headers["User-Agent"] = self._config.user_agent + + try: + response = self._http.post(url, data=payload, headers=headers) + except httpx.TransportError as exc: + raise TransportException("Request to gnexus-auth failed.") from exc + + if response.status_code >= 400: + message = self._extract_error_message(response.text) or "gnexus-auth returned an error response." + raise exception_class(message) + + if not expect_json: + return {} + + try: + data = response.json() + except ValueError: + raise exception_class("gnexus-auth returned malformed JSON.") + + if not isinstance(data, dict): + raise exception_class("gnexus-auth returned malformed JSON.") + + return data + + @staticmethod + def _map_token_set(data: dict) -> TokenSet: + expires_in = int(data.get("expires_in", 0)) + refresh_expires_in = data.get("refresh_expires_in") + if refresh_expires_in is not None: + refresh_expires_in = int(refresh_expires_in) + + scope = data.get("scope") + scopes = [] + if isinstance(scope, str) and scope: + scopes = [s for s in scope.split() if s] + + from datetime import datetime, timedelta, timezone + + expires_at = None + if expires_in > 0: + expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + + return TokenSet( + access_token=str(data.get("access_token", "")), + refresh_token=str(data["refresh_token"]) if "refresh_token" in data else None, + token_type=str(data.get("token_type", "Bearer")), + expires_in=expires_in, + expires_at=expires_at, + refresh_expires_in=refresh_expires_in, + scopes=scopes, + raw_payload=data, + ) + + @staticmethod + def _extract_error_message(text: str) -> str | None: + import json + + try: + decoded = json.loads(text) + except ValueError: + return None + if not isinstance(decoded, dict): + return None + if isinstance(decoded.get("error_description"), str): + return decoded["error_description"] + if isinstance(decoded.get("error"), str): + return decoded["error"] + return None diff --git a/src/gnexus_gauth/runtime.py b/src/gnexus_gauth/runtime.py new file mode 100644 index 0000000..54f11c2 --- /dev/null +++ b/src/gnexus_gauth/runtime.py @@ -0,0 +1,78 @@ +"""Runtime API client for gnexus-gauth.""" + +import httpx + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.contracts import RuntimeUserProviderInterface +from gnexus_gauth.dto import AuthenticatedUser, ClientAccess +from gnexus_gauth.exceptions import RuntimeApiException, TransportException + + +class HttpRuntimeUserProvider(RuntimeUserProviderInterface): + """HTTP runtime user provider.""" + + def __init__( + self, + config: GAuthConfig, + http_client: httpx.Client | None = None, + ) -> None: + self._config = config + self._http = http_client or httpx.Client() + self._own_client = http_client is None + + def __del__(self) -> None: + if getattr(self, "_own_client", False) and hasattr(self, "_http"): + self._http.close() + + def fetch_user(self, access_token: str) -> AuthenticatedUser: + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {access_token}", + } + if self._config.user_agent: + headers["User-Agent"] = self._config.user_agent + + try: + response = self._http.get(self._config.user_info_url, headers=headers) + except httpx.TransportError as exc: + raise TransportException("Request to gnexus-auth runtime API failed.") from exc + + if response.status_code >= 400: + raise RuntimeApiException("gnexus-auth runtime API returned an error response.") + + try: + payload = response.json() + except ValueError: + raise RuntimeApiException("gnexus-auth runtime API returned malformed JSON.") + + if not isinstance(payload, dict): + raise RuntimeApiException("gnexus-auth runtime API returned malformed JSON.") + + client_access_list: list[ClientAccess] = [] + client = payload.get("client") + if isinstance(client, dict) and isinstance(client.get("client_id"), str): + client_access_list.append( + ClientAccess( + client_id=client["client_id"], + access_status="granted", + role_ids=_string_list(client.get("roles")), + permission_ids=_string_list(client.get("permissions")), + ) + ) + + return AuthenticatedUser( + user_id=str(payload.get("sub", payload.get("id", ""))), + email=str(payload.get("email", "")), + email_verified=bool(payload.get("email_verified", False)), + system_role=str(payload["system_role"]) if "system_role" in payload else None, + status=str(payload["status"]) if "status" in payload else None, + profile=payload["profile"] if isinstance(payload.get("profile"), dict) else {}, + client_access_list=client_access_list, + raw_payload=payload, + ) + + +def _string_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value] diff --git a/src/gnexus_gauth/support.py b/src/gnexus_gauth/support.py new file mode 100644 index 0000000..f9948be --- /dev/null +++ b/src/gnexus_gauth/support.py @@ -0,0 +1,83 @@ +"""Support implementations for contracts.""" + +from datetime import datetime, timezone + +from gnexus_gauth.contracts import ( + ClockInterface, + PkceStoreInterface, + StateStoreInterface, + TokenStoreInterface, +) +from gnexus_gauth.dto import TokenSet + + +class SystemClock(ClockInterface): + """System clock using UTC.""" + + def now(self) -> datetime: + return datetime.now(timezone.utc) + + +class InMemoryStateStore(StateStoreInterface): + """In-memory authorization state store with TTL enforcement.""" + + def __init__(self) -> None: + self._items: dict[str, dict] = {} + + def put(self, state: str, expires_at: datetime, context: dict | None = None) -> None: + self._items[state] = {"expires_at": expires_at, "context": context or {}} + + def has(self, state: str) -> bool: + record = self._items.get(state) + if record is None: + return False + if record["expires_at"] < datetime.now(timezone.utc): + del self._items[state] + return False + return True + + def get_context(self, state: str) -> dict: + if not self.has(state): + return {} + return self._items[state].get("context", {}) + + def forget(self, state: str) -> None: + self._items.pop(state, None) + + +class InMemoryPkceStore(PkceStoreInterface): + """In-memory PKCE verifier store with TTL enforcement.""" + + def __init__(self) -> None: + self._items: dict[str, dict] = {} + + def put(self, state: str, verifier: str, expires_at: datetime) -> None: + self._items[state] = {"verifier": verifier, "expires_at": expires_at} + + def get(self, state: str) -> str | None: + record = self._items.get(state) + if record is None: + return None + if record["expires_at"] < datetime.now(timezone.utc): + del self._items[state] + return None + return record["verifier"] + + def forget(self, state: str) -> None: + self._items.pop(state, None) + + +class InMemoryTokenStore(TokenStoreInterface): + """In-memory token store.""" + + def __init__(self) -> None: + self._items: dict[str, TokenSet] = {} + + def put(self, key: str, token_set: TokenSet) -> None: + self._items[key] = token_set + + def get(self, key: str) -> TokenSet | None: + return self._items.get(key) + + def forget(self, key: str) -> None: + self._items.pop(key, None) diff --git a/src/gnexus_gauth/webhook.py b/src/gnexus_gauth/webhook.py new file mode 100644 index 0000000..baf29ab --- /dev/null +++ b/src/gnexus_gauth/webhook.py @@ -0,0 +1,132 @@ +"""Webhook components for gnexus-gauth.""" + +import hashlib +import hmac +from datetime import datetime, timezone + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.contracts import ( + ClockInterface, + WebhookParserInterface, + WebhookVerifierInterface, +) +from gnexus_gauth.dto import VerifiedWebhook, WebhookEvent +from gnexus_gauth.exceptions import ( + WebhookPayloadException, + WebhookVerificationException, +) +from gnexus_gauth.support import SystemClock + + +class HmacWebhookVerifier(WebhookVerifierInterface): + """HMAC-SHA256 webhook signature verifier.""" + + def __init__( + self, + config: GAuthConfig, + clock: ClockInterface | None = None, + ) -> None: + self._config = config + self._clock = clock or SystemClock() + + def verify(self, raw_body: str, headers: dict, secret: str) -> VerifiedWebhook: + normalized = self._normalize_headers(headers) + + for required in ("x-gnexus-event-id", "x-gnexus-event-type", "x-gnexus-event-timestamp", "x-gnexus-signature"): + if not normalized.get(required): + raise WebhookVerificationException(f"Missing webhook header: {required}.") + + signature = self._parse_signature_header(normalized["x-gnexus-signature"]) + timestamp = signature["timestamp"] + expected = hmac.new( + secret.encode(), + f"{timestamp}.{raw_body}".encode(), + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(expected, signature["hash"]): + raise WebhookVerificationException("Invalid webhook signature.") + + tolerance = self._config.webhook_tolerance_seconds + if tolerance > 0: + now_ts = int(self._clock.now().timestamp()) + if abs(now_ts - timestamp) > tolerance: + raise WebhookVerificationException("Webhook timestamp is outside the allowed tolerance window.") + + return VerifiedWebhook( + raw_body=raw_body, + normalized_headers=normalized, + signature_id=normalized["x-gnexus-event-id"], + verified_at=self._clock.now(), + ) + + @staticmethod + def _normalize_headers(headers: dict) -> dict[str, str]: + normalized: dict[str, str] = {} + for name, value in headers.items(): + normalized_name = str(name).lower().replace("_", "-") + if isinstance(value, list): + value = value[0] if value else "" + normalized[normalized_name] = str(value).strip() + return normalized + + @staticmethod + def _parse_signature_header(header: str) -> dict: + parts: dict[str, str] = {} + for chunk in header.split(","): + chunk = chunk.strip() + if "=" not in chunk: + continue + key, sep, value = chunk.partition("=") + if sep: + parts[key] = value + + if "t" not in parts or "v1" not in parts: + raise WebhookVerificationException("Malformed webhook signature header.") + + if not parts["t"].isdigit(): + raise WebhookVerificationException("Webhook timestamp must be numeric.") + + return { + "timestamp": int(parts["t"]), + "hash": parts["v1"].lower(), + } + + +class JsonWebhookParser(WebhookParserInterface): + """JSON webhook payload parser.""" + + def parse(self, raw_body: str) -> WebhookEvent: + import json + + try: + payload = json.loads(raw_body) + except (ValueError, TypeError) as exc: + raise WebhookPayloadException("Webhook payload is not valid JSON.") from exc + + if not isinstance(payload, dict): + raise WebhookPayloadException("Webhook payload is not valid JSON.") + + event_type = payload.get("type") + if not isinstance(event_type, str) or not event_type: + raise WebhookPayloadException("Webhook payload is missing event type.") + + occurred_at: datetime | None = None + raw_occurred = payload.get("occurred_at") + if isinstance(raw_occurred, str) and raw_occurred: + try: + occurred_at = datetime.fromisoformat(raw_occurred) + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=timezone.utc) + except ValueError as exc: + raise WebhookPayloadException("Webhook payload contains invalid occurred_at.") from exc + + return WebhookEvent( + event_id=str(payload["id"]) if "id" in payload else None, + event_type=event_type, + occurred_at=occurred_at, + target_identifiers=payload["target"] if isinstance(payload.get("target"), dict) else {}, + actor_identifiers=payload["actor"] if isinstance(payload.get("actor"), dict) else {}, + metadata=payload["data"] if isinstance(payload.get("data"), dict) else {}, + raw_payload=payload, + ) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..241c5ef --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,126 @@ +"""Tests for GAuthClient.""" + +from gnexus_gauth.client import GAuthClient +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.contracts import ( + RuntimeUserProviderInterface, + TokenEndpointInterface, + WebhookParserInterface, + WebhookVerifierInterface, +) +from gnexus_gauth.dto import ( + AuthenticatedUser, + AuthorizationRequest, + TokenSet, + VerifiedWebhook, + WebhookEvent, +) +from gnexus_gauth.support import InMemoryPkceStore, InMemoryStateStore + + +class FakeTokenEndpoint(TokenEndpointInterface): + def __init__(self) -> None: + self.exchanged = False + self.refreshed = False + self.revoked = False + + def exchange_authorization_code(self, code: str, pkce_verifier: str) -> TokenSet: + self.exchanged = True + return TokenSet("access", "refresh", "Bearer", 900) + + def refresh_token(self, refresh_token: str) -> TokenSet: + self.refreshed = True + return TokenSet("new_access", "new_refresh", "Bearer", 900) + + def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: + self.revoked = True + + +class FakeRuntimeUserProvider(RuntimeUserProviderInterface): + def fetch_user(self, access_token: str) -> AuthenticatedUser: + return AuthenticatedUser("1", "user@example.test", True) + + +class FakeWebhookVerifier(WebhookVerifierInterface): + def verify(self, raw_body: str, headers: dict, secret: str) -> VerifiedWebhook: + return VerifiedWebhook(raw_body, {}, "evt_1", __import__("datetime").datetime.now(__import__("datetime").timezone.utc)) + + +class FakeWebhookParser(WebhookParserInterface): + def parse(self, raw_body: str) -> WebhookEvent: + return WebhookEvent("evt_1", "webhook.test") + + +class TestGAuthClient: + def _make_client(self) -> tuple[GAuthClient, FakeTokenEndpoint, InMemoryStateStore, InMemoryPkceStore]: + state_store = InMemoryStateStore() + pkce_store = InMemoryPkceStore() + token_endpoint = FakeTokenEndpoint() + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + client = GAuthClient( + config=config, + token_endpoint=token_endpoint, + runtime_user_provider=FakeRuntimeUserProvider(), + webhook_verifier=FakeWebhookVerifier(), + webhook_parser=FakeWebhookParser(), + state_store=state_store, + pkce_store=pkce_store, + ) + return client, token_endpoint, state_store, pkce_store + + def test_build_authorization_request(self) -> None: + client, _, state_store, pkce_store = self._make_client() + + request = client.build_authorization_request("/dashboard", ["openid", "profile"]) + assert isinstance(request, AuthorizationRequest) + assert request.state + assert request.pkce_verifier + assert request.pkce_challenge + assert state_store.has(request.state) + assert pkce_store.get(request.state) == request.pkce_verifier + assert "response_type=code" in request.authorization_url + assert "client_id=billing" in request.authorization_url + assert "scope=openid%20profile" in request.authorization_url + assert "return_to=%2Fdashboard" in request.authorization_url + + def test_exchange_authorization_code(self) -> None: + client, token_endpoint, state_store, pkce_store = self._make_client() + + auth = client.build_authorization_request() + token_set = client.exchange_authorization_code("code_123", auth.state) + + assert token_set.access_token == "access" + assert token_endpoint.exchanged + assert not state_store.has(auth.state) + assert pkce_store.get(auth.state) is None + + def test_refresh_token(self) -> None: + client, token_endpoint, _, _ = self._make_client() + + token_set = client.refresh_token("old_refresh") + assert token_set.access_token == "new_access" + assert token_endpoint.refreshed + + def test_revoke_token(self) -> None: + client, token_endpoint, _, _ = self._make_client() + + client.revoke_token("token_123") + assert token_endpoint.revoked + + def test_fetch_user(self) -> None: + client, _, _, _ = self._make_client() + + user = client.fetch_user("access_token") + assert user.user_id == "1" + assert user.email == "user@example.test" + + def test_verify_and_parse_webhook(self) -> None: + client, _, _, _ = self._make_client() + + event = client.verify_and_parse_webhook('{"type":"test"}', {}, "secret") + assert event.event_type == "webhook.test" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..c22463e --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,114 @@ +"""Tests for GAuthConfig.""" + +import pytest + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.exceptions import ConfigurationException + + +class TestGAuthConfig: + def test_valid_config(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + assert config.base_url == "https://auth.example.test" + assert config.client_id == "billing" + assert config.client_secret == "secret" + assert config.redirect_uri == "https://billing.example.test/callback" + assert config.authorize_url == "https://auth.example.test/oauth/authorize" + assert config.token_url == "https://auth.example.test/oauth/token" + + def test_base_url_trailing_slash_stripped(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test/", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + assert config.base_url == "https://auth.example.test" + + def test_empty_base_url_raises(self) -> None: + with pytest.raises(ConfigurationException, match="base URL"): + GAuthConfig( + base_url="", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + def test_invalid_base_url_raises(self) -> None: + with pytest.raises(ConfigurationException, match="base URL"): + GAuthConfig( + base_url="not-a-url", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + def test_empty_client_id_raises(self) -> None: + with pytest.raises(ConfigurationException, match="client_id"): + GAuthConfig( + base_url="https://auth.example.test", + client_id="", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + def test_empty_client_secret_raises(self) -> None: + with pytest.raises(ConfigurationException, match="client_secret"): + GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="", + redirect_uri="https://billing.example.test/callback", + ) + + def test_invalid_redirect_uri_raises(self) -> None: + with pytest.raises(ConfigurationException, match="redirect URI"): + GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="not-a-url", + ) + + def test_state_ttl_too_low_raises(self) -> None: + with pytest.raises(ConfigurationException, match="TTL"): + GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + state_ttl_seconds=30, + ) + + def test_negative_webhook_tolerance_raises(self) -> None: + with pytest.raises(ConfigurationException, match="tolerance"): + GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + webhook_tolerance_seconds=-1, + ) + + def test_custom_paths(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + authorize_path="/auth", + token_path="/token", + refresh_path="/refresh", + revoke_path="/revoke", + user_info_path="/me", + ) + assert config.authorize_url == "https://auth.example.test/auth" + assert config.token_url == "https://auth.example.test/token" + assert config.refresh_url == "https://auth.example.test/refresh" + assert config.revoke_url == "https://auth.example.test/revoke" + assert config.user_info_url == "https://auth.example.test/me" diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py new file mode 100644 index 0000000..c19cb56 --- /dev/null +++ b/tests/unit/test_oauth.py @@ -0,0 +1,195 @@ +"""Tests for OAuth components.""" + +from datetime import datetime, timedelta, timezone + +import httpx +import pytest +import respx +from respx import mock + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.dto import TokenSet +from gnexus_gauth.exceptions import TokenExchangeException, TransportException +from gnexus_gauth.oauth import ( + AuthorizationUrlBuilder, + HttpTokenEndpoint, + PkceGenerator, +) + + +class TestPkceGenerator: + def test_generate_verifier(self) -> None: + verifier = PkceGenerator.generate_verifier() + assert verifier + assert "=" not in verifier + assert "+" not in verifier + assert "/" not in verifier + + def test_generate_challenge(self) -> None: + verifier = PkceGenerator.generate_verifier() + challenge = PkceGenerator.generate_challenge(verifier) + assert challenge + assert "=" not in challenge + assert "+" not in challenge + assert "/" not in challenge + + def test_generate_state(self) -> None: + state = PkceGenerator.generate_state() + assert state + assert "=" not in state + + +class TestAuthorizationUrlBuilder: + def test_build_basic(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + builder = AuthorizationUrlBuilder(config) + url = builder.build(state="abc123", pkce_challenge="xyz789") + assert url.startswith("https://auth.example.test/oauth/authorize?") + assert "response_type=code" in url + assert "client_id=billing" in url + assert "state=abc123" in url + assert "code_challenge=xyz789" in url + assert "code_challenge_method=S256" in url + + def test_build_with_scopes(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + builder = AuthorizationUrlBuilder(config) + url = builder.build(state="s", pkce_challenge="c", scopes=["openid", "profile"]) + assert "scope=openid%20profile" in url + + def test_build_with_return_to(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + builder = AuthorizationUrlBuilder(config) + url = builder.build(state="s", pkce_challenge="c", return_to="/dashboard") + assert "return_to=%2Fdashboard" in url + + +class TestHttpTokenEndpoint: + def test_exchange_authorization_code_success(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + route = respx.post("https://auth.example.test/oauth/token").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "access_123", + "refresh_token": "refresh_123", + "token_type": "Bearer", + "expires_in": 900, + "scope": "openid profile", + }, + ) + ) + client = httpx.Client() + endpoint = HttpTokenEndpoint(config, http_client=client) + token_set = endpoint.exchange_authorization_code("code_123", "verifier_123") + + assert token_set.access_token == "access_123" + assert token_set.refresh_token == "refresh_123" + assert token_set.token_type == "Bearer" + assert token_set.expires_in == 900 + assert token_set.scopes == ["openid", "profile"] + assert token_set.expires_at is not None + assert route.called + request = route.calls.last.request + assert b"grant_type=authorization_code" in request.content + assert b"code_verifier=verifier_123" in request.content + + def test_exchange_authorization_code_error(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + respx.post("https://auth.example.test/oauth/token").mock( + return_value=httpx.Response( + 400, + json={"error": "invalid_grant", "error_description": "Code expired."}, + ) + ) + client = httpx.Client() + endpoint = HttpTokenEndpoint(config, http_client=client) + with pytest.raises(TokenExchangeException, match="Code expired"): + endpoint.exchange_authorization_code("code_123", "verifier_123") + + def test_refresh_token_success(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + respx.post("https://auth.example.test/oauth/refresh").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "new_access", + "refresh_token": "new_refresh", + "token_type": "Bearer", + "expires_in": 900, + }, + ) + ) + client = httpx.Client() + endpoint = HttpTokenEndpoint(config, http_client=client) + token_set = endpoint.refresh_token("old_refresh") + + assert token_set.access_token == "new_access" + assert token_set.refresh_token == "new_refresh" + + def test_revoke_token(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + respx.post("https://auth.example.test/oauth/revoke").mock( + return_value=httpx.Response(200, text="OK") + ) + client = httpx.Client() + endpoint = HttpTokenEndpoint(config, http_client=client) + endpoint.revoke_token("token_123", "access_token") + + def test_transport_error(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + respx.post("https://auth.example.test/oauth/token").mock(side_effect=httpx.ConnectError("Connection refused")) + client = httpx.Client() + endpoint = HttpTokenEndpoint(config, http_client=client) + with pytest.raises(TransportException, match="Request to gnexus-auth failed"): + endpoint.exchange_authorization_code("code", "verifier") diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py new file mode 100644 index 0000000..cdef257 --- /dev/null +++ b/tests/unit/test_runtime.py @@ -0,0 +1,106 @@ +"""Tests for runtime components.""" + +import httpx +import pytest +import respx + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.exceptions import RuntimeApiException, TransportException +from gnexus_gauth.runtime import HttpRuntimeUserProvider + + +class TestHttpRuntimeUserProvider: + def test_fetch_user_success(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + route = respx.get("https://auth.example.test/oauth/userinfo").mock( + return_value=httpx.Response( + 200, + json={ + "sub": "user_123", + "id": "user_123", + "email": "user@example.test", + "email_verified": True, + "system_role": "user", + "status": "active", + "profile": {"name": "Test User"}, + "client": { + "client_id": "billing", + "roles": ["admin"], + "permissions": ["read", "write"], + }, + }, + ) + ) + client = httpx.Client() + provider = HttpRuntimeUserProvider(config, http_client=client) + user = provider.fetch_user("access_token_123") + + assert user.user_id == "user_123" + assert user.email == "user@example.test" + assert user.email_verified is True + assert user.system_role == "user" + assert user.status == "active" + assert user.profile == {"name": "Test User"} + assert len(user.client_access_list) == 1 + assert user.client_access_list[0].client_id == "billing" + assert user.client_access_list[0].role_ids == ["admin"] + assert user.client_access_list[0].permission_ids == ["read", "write"] + assert route.called + request = route.calls.last.request + assert request.headers["Authorization"] == "Bearer access_token_123" + + def test_fetch_user_error(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + respx.get("https://auth.example.test/oauth/userinfo").mock( + return_value=httpx.Response(401, json={"error": "Unauthorized"}) + ) + client = httpx.Client() + provider = HttpRuntimeUserProvider(config, http_client=client) + with pytest.raises(RuntimeApiException, match="error response"): + provider.fetch_user("bad_token") + + def test_fetch_user_transport_error(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + respx.get("https://auth.example.test/oauth/userinfo").mock(side_effect=httpx.ConnectError("Connection refused")) + client = httpx.Client() + provider = HttpRuntimeUserProvider(config, http_client=client) + with pytest.raises(TransportException, match="runtime API failed"): + provider.fetch_user("token") + + def test_fetch_user_malformed_json(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + + with respx.mock: + respx.get("https://auth.example.test/oauth/userinfo").mock( + return_value=httpx.Response(200, text="not-json") + ) + client = httpx.Client() + provider = HttpRuntimeUserProvider(config, http_client=client) + with pytest.raises(RuntimeApiException, match="malformed JSON"): + provider.fetch_user("token") diff --git a/tests/unit/test_webhook.py b/tests/unit/test_webhook.py new file mode 100644 index 0000000..f19d9ad --- /dev/null +++ b/tests/unit/test_webhook.py @@ -0,0 +1,178 @@ +"""Tests for webhook components.""" + +import hashlib +import hmac +import json +from datetime import datetime, timezone + +import pytest + +from gnexus_gauth.config import GAuthConfig +from gnexus_gauth.exceptions import WebhookPayloadException, WebhookVerificationException +from gnexus_gauth.support import SystemClock +from gnexus_gauth.webhook import HmacWebhookVerifier, JsonWebhookParser + + +class TestHmacWebhookVerifier: + def test_verify_success(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + webhook_tolerance_seconds=300, + ) + verifier = HmacWebhookVerifier(config) + + raw_body = '{"type": "user.updated", "id": "evt_1"}' + timestamp = int(datetime.now(timezone.utc).timestamp()) + signature = hmac.new( + b"webhook_secret", + f"{timestamp}.{raw_body}".encode(), + hashlib.sha256, + ).hexdigest() + headers = { + "X-Gnexus-Event-Id": "evt_1", + "X-Gnexus-Event-Type": "user.updated", + "X-Gnexus-Event-Timestamp": "2024-01-01T00:00:00Z", + "X-Gnexus-Signature": f"t={timestamp},v1={signature}", + } + + result = verifier.verify(raw_body, headers, "webhook_secret") + assert result.raw_body == raw_body + assert result.signature_id == "evt_1" + assert result.verified_at is not None + + def test_missing_header(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + verifier = HmacWebhookVerifier(config) + + with pytest.raises(WebhookVerificationException, match="Missing webhook header"): + verifier.verify("body", {}, "secret") + + def test_invalid_signature(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + verifier = HmacWebhookVerifier(config) + + raw_body = '{"type": "user.updated"}' + timestamp = int(datetime.now(timezone.utc).timestamp()) + headers = { + "X-Gnexus-Event-Id": "evt_1", + "X-Gnexus-Event-Type": "user.updated", + "X-Gnexus-Event-Timestamp": "2024-01-01T00:00:00Z", + "X-Gnexus-Signature": f"t={timestamp},v1=bad_signature", + } + + with pytest.raises(WebhookVerificationException, match="Invalid webhook signature"): + verifier.verify(raw_body, headers, "secret") + + def test_timestamp_outside_tolerance(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + webhook_tolerance_seconds=60, + ) + verifier = HmacWebhookVerifier(config) + + raw_body = '{"type": "user.updated"}' + old_timestamp = int(datetime.now(timezone.utc).timestamp()) - 120 + signature = hmac.new( + b"secret", + f"{old_timestamp}.{raw_body}".encode(), + hashlib.sha256, + ).hexdigest() + headers = { + "X-Gnexus-Event-Id": "evt_1", + "X-Gnexus-Event-Type": "user.updated", + "X-Gnexus-Event-Timestamp": "2024-01-01T00:00:00Z", + "X-Gnexus-Signature": f"t={old_timestamp},v1={signature}", + } + + with pytest.raises(WebhookVerificationException, match="tolerance window"): + verifier.verify(raw_body, headers, "secret") + + def test_malformed_signature_header(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + verifier = HmacWebhookVerifier(config) + + headers = { + "X-Gnexus-Event-Id": "evt_1", + "X-Gnexus-Event-Type": "user.updated", + "X-Gnexus-Event-Timestamp": "2024-01-01T00:00:00Z", + "X-Gnexus-Signature": "bad_header", + } + + with pytest.raises(WebhookVerificationException, match="Malformed"): + verifier.verify("body", headers, "secret") + + def test_non_numeric_timestamp(self) -> None: + config = GAuthConfig( + base_url="https://auth.example.test", + client_id="billing", + client_secret="secret", + redirect_uri="https://billing.example.test/callback", + ) + verifier = HmacWebhookVerifier(config) + + headers = { + "X-Gnexus-Event-Id": "evt_1", + "X-Gnexus-Event-Type": "user.updated", + "X-Gnexus-Event-Timestamp": "2024-01-01T00:00:00Z", + "X-Gnexus-Signature": "t=abc,v1=hash", + } + + with pytest.raises(WebhookVerificationException, match="numeric"): + verifier.verify("body", headers, "secret") + + +class TestJsonWebhookParser: + def test_parse_success(self) -> None: + parser = JsonWebhookParser() + raw = json.dumps({ + "id": "evt_1", + "type": "user.updated", + "occurred_at": "2024-01-15T10:30:00+00:00", + "target": {"user_id": "user_123"}, + "actor": {"user_id": "admin_1"}, + "data": {"field": "email"}, + }) + event = parser.parse(raw) + assert event.event_id == "evt_1" + assert event.event_type == "user.updated" + assert event.occurred_at is not None + assert event.occurred_at.year == 2024 + assert event.target_identifiers == {"user_id": "user_123"} + assert event.actor_identifiers == {"user_id": "admin_1"} + assert event.metadata == {"field": "email"} + + def test_parse_invalid_json(self) -> None: + parser = JsonWebhookParser() + with pytest.raises(WebhookPayloadException, match="not valid JSON"): + parser.parse("not json") + + def test_parse_missing_type(self) -> None: + parser = JsonWebhookParser() + with pytest.raises(WebhookPayloadException, match="missing event type"): + parser.parse('{"id": "evt_1"}') + + def test_parse_invalid_occurred_at(self) -> None: + parser = JsonWebhookParser() + with pytest.raises(WebhookPayloadException, match="invalid occurred_at"): + parser.parse('{"type": "test", "occurred_at": "not-a-date"}')