diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f55030b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Tests + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: | + ruff check src/ tests/ + ruff format --check src/ tests/ + + - name: Type check with ty + run: ty check src/ + + - name: Run tests with coverage + run: pytest --cov=authsome --cov-report=xml --cov-report=term -p no:xdist + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + fail_ci_if_error: false diff --git a/README.md b/README.md index 92aaba5..3ad75fd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [](https://pypi.org/project/authsome/) [](https://opensource.org/licenses/MIT) [](https://pypi.org/project/authsome/) +[](https://github.com/manojbajaj95/authsome/actions/workflows/test.yml) +[](https://codecov.io/gh/manojbajaj95/authsome) ```text __ __ diff --git a/pyproject.toml b/pyproject.toml index 1365995..8eedb5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.9", + "ty", ] [project.scripts] diff --git a/src/authsome/client.py b/src/authsome/client.py index d4093bb..6268fce 100644 --- a/src/authsome/client.py +++ b/src/authsome/client.py @@ -28,7 +28,6 @@ from authsome.crypto.base import CryptoBackend from authsome.crypto.keyring_crypto import KeyringCryptoBackend from authsome.crypto.local_file_crypto import LocalFileCryptoBackend from authsome.errors import ( - ProviderNotFoundError, AuthsomeError, ConnectionNotFoundError, CredentialMissingError, @@ -342,9 +341,11 @@ class AuthClient: # Fetch client credentials client_record = self.get_provider_client_credentials(provider, profile_name) - + flow_client_id = client_record.client_id if client_record else None - flow_client_secret = self.crypto.decrypt(client_record.client_secret) if client_record and client_record.client_secret else None + flow_client_secret = ( + self.crypto.decrypt(client_record.client_secret) if client_record and client_record.client_secret else None + ) flow_api_key = None # Secure Bridge Prompts for missing interactive inputs @@ -358,19 +359,27 @@ class AuthClient: } ) missing_fields.append({"name": "client_id", "label": "Client ID", "type": "text"}) - missing_fields.append({"name": "client_secret", "label": "Client Secret (Optional)", "type": "password", "required": False}) + missing_fields.append( + { + "name": "client_secret", + "label": "Client Secret (Optional)", + "type": "password", + "required": False, + } + ) elif flow_type == FlowType.API_KEY and not flow_api_key: missing_fields.append({"name": "api_key", "label": "API Key", "type": "password"}) if missing_fields: from authsome.flows.bridge import secure_input_bridge + title = f"{definition.display_name} Credentials" inputs = secure_input_bridge(title, missing_fields) - + if flow_type in (FlowType.PKCE, FlowType.DEVICE_CODE): flow_client_id = inputs.get("client_id") secret_input = inputs.get("client_secret") - + if client_record is None: client_record = ProviderClientRecord( profile=profile_name, @@ -403,14 +412,15 @@ class AuthClient: provider=provider, ) client_record.client_id = record.metadata.pop("_dcr_client_id") - + dcr_secret_dict = record.metadata.pop("_dcr_client_secret", None) if dcr_secret_dict: from authsome.crypto.base import EncryptedField + client_record.client_secret = EncryptedField(**dcr_secret_dict) else: client_record.client_secret = None - + self._save_provider_client_credentials(client_record) # Persist the connection record @@ -565,10 +575,10 @@ class AuthClient: """ profile_name = profile or self.active_profile store = self._get_store(profile_name) - + # Ensure provider exists self.get_provider(provider) - + # 1. Log out of all connections for this provider to remotely revoke them meta_key = build_store_key( profile=profile_name, @@ -581,10 +591,10 @@ class AuthClient: # Make a copy of the list as logout will modify it for conn_name in list(metadata.connection_names): self.logout(provider, connection=conn_name, profile=profile_name) - + # 2. Delete ProviderMetadataRecord store.delete(meta_key) - + # 3. Delete ProviderClientRecord client_key = build_store_key( profile=profile_name, @@ -592,7 +602,7 @@ class AuthClient: record_type="client", ) store.delete(client_key) - + logger.info("Revoked all credentials for provider=%s in profile=%s", provider, profile_name) def remove( @@ -605,10 +615,10 @@ class AuthClient: Removes all local credential state and deletes the provider JSON file if it exists locally. """ profile_name = profile or self.active_profile - + # Revoke all credentials (cleans up connections and client secrets) self.revoke(provider, profile=profile_name) - + # Delete the provider definition JSON if it's a local/custom provider local_path = self._home / "providers" / f"{provider}.json" if local_path.exists(): @@ -687,7 +697,7 @@ class AuthClient: if "=" in line: key, value = line.split("=", 1) env[key] = value - + def _dquote(s: str) -> str: """Double-quote a token so $VAR references are expanded by the shell.""" s = s.replace("\\", "\\\\") @@ -964,7 +974,9 @@ class AuthClient: if record.refresh_token: try: refreshed = self._refresh_token(record, provider) - return self.crypto.decrypt(refreshed.access_token) # type: ignore[arg-type] + if refreshed.access_token is None: + raise RefreshFailedError("Refreshed record missing access token", provider=provider) + return self.crypto.decrypt(refreshed.access_token) except RefreshFailedError: # Check if the token hasn't actually expired yet if now < record.expires_at: @@ -1008,7 +1020,7 @@ class AuthClient: client_record = self.get_provider_client_credentials(provider_name, record.profile) client_id = None client_secret = None - + if client_record: client_id = client_record.client_id if client_record.client_secret: diff --git a/src/authsome/flows/api_key.py b/src/authsome/flows/api_key.py index 5380673..8325174 100644 --- a/src/authsome/flows/api_key.py +++ b/src/authsome/flows/api_key.py @@ -6,7 +6,6 @@ Spec §13.5: API Key Environment Import — read from environment variable, stor from __future__ import annotations - import logging from authsome.crypto.base import CryptoBackend @@ -82,5 +81,3 @@ class ApiKeyFlow(AuthFlow): account=AccountInfo(), metadata={}, ) - - diff --git a/src/authsome/flows/bridge.py b/src/authsome/flows/bridge.py index e4b0f4d..4b570e8 100644 --- a/src/authsome/flows/bridge.py +++ b/src/authsome/flows/bridge.py @@ -5,17 +5,18 @@ web browser instead of the terminal. This prevents secrets from being exposed in environments where agents or scripts might intercept standard I/O. """ -from html import escape import http.server import logging import socket import threading import urllib.parse import webbrowser +from html import escape from typing import Any logger = logging.getLogger(__name__) + def _find_free_port() -> int: """Find a free TCP port on localhost.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -25,11 +26,11 @@ def _find_free_port() -> int: class _BridgeHandler(http.server.BaseHTTPRequestHandler): """HTTP handler that renders a form and captures input.""" - + title: str = "Secure Input" fields: list[dict[str, Any]] = [] result: dict[str, str] | None = None - + def do_GET(self) -> None: """Serve the HTML form.""" html = [ @@ -38,18 +39,20 @@ class _BridgeHandler(http.server.BaseHTTPRequestHandler): "", "
", f"