Metadata-Version: 2.1
Name: alter-sdk
Version: 0.8.0
Summary: Alter Vault Python SDK - OAuth token management with policy enforcement
Home-page: https://alterauth.com
Keywords: oauth,tokens,security,policy,vault
Author: Alter Team
Author-email: founders@alterauth.com
Requires-Python: >=3.11,<4.0
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Provides-Extra: aws
Provides-Extra: langchain
Provides-Extra: mcp
Requires-Dist: boto3 (>=1.28.0,<2.0.0) ; extra == "aws"
Requires-Dist: fastmcp (>=3.0.0) ; extra == "mcp"
Requires-Dist: httpx[http2] (>=0.25.0,<1.0)
Requires-Dist: langchain-core (>=0.3.0) ; extra == "langchain"
Requires-Dist: pydantic (>=2.5.0,<3.0.0)
Description-Content-Type: text/markdown

# Alter SDK for Python

Official Python SDK for [Alter Vault](https://alterauth.com) - Credential management for agents with policy enforcement.

## Features

- **Zero Token Exposure**: Tokens are never exposed to developers - injected automatically
- **Single Entry Point**: One method (`vault.request()`) for all provider APIs
- **Type-Safe Enums**: `Provider` and `HttpMethod` enums with autocomplete
- **URL Templating**: Path parameter substitution with automatic URL encoding
- **Automatic Audit Logging**: All API calls logged with request metadata (HTTP method and URL) for full audit trail
- **Real-time Policy Enforcement**: Every token request checked against current policies
- **Automatic Token Refresh**: Tokens refreshed transparently by the backend
- **API Key and Custom Credential Support**: Handles OAuth tokens, API keys, and custom credential formats automatically
- **Signed Requests**: All SDK-to-backend requests are cryptographically signed for integrity, authenticity, and replay protection
- **Framework Integrations**: Optional extras for MCP and LangChain

## Installation

```bash
pip install alter-sdk
```

Optional framework integrations:

```bash
pip install 'alter-sdk[mcp]'        # FastMCP integration
pip install 'alter-sdk[langchain]'  # LangChain/LangGraph integration
```

## Quick Start

```python
import asyncio
from alter_sdk import AlterVault, HttpMethod

async def main():
    vault = AlterVault(
        api_key="alter_key_...",
        caller="my-agent",
    )

    # Make API request - token injected automatically, never exposed
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/resource",
        grant_id="GRANT_ID",  # from Alter Connect (see below)
        query_params={"maxResults": "10"},
    )
    data = response.json()
    print(data)

    await vault.close()

asyncio.run(main())
```

### Where does `grant_id` come from?

**OAuth grants** (per-user, from end user action):
1. Your **end user** completes OAuth via [Alter Connect](https://docs.alterauth.com/sdks/javascript/quickstart) (frontend widget) or `vault.connect()` (headless)
2. The `onSuccess` callback returns a `grant_id` (UUID) - one per user per account
3. You save it in your database, mapped to your user
4. You pass it to `vault.request()` when making API calls

**Managed secrets** (per-service, from developer action):
1. **You** store credentials in the [Developer Portal](https://portal.alterauth.com) under **Managed Secrets**
2. The portal returns a `grant_id` - one per stored credential, shared across your backend
3. Use the same `vault.request()` - credentials are injected automatically

```python
# You can also discover grant_ids programmatically:
result = await vault.list_grants(provider_id="google")
for grant in result.grants:
    print(f"{grant.grant_id}: {grant.account_display_name}")
```

## Usage

The `request()` method returns the raw `httpx.Response`. The token is injected automatically and never exposed.

### Simple GET Request

```python
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=grant_id,
)
```

### POST with JSON Body

```python
response = await vault.request(
    HttpMethod.POST,
    "https://api.example.com/v1/items",
    grant_id=grant_id,
    json={"name": "New Item", "price": 99.99},
    reason="Creating new item",
)
```

### URL Path Templating

```python
response = await vault.request(
    HttpMethod.PUT,
    "https://api.example.com/v1/items/{item_id}",
    grant_id=grant_id,
    path_params={"item_id": "123"},
    json={"price": 89.99},
)
```

### Query Parameters and Extra Headers

```python
response = await vault.request(
    HttpMethod.POST,
    "https://api.example.com/v1/databases/{db_id}/query",
    grant_id=grant_id,
    path_params={"db_id": "abc123"},
    extra_headers={"Api-Version": "2022-06-28"},
    json={"page_size": 10},
)
```

### Context Manager

```python
async with AlterVault(
    api_key="alter_key_...",
    caller="my-service",
) as vault:
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/resource",
        grant_id=grant_id,
    )
# Automatically closed
```

> **Note:** After `close()` is called, subsequent `request()` calls raise `AlterSDKError`. `close()` is idempotent - calling it multiple times is safe.

### Using Managed Secrets

For your own APIs with API keys or service tokens (no OAuth flow needed):

```python
async with AlterVault(
    api_key="alter_key_...",
    caller="my-service",
) as vault:
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/data",
        grant_id="MANAGED_SECRET_GRANT_ID",  # from Developer Portal
    )
```

The credential is injected automatically as the configured header type (Bearer, API Key, Basic Auth).

### Request Context (Audit)

Pass a `context` dict per-request for structured audit logging:

```python
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=grant_id,
    context={"tool": "read_data", "agent": "my-agent"},
    reason="Fetching resource for user request",
)
```

The `context` dict is stored in audit logs for querying and attribution. The SDK validates the dict before sending — keys and values must be strings, the dict must have at most 20 keys, no key longer than 64 chars, no value longer than 512 chars, and the JSON-encoded payload must fit in 4 KB. Violations raise `AlterValueError` so a malformed context never silently disappears from your audit trail.

### Identity-Based Grant Resolution

For identity-based grant resolution, authenticate end users via the configured IDP:

```python
vault = AlterVault(
    api_key="alter_key_...",
    caller="my-agent",
    user_token_getter=lambda: get_current_user_jwt(),
)

# Subsequent requests can resolve grants via user identity
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    provider="<provider>",  # resolved via user identity instead of grant_id
)
```

Or use explicit browser-based authentication:

```python
auth = await vault.authenticate(timeout=300.0)

print(auth.user_info)  # {"sub": "user-123", "email": "user@example.com", ...}

# Subsequent requests automatically use identity resolution
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    provider="<provider>",
)
```

### Grant Management

#### List Grants

Retrieve OAuth grants for your app, optionally filtered by provider:

```python
from alter_sdk import AlterVault

async with AlterVault(
    api_key="alter_key_...",
    caller="my-service",
) as vault:
    # List all grants
    result = await vault.list_grants()
    for grant in result.grants:
        print(f"{grant.provider_id}: {grant.account_display_name} ({grant.status})")

    # Filter by provider with pagination
    result = await vault.list_grants(provider_id="google", limit=10, offset=0)
    print(f"Total: {result.total}, Has more: {result.has_more}")
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `provider_id` | `str \| None` | `None` | Filter by provider (e.g., `"google"`) |
| `limit` | `int` | `100` | Max grants to return |
| `offset` | `int` | `0` | Pagination offset |

Returns `GrantListResult` with: `grants` (`list[GrantInfo]`), `total`, `limit`, `offset`, `has_more`.

Each `GrantInfo` has:

| Field | Type | Description |
|-------|------|-------------|
| `grant_id` | `str` | Unique grant identifier (UUID). **Not `.id`** — always use `.grant_id` |
| `provider_id` | `str` | Provider slug (e.g., `"google"`, `"slack"`) |
| `scopes` | `list[str]` | Granted OAuth scopes |
| `account_identifier` | `str \| None` | Provider account email or username |
| `account_display_name` | `str \| None` | Human-readable account name |
| `status` | `str` | Grant status (e.g., `"active"`) |
| `scope_mismatch` | `bool` | `True` if granted scopes don't match requested scopes |
| `expires_at` | `str \| None` | Expiry timestamp (if a grant policy TTL was set) |
| `created_at` | `str` | When the grant was created |
| `last_used_at` | `str \| None` | When the grant was last used for an API call |

#### Create Connect Session

Generate a session URL for end-users to authenticate with OAuth providers:

```python
session = await vault.create_connect_session(
    allowed_providers=["google", "github"],
    return_url="https://myapp.com/callback",
)
print(f"Connect URL: {session.connect_url}")
print(f"Expires in: {session.expires_in}s")
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `allowed_providers` | `list[str] \| None` | `None` | Restrict to specific providers |
| `return_url` | `str \| None` | `None` | Redirect URL after OAuth flow |

Returns `ConnectSession` with: `session_token`, `connect_url`, `expires_in`, `expires_at`.

#### Headless Connect (from code)

For CLI tools, Jupyter notebooks, and backend scripts - opens the browser, waits for the user to complete OAuth, and returns the result:

```python
results = await vault.connect(
    providers=["google"],
    grant_policy={  # optional TTL bounds
        "max_ttl_seconds": 86400,
        "default_ttl_seconds": 3600,
    },
    timeout=300,         # max wait in seconds (default: 5 min)
    open_browser=True,   # set False to print URL instead
)
for result in results:
    print(f"Connected: {result.grant_id} ({result.provider_id})")
    print(f"Account: {result.account_identifier}")

# Now use the grant_id with vault.request()
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=results[0].grant_id,
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `providers` | `list[str] \| None` | `None` | Restrict to specific providers |
| `timeout` | `int` | `300` | Max seconds to wait for completion |
| `poll_interval` | `float` | `2.0` | Seconds between status checks |
| `grant_policy` | `dict \| None` | `None` | TTL bounds (`max_ttl_seconds`, `default_ttl_seconds`) |
| `open_browser` | `bool` | `True` | Open browser automatically |

Returns `list[ConnectResult]` - one per connected provider. Each has: `grant_id`, `provider_id`, `account_identifier`, `scopes`, and optionally `grant_policy` (if a TTL was set).

Raises `ConnectTimeoutError` if the user doesn't complete in time, `ConnectDeniedError` if the user denies authorization, `ConnectConfigError` if the OAuth app is misconfigured.

### Multi-Agent Deployments

Each agent should create its own `AlterVault` instance with a unique `caller`. Do not share a single instance across agents.

```python
# Each agent gets its own vault instance
email_agent = AlterVault(
    api_key="alter_key_...",
    caller="email-assistant-v2",
)

calendar_agent = AlterVault(
    api_key="alter_key_...",
    caller="calendar-agent-v1",
)

# Audit logs and policies are tracked per caller
await email_agent.request(
    HttpMethod.GET,
    "https://api.example.com/v1/messages",
    grant_id=gmail_grant_id,
)
await calendar_agent.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=calendar_grant_id,
)

# Clean up each instance
await email_agent.close()
await calendar_agent.close()
```

## Configuration

```python
from alter_sdk import AlterVault, CallerType

vault = AlterVault(
    api_key="alter_key_...",              # Required: Alter Vault API key
    caller="my-agent",                    # Optional: Identifies this SDK instance for audit + policy
    caller_type=CallerType.AGENT,         # Optional: "agent" (default, Agents tab) or "service" (hidden)
    timeout=30.0,                         # Optional: HTTP timeout in seconds
    user_token_getter=lambda: get_jwt(),  # Optional: Per-request user identity for grant resolution
)
```

## Error Handling

The SDK provides a typed exception hierarchy so you can handle each failure mode precisely:

```
AlterSDKError (base)
├── AlterValueError                  # SDK rejected your input — fix your code
├── BackendError                     # Generic backend error
│   ├── ReAuthRequiredError          # User must re-authorize via Alter Connect
│   │   ├── GrantExpiredError        # 403 — grant TTL elapsed
│   │   ├── CredentialRevokedError   # 400 — underlying auth permanently broken (revoked, invalid_grant)
│   │   ├── GrantRevokedError        # 400 — grant revoked
│   │   └── GrantDeletedError        # 410 — user disconnected via Wallet (new ID on re-auth)
│   ├── GrantNotFoundError           # 404 — wrong grant_id
│   └── PolicyViolationError         # 403 — policy denied (business hours, IP allowlist, etc.)
├── ConnectFlowError                 # Headless connect() failed
│   ├── ConnectDeniedError           # User clicked Deny
│   ├── ConnectConfigError           # OAuth app misconfigured
│   └── ConnectTimeoutError          # User didn't complete in time
├── ProviderAPIError                 # Provider returned 4xx/5xx
│   └── ScopeReauthRequiredError    # 403 + scope mismatch — user must re-authorize
└── NetworkError                     # Backend or provider unreachable
    └── TimeoutError                 # Request timed out (safe to retry)
```

`AlterValueError` is raised for input validation failures the developer must fix in their own code (e.g., a malformed `context` dict). It signals a programming bug, not a runtime/network/backend failure.

```python
from alter_sdk import AlterVault, HttpMethod
from alter_sdk.exceptions import (
    AlterSDKError,              # Base exception
    AlterValueError,            # SDK rejected input — fix your code
    BackendError,               # Generic backend error
    ReAuthRequiredError,        # Parent for all re-auth errors
    GrantDeletedError,          # User disconnected via Wallet — new ID on re-auth (410)
    GrantExpiredError,          # TTL expired — user must re-authorize (403)
    GrantNotFoundError,         # Wrong grant_id — check for typos (404)
    CredentialRevokedError,     # Underlying auth permanently broken — revoked/invalid_grant (400)
    GrantRevokedError,          # Grant revoked (400)
    PolicyViolationError,       # Policy denied access — business hours, IP, etc. (403)
    ConnectFlowError,           # Headless connect() failed (denied, provider error)
    ConnectDeniedError,         # User denied authorization
    ConnectConfigError,         # OAuth app misconfigured
    ConnectTimeoutError,        # Headless connect() timed out
    NetworkError,               # Backend or provider unreachable
    TimeoutError,               # Request timed out (subclass of NetworkError)
    ProviderAPIError,           # Provider API returned error (4xx/5xx)
    ScopeReauthRequiredError,   # 403 + scope mismatch (subclass of ProviderAPIError)
)

try:
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/resource",
        grant_id=grant_id,
    )

# --- Grant unusable — user must re-authorize via Alter Connect ---
except GrantExpiredError:
    # TTL set during Connect flow has elapsed
    print("Grant expired — prompt user to re-authorize")
except CredentialRevokedError:
    # Underlying auth permanently broken — user revoked at provider, refresh token
    # expired, or token refresh permanently failed (invalid_grant)
    print("Connection revoked — prompt user to re-authorize")
except GrantRevokedError:
    # Grant itself was revoked
    print("Grant revoked — prompt user to re-authorize")
except GrantDeletedError:
    # User disconnected via Wallet — re-auth generates a NEW grant_id
    print("Grant deleted — prompt user to re-connect, store the new ID")
except GrantNotFoundError:
    # No grant with this ID exists — check for typos or stale references
    print("Grant not found — verify your grant_id")

# --- Policy restrictions — may resolve on its own ---
except PolicyViolationError as e:
    # Business hours, IP allowlist, or other policy denial configured in the Developer Portal
    print(f"Policy denied: {e.message} (reason: {e.policy_error})")
    print("Check policy config in Developer Portal, or queue work for later")

# --- Transient / infrastructure errors — safe to retry ---
except NetworkError as e:
    # TimeoutError is a subclass, so this catches both
    print(f"Network issue — retry with backoff: {e.message}")

except ScopeReauthRequiredError as e:
    print(f"Scope mismatch on {e.grant_id} - user needs to re-authorize")
    # Create a new Connect session so the user can grant updated scopes

# --- Provider errors ---
except ProviderAPIError as e:
    print(f"Provider error {e.status_code}: {e.response_body}")
```

### Re-authorization and Grant IDs

When a user re-authorizes through Alter Connect, the **same `grant_id` is preserved** in most cases. The existing grant record is updated in place with fresh tokens. You do **not** need to update your stored `grant_id`.

The exception is `GrantDeletedError` — the user disconnected via the Wallet, so re-authorization creates a new grant with a **new `grant_id`**. Store the new ID from the `ConnectResult`.

| Exception | Same `grant_id` after re-auth? |
|---|---|
| `GrantExpiredError` | Yes |
| `CredentialRevokedError` | Yes |
| `GrantRevokedError` | Yes |
| `GrantDeletedError` | **No** — new ID generated |
| `GrantNotFoundError` | N/A — ID never existed |

## Supported Providers

The SDK includes type-safe `Provider` enums for all 66 supported providers. Use them for filtering grants or as documentation -- `request()` takes a `grant_id` string, not a provider enum.

```python
from alter_sdk import Provider

# Custom providers (full OAuth implementations)
Provider.GOOGLE       # "google"
Provider.GITHUB       # "github"
Provider.SLACK        # "slack"
Provider.CALENDLY     # "calendly"
Provider.CLICKUP      # "clickup"
Provider.CANVA        # "canva"
# ... and 23 more (see Provider enum for full list)

# Config-driven providers (45 total) -- examples:
Provider.HUBSPOT      # "hubspot"
Provider.SALESFORCE   # "salesforce"
Provider.STRIPE       # "stripe"
Provider.MICROSOFT    # "microsoft"
Provider.DISCORD      # "discord"
Provider.SPOTIFY      # "spotify"
Provider.LINKEDIN     # "linkedin"
Provider.DROPBOX      # "dropbox"
Provider.FIGMA        # "figma"
# ... and 36 more (see Provider enum for full list)

# Usage: filter grants by provider
result = await vault.list_grants(provider_id=Provider.HUBSPOT)

# Usage: make requests with grant_id
await vault.request(HttpMethod.GET, "https://api.example.com/v1/resource", grant_id=grant_id)
```

<details>
<summary>All 66 providers</summary>

Acuity Scheduling, Adobe, Aircall, Airtable, Apollo, Asana, Atlassian, Attio, Autodesk, Basecamp, Bitbucket, Bitly, Box, Brex, Cal.com, Calendly, Canva, ClickUp, Close, Constant Contact, Contentful, Deel, Dialpad, DigitalOcean, Discord, DocuSign, Dropbox, eBay, Eventbrite, Facebook, Figma, GitHub, Google, HubSpot, Instagram, Linear, LinkedIn, Mailchimp, Mercury, Microsoft, Miro, Monday, Notion, Outreach, PagerDuty, PayPal, Pinterest, Pipedrive, QuickBooks, Ramp, Reddit, RingCentral, Salesforce, Sentry, Slack, Snapchat, Spotify, Square, Squarespace, Stripe, TikTok, Todoist, Twitter, Typeform, Webex, Webflow

</details>

## Framework Integrations

### FastMCP (`alter-sdk[mcp]`)

Build MCP tools with automatic OAuth credential injection:

```python
from fastmcp import FastMCP
from alter_sdk import AlterVault
from alter_sdk.mcp import AlterMCP, AlterContext

vault = AlterVault(api_key="alter_key_...", caller="my-mcp-server")
alter = AlterMCP(vault)
mcp = FastMCP("my-server")


@mcp.tool()
@alter.tool(provider="google")
async def list_events(ctx: AlterContext, max_results: int = 10) -> list[dict]:
    """List upcoming calendar events."""
    response = await ctx.request(
        "GET",
        "https://www.googleapis.com/calendar/v3/calendars/primary/events",
        query_params={"maxResults": str(max_results)},
    )
    return response.json().get("items", [])
```

The `@alter.tool()` decorator:

- Injects `AlterContext` with a pre-configured provider and audit context
- Hides `AlterContext` from the MCP tool schema (the LLM never sees it)
- Returns a Connect URL if no grant exists (`GrantNotFoundError` recovery)
- Returns a re-auth message on scope mismatch (`ScopeReauthRequiredError` recovery)

For user authentication, use `AlterAuthProvider` with FastMCP:

```python
from alter_sdk.mcp.auth import AlterAuthProvider

auth = AlterAuthProvider(vault, providers={"google": ["calendar.events"]})
mcp = FastMCP("my-server", auth=auth)
```

### LangChain / LangGraph (`alter-sdk[langchain]`)

Build LangChain tools that are real `StructuredTool` instances:

```python
from alter_sdk import AlterVault, HttpMethod
from alter_sdk.langchain import alter_tool

vault = AlterVault(api_key="alter_key_...", caller="my-agent")


@alter_tool(vault, provider="github")
async def list_repos(org: str) -> str:
    """List repositories for a GitHub organization."""
    response = await vault.request(
        HttpMethod.GET,
        "https://api.github.com/orgs/{org}/repos",
        provider="github",
        path_params={"org": org},
    )
    repos = response.json()
    return "\n".join(r["full_name"] for r in repos)


# Pass directly to any LangChain agent or LangGraph node
from langgraph.prebuilt import create_react_agent

agent = create_react_agent(llm, tools=[list_repos])
```

The `@alter_tool()` decorator:

- Produces a real `langchain_core.tools.StructuredTool` (no manual wrapping needed)
- Extracts `run_id` and `thread_id` from LangChain's `config` for audit context
- Catches `GrantNotFoundError` and returns a Connect URL message
- Catches `ScopeReauthRequiredError` and returns a re-auth message

For remote MCP tools via `langchain-mcp-adapters`, use `AlterMCPInterceptor`:

```python
from langchain_mcp_adapters import MCPToolkit
from alter_sdk.langchain import AlterMCPInterceptor

interceptor = AlterMCPInterceptor()
toolkit = MCPToolkit(server="https://mcp.example.com", interceptor=interceptor)
```

## Requirements

- Python 3.11+
- httpx[http2]
- pydantic

## License

MIT License

