# wais-pod

> Proof of Delegation (PoD) — open-source authentication for AI agents. Part of WAIS (Web Agent Interaction Standard).

Enables AI agents to prove they act on behalf of authenticated human users with verifiable, scoped authorization.

## Install

pip install wais-pod
pip install wais-pod[site]  # for FastAPI integration

## Quick Start

### Issue a token (platform side)

```python
from pod import PoDIssuer, DPoPKeyPair

private_pem, public_pem = PoDIssuer.generate_keypair()
issuer = PoDIssuer(platform_url="https://pod.deeger.io", private_key_pem=private_pem)
agent_keys = DPoPKeyPair.generate()

token = issuer.create_token(
    agent_session="session-1",
    audience="https://serphub.deeger.io",
    user_hash="sha256:user_hash",
    scopes=["api.access"],
    agent_public_key_jwk=agent_keys.public_jwk,
)
```

### Verify a token (site side)

```python
from pod import PoDVerifier

verifier = PoDVerifier()
verifier.add_trusted_platform_pem("https://pod.deeger.io", public_pem)

result = verifier.verify(
    token_string=token,
    expected_audience="https://serphub.deeger.io",
    required_scopes=["api.access"],
    dpop_proof=proof,
    http_method="POST",
    http_url="https://api.serphub.deeger.io/v1/search",
)
# result.valid, result.token.delegation.user_hash, result.token.delegation.scopes
```

### FastAPI integration (3 lines)

```python
from pod.site import WAISAuth

wais = WAISAuth(
    site_url="https://serphub.deeger.io",
    api_base_url="https://api.serphub.deeger.io",
    platform_urls=["https://pod.deeger.io"],
)

# In lifespan:
await wais.setup()

# On endpoints:
@app.post("/v1/search")
async def search(auth=Depends(wais.require(scopes=["api.access"], api_key_validator=validate_key))):
    # auth.is_wais, auth.user_hash, auth.scopes, auth.api_key_data
```

## Modules

### pod.token
- Constraints(max_transaction_amount, require_confirmation_above, allowed_domains, geo_restrictions)
  - exceeds_amount(amount, currency) -> bool
  - needs_confirmation(amount, currency) -> bool
- DelegationPayload(user_hash, user_verified, consent_timestamp, scopes, constraints, payment_context)
  - hash_user_id(user_id) -> str [static] — creates "sha256:{hex}"
  - has_scope(scope) -> bool — supports wildcard "catalog.*"
  - has_all_scopes(scopes) -> bool
- PoDToken(alg, typ, kid, iss, sub, aud, iat, exp, jti, client_id, scope, cnf, delegation)
  - is_expired -> bool
  - header -> dict
  - payload -> dict

### pod.issuer
- PoDIssuer(platform_url, private_key_path=None, private_key_pem=None, key_id=None)
  - generate_keypair() -> (private_pem, public_pem) [static]
  - create_token(agent_session, audience, user_hash, scopes, constraints=None, ttl_seconds=3600, user_verified=True, agent_public_key_jwk=None, client_id=None, payment_context=None) -> str

### pod.verifier
- VerificationResult(valid, token, reason)
  - requires_confirmation(action_amount, currency) -> bool
  - exceeds_limit(action_amount, currency) -> bool
- PoDVerifier(trusted_platforms=None, registry_url=None, allow_expired=False, clock_skew_seconds=30)
  - add_trusted_platform(platform_url, public_key_path)
  - add_trusted_platform_pem(platform_url, public_key_pem)
  - verify(token_string, required_scopes=None, expected_audience=None, expected_audiences=None, dpop_proof=None, http_method=None, http_url=None) -> VerificationResult
  - 12 verification steps: parse, typ, alg, issuer, signature, token build, iat, exp, jti, audience, user_verified, scopes+DPoP

### pod.dpop
- DPoPKeyPair(private_key)
  - generate() -> DPoPKeyPair [static]
  - public_jwk -> dict [property]
  - thumbprint() -> str — SHA-256 JWK Thumbprint (RFC 7638)
  - create_proof(method, url, access_token) -> str
- DPoPVerifier(max_age_seconds=300)
  - verify(dpop_proof, access_token, expected_method, expected_url, token_jkt) -> DPoPVerificationResult
- DPoPVerificationResult(valid, reason)

### pod.sd_jwt
- SDJWTIssuer(private_key, issuer_url)
  - from_pem(private_key_pem, issuer_url) [classmethod]
  - create_credential(user_data, subject="", credential_type="UserIdentityCredential", ttl_seconds=31536000) -> (sd_jwt, disclosure_map)
- SDJWTHolder
  - create_presentation(sd_jwt_full, disclosed_claims, disclosure_map) -> str [static]
- SDJWTVerifier
  - verify(presentation, issuer_public_key_pem, required_claims=None, expected_issuer=None) -> SDJWTVerificationResult
- SDJWTVerificationResult(valid, claims, reason)

### pod.scopes
- Scopes — 30+ standard scopes across 7 categories
  - Standard scopes: account.create/read/modify/delete, subscription.create/read/modify/cancel, catalog.browse/compare, cart.modify, checkout.execute, order.track, return.initiate/complete, service.browse/subscribe/manage, api.access/keys.create/keys.revoke, availability.search, booking.create/modify/cancel, claim.submit, quote.request, payment.execute, dispute.file, policy.modify, appointment.book/schedule, form.submit, document.request, status.check, prescription.renew, records.access
  - Risk levels: low, medium, high, critical
  - is_standard_scope(scope) -> bool
  - is_custom_scope(scope) -> bool — x- prefix
  - risk_level(scope) -> str — "medium" for custom scopes
  - is_high_risk(scope) -> bool
  - validate_risk_override(scope, declared_risk) -> str — sites can RAISE never LOWER
  - Bundles: universal_read(), ecommerce_browse(), ecommerce_full(), saas_browse(), saas_full(), travel_full()

### pod.confirmation
- PaymentInfo(method, provider, amount, currency, url=None, expires_at=None)
- Resolution(mode="poll", endpoint="", interval_seconds=5, max_attempts=60, timeout_seconds=300, statuses={pending:202, completed:200, failed:422, expired:410})
- ConfirmationChallenge(challenge_id, action, risk_level, expires_at, display_to_user, approval_methods, payment, resolution)
  - create(action, risk_level="high", ttl_seconds=300, ...) [classmethod]
  - is_expired -> bool
  - requires_payment -> bool
  - to_dict() -> {"wais_confirmation": {...}}
- ConfirmationResponse(challenge_id, approved, approval_method, approved_at, user_hash, platform_signature)
  - is_well_formed -> bool

### pod.site
- WAISAuth(site_url, api_base_url=None, platform_urls=None, allow_expired=False, clock_skew_seconds=30)
  - setup() [async] — fetches JWKS from platforms, 3 retries with backoff
  - extract_token(request) -> str|None [static] — checks DPoP > Bearer > X-WAIS-PoD
  - verify(request, required_scopes=None) -> VerificationResult
  - require(scopes=None, api_key_validator=None) -> FastAPI dependency returning AuthResult
  - agents_json(name, description, actions=None, scopes=None, ...) -> dict
  - URL resolution for DPoP htu: api_base_url+path > X-Forwarded headers > site_url+path
- AuthResult(is_wais, client, user_hash, scopes, wais_result, api_key_data)
  - constraints -> Constraints|None
  - payment_context -> dict|None

### pod.manifest
- WAISManifest(data)
  - from_url(url) [async classmethod] — fetches and parses agents.json
  - from_dict(data) [classmethod]
  - site_url, api_base_url, name, description, provider_urls [properties]
  - resolve_endpoint(action_id) -> str — api_base_url + endpoint
  - get_action(action_id) -> dict|None
  - list_actions() -> list[dict]
  - get_effective_risk(action_id) -> str — hybrid model, sites can only raise
  - get_required_scopes(action_id) -> list[str]
  - get_all_scopes() -> list[str]
  - is_async(action_id) -> bool
  - get_resolution(action_id) -> Resolution|None
  - get_registration_claims() -> (required, optional)

## Key Protocol Decisions

1. Token audience (aud) = site_url (discovery domain), never api_base_url
2. DPoP htu = exact request URL (routed to api_base_url)
3. Sites can RAISE risk levels, never LOWER them
4. Async resolution: 202=pending, 200=completed, 422=failed, 410=expired
5. WAIS never touches money — sites provide payment links, agents poll for completion
6. Transport: Authorization: DPoP <token> + DPoP: <proof> headers
7. Custom scopes use x- prefix, default risk: medium

## Links

- PyPI: https://pypi.org/project/wais-pod/
- Repository: https://github.com/deegerhq/wais-core
- Docs: https://deeger.io/wais
- Site integration guide: docs/SITE-INTEGRATION.md
- API reference: docs/API.md
