Skip to main content

Keycard OAuth SDK

A comprehensive Python SDK for OAuth 2.0 functionality implementing multiple OAuth 2.0 standards for enterprise-grade token management.

Requirements

  • Python 3.10 or greater
  • Virtual environment (recommended)

Setup Guide

Option 1: Using uv (Recommended)

If you have uv installed:

# Create a new project with uv
uv init my-oauth-project
cd my-oauth-project

# Create and activate virtual environment
uv venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

Option 2: Using Standard Python

# Create project directory
mkdir my-oauth-project
cd my-oauth-project

# Create and activate virtual environment
python3 -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Upgrade pip (recommended)
pip install --upgrade pip

Installation

uv add keycardai-oauth

Or with pip:

pip install keycardai-oauth

Quick Start

Synchronous Client

For traditional applications that don't use async/await:

from keycardai.oauth import Client, BasicAuth, TokenType

with Client(
    "https://oauth.example.com",
    auth=BasicAuth("your_client_id", "your_client_secret")
) as client:
    response = client.exchange_token(
        subject_token="original_access_token",
        subject_token_type=TokenType.ACCESS_TOKEN,
        audience="https://api.example.com"
    )
    print(f"New token: {response.access_token}")
    print(f"Expires in: {response.expires_in} seconds")

Asynchronous Client

For async applications (FastAPI, aiohttp, etc.):

import asyncio
from keycardai.oauth import AsyncClient, BasicAuth, TokenType

async def main():
    async with AsyncClient(
        "https://oauth.example.com",
        auth=BasicAuth("your_client_id", "your_client_secret")
    ) as client:
        response = await client.exchange_token(
            subject_token="original_access_token",
            subject_token_type=TokenType.ACCESS_TOKEN,
            audience="https://api.example.com"
        )
        print(f"New token: {response.access_token}")

asyncio.run(main())

Web-App Authorization Code Flow

For applications that own their registered redirect route, use the stateless web-app flow and store the returned state and code_verifier in session state between requests:

from keycardai.oauth.pkce import begin_authorization, complete_authorization

async def login_route(session):
    redirect = await begin_authorization(
        client_id="my-web-app",
        issuer="https://oauth.example.com",
        redirect_uri="https://app.example.com/oauth/callback",
        scopes=["openid"],
    )
    session["oauth_flow"] = {
        "state": redirect.state,
        "code_verifier": redirect.code_verifier,
    }
    return redirect.url  # Redirect the browser to this URL.


async def callback_route(request, session):
    flow = session.pop("oauth_flow")
    return await complete_authorization(
        callback_params=request.query_params,
        state=flow["state"],
        code_verifier=flow["code_verifier"],
        client_id="my-web-app",
        redirect_uri="https://app.example.com/oauth/callback",
        issuer="https://oauth.example.com",
    )

If the application caches authorization server metadata, it can replace issuer=... with metadata=cached_metadata in both handlers to skip discovery on each sign-in:

from keycardai.oauth import AuthorizationServerMetadata

cached_metadata = AuthorizationServerMetadata(
    issuer="https://oauth.example.com",
    authorization_endpoint="https://oauth.example.com/authorize",
    token_endpoint="https://oauth.example.com/token",
)

# login_route:    issuer="https://oauth.example.com"
#             -> metadata=cached_metadata
# callback_route: issuer="https://oauth.example.com"
#             -> metadata=cached_metadata

Features

  • Token Exchange (RFC 8693) - Exchange tokens for different audiences, scopes, or token types
  • Dynamic Client Registration (RFC 7591) - Register OAuth clients programmatically
  • Authorization Server Metadata (RFC 8414) - Auto-discover server endpoints and capabilities
  • UserInfo (OIDC Core 1.0 Section 5.3) - Fetch the signed-in user's identity claims
  • Bearer Token Support (RFC 6750) - Standard bearer token handling and utilities
  • PKCE Support (RFC 7636) - Proof Key for Code Exchange for public clients
  • Web-App Authorization Code Flow - Stateless begin and complete helpers for applications with their own callback route
  • Multiple Auth Strategies - BasicAuth, BearerAuth, and multi-zone authentication
  • Comprehensive Error Handling - Structured exceptions with retry guidance
  • Sync and Async Clients - Choose the right client for your application

OAuth Standards Supported

The SDK implements the following OAuth 2.0 specifications:

RFC Standard Description
RFC 8693 Token Exchange Exchange tokens for different audiences, scopes, or impersonation
RFC 7591 Dynamic Client Registration Register clients programmatically with authorization servers
RFC 8414 Authorization Server Metadata Discover server endpoints and capabilities automatically
RFC 6750 Bearer Token Usage Standard format for OAuth 2.0 access tokens
RFC 7636 PKCE Security extension for public clients
RFC 7662 Token Introspection Validate and inspect token metadata
RFC 7009 Token Revocation Invalidate access and refresh tokens
RFC 9126 Pushed Authorization Requests Enhanced authorization request security
OIDC Core 1.0 Section 5.3 UserInfo Fetch identity claims for the subject of an access token

Configuration

Client Initialization

Both Client and AsyncClient accept the same initialization parameters:

from keycardai.oauth import Client, AsyncClient, BasicAuth, Endpoints, ClientConfig

# Minimal initialization
client = Client("https://oauth.example.com")

# Full initialization with all options
client = Client(
    base_url="https://oauth.example.com",
    auth=BasicAuth("client_id", "client_secret"),
    endpoints=Endpoints(
        token="/oauth2/token",
        register="/oauth2/register"
    ),
    config=ClientConfig(
        timeout=60.0,
        max_retries=5
    )
)

ClientConfig Options

Configure client behavior with ClientConfig:

Parameter Type Default Description
timeout float 30.0 HTTP request timeout in seconds
max_retries int 3 Maximum retry attempts for failed requests
verify_ssl bool True Verify SSL/TLS certificates
user_agent str "Keycard-OAuth/0.0.1" HTTP User-Agent header
custom_headers dict[str, str] | None None Additional HTTP headers for all requests
enable_metadata_discovery bool True Auto-discover server endpoints via RFC 8414
auto_register_client bool False Automatically register client on context entry
client_id str | None None Pre-existing client ID (skip registration)
client_name str "Keycard OAuth Client" Client name for dynamic registration
client_redirect_uris list[str] ["http://localhost:8080/callback"] Redirect URIs for registration
client_grant_types list[GrantType] [AUTHORIZATION_CODE, REFRESH_TOKEN, TOKEN_EXCHANGE] Grant types for registration
client_token_endpoint_auth_method TokenEndpointAuthMethod NONE Token endpoint auth method
client_jwks_url str | None None JWKS URL for private_key_jwt auth

Example with custom configuration:

from keycardai.oauth import Client, ClientConfig, GrantType

config = ClientConfig(
    timeout=60.0,
    max_retries=5,
    enable_metadata_discovery=True,
    auto_register_client=True,
    client_name="My Application",
    client_grant_types=[GrantType.TOKEN_EXCHANGE, GrantType.CLIENT_CREDENTIALS]
)

with Client("https://oauth.example.com", config=config) as client:
    # Client automatically discovers endpoints and registers if needed
    response = client.exchange_token(...)

Endpoints Configuration

Override discovered or default endpoints with Endpoints:

Endpoint RFC Description
token RFC 6749 Token endpoint for exchanges and grants
introspect RFC 7662 Token introspection endpoint
revoke RFC 7009 Token revocation endpoint
register RFC 7591 Dynamic client registration endpoint
par RFC 9126 Pushed authorization request endpoint
authorize RFC 6749 Authorization endpoint
from keycardai.oauth import Client, Endpoints

endpoints = Endpoints(
    token="/custom/token",
    register="/custom/register"
)

with Client("https://oauth.example.com", endpoints=endpoints) as client:
    # Uses custom endpoints instead of discovered ones
    pass

Configuration Precedence

Endpoint resolution follows this priority (highest to lowest):

  1. Explicit Endpoints overrides - Always used if provided
  2. Discovered server metadata - From RFC 8414 discovery (if enable_metadata_discovery=True)
  3. Default endpoints - Standard OAuth 2.0 paths (e.g., /oauth2/token)

Authentication Strategies

The SDK provides four authentication strategies for different use cases.

NoneAuth

No authentication. Use for public endpoints or dynamic client registration:

from keycardai.oauth import Client, NoneAuth

# For server metadata discovery (no auth required)
with Client("https://oauth.example.com", auth=NoneAuth()) as client:
    metadata = client.discover_server_metadata()
    print(f"Token endpoint: {metadata.token_endpoint}")

BasicAuth (RFC 7617)

HTTP Basic authentication using client credentials:

from keycardai.oauth import Client, BasicAuth

auth = BasicAuth(
    client_id="your_client_id",
    client_secret="your_client_secret"
)

with Client("https://oauth.example.com", auth=auth) as client:
    response = client.exchange_token(
        subject_token="user_token",
        subject_token_type=TokenType.ACCESS_TOKEN,
        audience="https://api.example.com"
    )

BearerAuth (RFC 6750)

Bearer token authentication for API access:

from keycardai.oauth import Client, BearerAuth

# Use an existing access token for authentication
auth = BearerAuth(access_token="your_access_token")

with Client("https://oauth.example.com", auth=auth) as client:
    response = client.exchange_token(
        subject_token="another_token",
        subject_token_type=TokenType.ACCESS_TOKEN,
        resource="https://api.example.com"
    )

MultiZoneBasicAuth

For multi-zone deployments with different credentials per zone, keyed by each zone's issuer URL:

from keycardai.oauth import Client, MultiZoneBasicAuth

# Configure credentials per zone issuer
auth = MultiZoneBasicAuth({
    "https://prod.keycard.cloud": ("prod_client_id", "prod_client_secret"),
    "https://staging.keycard.cloud": ("staging_client_id", "staging_client_secret"),
})

# Check configured issuers
print(auth.get_configured_issuers())

# Check if an issuer is configured
if auth.has_issuer("https://prod.keycard.cloud"):
    # Get headers for a specific issuer
    headers = auth.apply_headers("https://prod.keycard.cloud")

    # Or get the BasicAuth instance for an issuer
    prod_auth = auth.get_auth_for_issuer("https://prod.keycard.cloud")

# Token operations select credentials per call with issuer=...
with Client("https://prod.keycard.cloud", auth=auth) as client:
    response = client.exchange_token(
        subject_token="token",
        issuer="https://prod.keycard.cloud",
    )

Operations

Token Exchange (RFC 8693)

Exchange tokens for different audiences, scopes, or perform delegation/impersonation:

from keycardai.oauth import Client, BasicAuth, TokenType, TokenExchangeRequest

with Client("https://oauth.example.com", auth=BasicAuth(...)) as client:
    # Simple delegation - exchange for a different audience
    response = client.exchange_token(
        subject_token="user_access_token",
        subject_token_type=TokenType.ACCESS_TOKEN,
        audience="https://api.example.com"
    )
    print(f"Delegated token: {response.access_token}")

    # Exchange with scope restriction
    response = client.exchange_token(
        subject_token="user_access_token",
        subject_token_type=TokenType.ACCESS_TOKEN,
        audience="https://api.example.com",
        scope="read:users"
    )

    # Advanced: Impersonation with actor token
    request = TokenExchangeRequest(
        subject_token="user_token",
        subject_token_type=TokenType.ACCESS_TOKEN,
        actor_token="service_account_token",
        actor_token_type=TokenType.ACCESS_TOKEN,
        audience="https://backend-api.example.com"
    )
    response = client.exchange_token(request)

Dynamic Client Registration (RFC 7591)

Register OAuth clients programmatically:

from keycardai.oauth import Client, ClientRegistrationRequest, GrantType, TokenEndpointAuthMethod

with Client("https://oauth.example.com") as client:
    # Simple registration with defaults
    response = client.register_client(client_name="My Application")
    print(f"Client ID: {response.client_id}")
    print(f"Client Secret: {response.client_secret}")

    # Full control over registration
    request = ClientRegistrationRequest(
        client_name="Production Web App",
        redirect_uris=[
            "https://app.example.com/callback",
            "https://app.example.com/silent-refresh"
        ],
        grant_types=[
            GrantType.AUTHORIZATION_CODE,
            GrantType.REFRESH_TOKEN,
            GrantType.TOKEN_EXCHANGE
        ],
        token_endpoint_auth_method=TokenEndpointAuthMethod.CLIENT_SECRET_BASIC,
        scope="openid profile email"
    )
    response = client.register_client(request)

Server Metadata Discovery (RFC 8414)

Discover authorization server capabilities:

from keycardai.oauth import Client

with Client("https://oauth.example.com") as client:
    metadata = client.discover_server_metadata()

    print(f"Issuer: {metadata.issuer}")
    print(f"Token endpoint: {metadata.token_endpoint}")
    print(f"Registration endpoint: {metadata.registration_endpoint}")
    print(f"Supported grants: {metadata.grant_types_supported}")
    print(f"Supported scopes: {metadata.scopes_supported}")
    print(f"PKCE methods: {metadata.code_challenge_methods_supported}")
    print(f"UserInfo endpoint: {metadata.userinfo_endpoint}")
    print(f"End session endpoint: {metadata.end_session_endpoint}")

UserInfo (OIDC Core 1.0 Section 5.3)

Fetch the identity claims for the user an access token was issued to. The endpoint comes from discovery (userinfo_endpoint), and the access token is sent as a Bearer credential instead of the client's own credentials:

from keycardai.oauth import Client

with Client("https://oauth.example.com") as client:
    user = client.userinfo(access_token)

    print(f"Subject: {user.sub}")
    print(f"Email: {user.claims.get('email')}")
    # Every claim the provider returned is preserved in user.claims

If the server's metadata has no userinfo_endpoint, userinfo() raises ConfigError without making a request. An expired or revoked token raises InvalidTokenError.

Error Handling

The SDK provides a structured exception hierarchy with retry guidance.

Exception Hierarchy

OAuthError (base)
├── OAuthHttpError          # HTTP 4xx/5xx responses
├── OAuthProtocolError      # RFC 6749 OAuth error responses
│   └── TokenExchangeError  # RFC 8693 specific errors
├── NetworkError            # Connection/transport failures
├── ConfigError             # Client misconfiguration
└── AuthenticationError     # Authentication failures

Retryable vs Non-Retryable Errors

Every exception exposes a retryable property: whether repeating the failed operation unchanged could succeed.

Exception retryable Condition
OAuthHttpError Yes HTTP 429 (rate limit) or 5xx (server error)
OAuthHttpError No HTTP 4xx (client error, except 429)
OAuthProtocolError / TokenExchangeError No Error code in PERMANENT_ERROR_CODES (access_denied, insufficient_authorization, invalid_client)
OAuthProtocolError / TokenExchangeError Yes Any other OAuth error code
NetworkError Yes Always: transport faults are transient, permanent failures surface as protocol or HTTP errors
ConfigError No Invalid configuration (requires code fix)
AuthenticationError No Invalid credentials

If you use the older retriable attribute: it is a legacy constructor flag, while retryable is the classification derived from the failure (the OAuth error code for protocol errors, the status code for HTTP errors, and always True on NetworkError). Prefer retryable for retry decisions.

Error Handling Patterns

from keycardai.oauth import (
    Client,
    BasicAuth,
    OAuthError,
    OAuthHttpError,
    OAuthProtocolError,
    NetworkError,
    ConfigError,
    AuthenticationError,
)

with Client("https://oauth.example.com", auth=BasicAuth(...)) as client:
    try:
        response = client.exchange_token(
            subject_token="token",
            subject_token_type=TokenType.ACCESS_TOKEN,
            audience="https://api.example.com"
        )
    except OAuthHttpError as e:
        if e.retryable:
            # HTTP 429 or 5xx - implement backoff and retry
            print(f"Retryable HTTP error (status {e.status_code}): {e}")
        else:
            # HTTP 4xx - fix the request
            print(f"Client error: {e.response_body}")

    except OAuthProtocolError as e:
        # OAuth error response from server
        print(f"OAuth error: {e.error}")
        print(f"Description: {e.error_description}")
        if e.error_uri:
            print(f"More info: {e.error_uri}")

    except NetworkError as e:
        # Connection issues are transient: retryable is always True here
        print(f"Network error (retryable: {e.retryable}): {e.cause}")

    except ConfigError as e:
        # Configuration issue - fix code
        print(f"Configuration error: {e}")

    except AuthenticationError as e:
        # Credentials invalid
        print(f"Authentication failed: {e}")

Implementing Retry Logic

import time
from keycardai.oauth import Client, BasicAuth, OAuthHttpError, NetworkError

def exchange_with_retry(client, max_attempts=3, base_delay=1.0):
    """Exchange token with exponential backoff for retryable errors."""
    for attempt in range(max_attempts):
        try:
            return client.exchange_token(
                subject_token="token",
                subject_token_type=TokenType.ACCESS_TOKEN,
                audience="https://api.example.com"
            )
        except (OAuthHttpError, NetworkError) as e:
            if not e.retryable or attempt == max_attempts - 1:
                raise
            delay = base_delay * (2 ** attempt)
            print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
            time.sleep(delay)

Utility Functions

Bearer Token Utilities

Extract and validate bearer tokens from HTTP headers:

from keycardai.oauth import extract_bearer_token, validate_bearer_format

# Extract token from Authorization header
header = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
token = extract_bearer_token(header)
print(token)  # "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

# Validate token format
is_valid = validate_bearer_format(token)
print(f"Token format valid: {is_valid}")

Examples

Working examples are available in the examples/ directory:

Run examples:

cd examples/discover_server_metadata
ZONE_URL="https://your-zone.keycard.cloud" uv run python main.py

API Reference

Note: Auto-generated API documentation is planned for a future release. For now, refer to the inline docstrings in the source code and the examples in this README. The SDK includes comprehensive docstrings with RFC references.

Development

This package is part of the Keycard Python SDK workspace.

To develop:

# From workspace root
uv sync
uv run --package keycardai-oauth pytest

Run tests with coverage:

uv run --package keycardai-oauth pytest --cov=keycardai.oauth --cov-report=term-missing

License

MIT License - see LICENSE file for details.

Support

Release files for keycardai-oauth 0.30.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for keycardai-oauth 0.30.0
File Size Uploaded
keycardai_oauth-0.30.0.tar.gz 231.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for keycardai-oauth 0.30.0
File Interpreter ABI Platform
keycardai_oauth-0.30.0-py3-none-any.whl Python 3 none any Details

Total release size: 331.4 kB

Release files / keycardai_oauth-0.30.0.tar.gz

Download URL keycardai_oauth-0.30.0.tar.gz
Size 231.4 kB
Tags Source
SHA-256 checksum
How to use checksums
f2dfe4ee86ae92abf5c8de52e5b0411930ec7508cc89fe4949a193f7f7a81b82
BLAKE2b-256 checksum
How to use checksums
084a07abe2de9eeb9ced798fce0ea4232f1fc725568caf25033cffa557b992d2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / keycardai_oauth-0.30.0-py3-none-any.whl

Download URL keycardai_oauth-0.30.0-py3-none-any.whl
Size 100.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9b0975391fbf21d67d104b9a77931dc1e64ed2420b37ee90854fe77f13ffdd8e
BLAKE2b-256 checksum
How to use checksums
7f3eff5c7a2fdade9787bb464227ac6085819c31822e9df9380d3853203cc480
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page