diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bd0709d..45ec19c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,8 +30,12 @@ updates: - package-ecosystem: "pip" directories: - "/a2a/python-agent" + - "/mcp/credential-broker" - "/mcp/remote-server-py" - "/native/python" + - "/native/python-agent-aws" + - "/native/python-agent-github" + - "/native/python-agent-multi" - "/web/django-allauth" - "/web/fastapi-authlib" - "/web/flask-authlib" @@ -41,10 +45,14 @@ updates: - package-ecosystem: "docker" directories: - "/a2a/python-agent" + - "/mcp/credential-broker" - "/mcp/remote-server-py" - "/mcp/remote-server-ts" - "/native/node" - "/native/python" + - "/native/python-agent-aws" + - "/native/python-agent-github" + - "/native/python-agent-multi" - "/native/rust" - "/spa/angular" - "/spa/bff-express" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 872e0b4..9be2cad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,14 @@ jobs: matrix: include: - directory: a2a/python-agent + - directory: mcp/credential-broker - directory: mcp/remote-server-py - directory: mcp/remote-server-ts - directory: native/node - directory: native/python + - directory: native/python-agent-aws + - directory: native/python-agent-github + - directory: native/python-agent-multi - directory: native/rust - directory: spa/angular - directory: spa/bff-express diff --git a/README.md b/README.md index 7eaeb2d..f2dc37a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ Terminal tools and headless servers using the Device Authorization Grant (RFC 86 | Framework | Directory | Language | |-----------|-----------|----------| | Python + requests | [`native/python`](native/python) | Python | +| Python Agent: AWS | [`native/python-agent-aws`](native/python-agent-aws) | Python | +| Python Agent: GitHub | [`native/python-agent-github`](native/python-agent-github) | Python | +| Python Agent: Multi-Credential | [`native/python-agent-multi`](native/python-agent-multi) | Python | | Node.js + fetch | [`native/node`](native/node) | Node.js | | Rust + reqwest | [`native/rust`](native/rust) | Rust | @@ -59,6 +62,7 @@ Secure AI agent communication using Vouch for hardware-backed authentication. |----------|-----------|-------------| | MCP Remote Server (TypeScript) | [`mcp/remote-server-ts`](mcp/remote-server-ts) | [Model Context Protocol](https://modelcontextprotocol.io/) server with Bearer auth + Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) | | MCP Remote Server (Python) | [`mcp/remote-server-py`](mcp/remote-server-py) | Same as above, in Python with FastMCP | +| MCP Credential Broker (Python) | [`mcp/credential-broker`](mcp/credential-broker) | MCP server that brokers AWS, GitHub, and SSH credentials on behalf of the authenticated user | | A2A Agent (Python) | [`a2a/python-agent`](a2a/python-agent) | [Agent-to-Agent](https://github.com/a2aproject/A2A) agent with OpenID Connect security scheme in the Agent Card | ## Quick Start @@ -116,6 +120,9 @@ Several examples go beyond basic login to demonstrate real-world OIDC patterns: | Post-auth API calls | [`native/node`](native/node), [`native/python`](native/python) | | Token expiry display | [`spa/react`](spa/react) | | Profile claims display | [`spa/react`](spa/react) | +| Credential brokering (AWS) | [`native/python-agent-aws`](native/python-agent-aws), [`native/python-agent-multi`](native/python-agent-multi), [`mcp/credential-broker`](mcp/credential-broker) | +| Credential brokering (GitHub) | [`native/python-agent-github`](native/python-agent-github), [`native/python-agent-multi`](native/python-agent-multi), [`mcp/credential-broker`](mcp/credential-broker) | +| Credential brokering (SSH) | [`native/python-agent-multi`](native/python-agent-multi), [`mcp/credential-broker`](mcp/credential-broker) | ## Custom Claims diff --git a/mcp/credential-broker/Dockerfile b/mcp/credential-broker/Dockerfile new file mode 100644 index 0000000..4e4f1cd --- /dev/null +++ b/mcp/credential-broker/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.14-slim-trixie + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 3000 +CMD ["python", "server.py"] diff --git a/mcp/credential-broker/README.md b/mcp/credential-broker/README.md new file mode 100644 index 0000000..ee5d5a0 --- /dev/null +++ b/mcp/credential-broker/README.md @@ -0,0 +1,43 @@ +# MCP Credential Broker + Vouch (Python) + +A remote [Model Context Protocol](https://modelcontextprotocol.io/) server that brokers downstream credentials on behalf of the authenticated user. + +This example demonstrates: +- **Credential brokering** -- exchanges a Vouch OIDC token for AWS, GitHub, and SSH credentials +- **Streamable HTTP transport** -- the MCP standard for remote servers +- **Protected Resource Metadata** ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) -- advertises Vouch as the authorization server +- **Bearer token validation** -- verifies JWTs issued by Vouch using ES256 + +## Tools + +| Tool | Description | +|------|-------------| +| `get-aws-credentials` | Get an AWS ID token from Vouch, then exchange it for temporary AWS credentials via STS | +| `get-github-token` | Get a GitHub installation token scoped to the user's identity via Vouch | +| `get-ssh-certificate` | Sign an SSH public key with a Vouch-issued certificate | + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `VOUCH_ISSUER` | No | Vouch issuer URL (default: `https://us.vouch.sh`) | + +## Run + +```bash +docker build -t vouch-mcp-credential-broker . +docker run -p 3000:3000 \ + -e VOUCH_ISSUER=https://us.vouch.sh \ + vouch-mcp-credential-broker +``` + +## Endpoints + +| Path | Description | +|------|-------------| +| `GET /.well-known/oauth-protected-resource` | Protected Resource Metadata (RFC 9728) | +| `POST /mcp` | MCP Streamable HTTP endpoint (requires Bearer token) | + +## Production Considerations + +The `get-aws-credentials` tool returns `SecretAccessKey` and `SessionToken` as plaintext in the MCP tool response. This is fine for demonstration purposes since the credentials are short-lived (1 hour max), but in production you should consider whether credentials flowing through MCP tool responses as plaintext matches your threat model. Alternatives include having the MCP server make AWS API calls directly on behalf of the user rather than returning raw credentials to the client. diff --git a/mcp/credential-broker/requirements.txt b/mcp/credential-broker/requirements.txt new file mode 100644 index 0000000..153023f --- /dev/null +++ b/mcp/credential-broker/requirements.txt @@ -0,0 +1,6 @@ +mcp>=1.12.0 +uvicorn>=0.40 +PyJWT>=2.11 +cryptography>=46.0 +pydantic>=2.0 +httpx>=0.28 diff --git a/mcp/credential-broker/server.py b/mcp/credential-broker/server.py new file mode 100644 index 0000000..891e6c7 --- /dev/null +++ b/mcp/credential-broker/server.py @@ -0,0 +1,202 @@ +import os +import json +import contextvars +import xml.etree.ElementTree as ET +import jwt +import httpx +from jwt import PyJWKClient +from pydantic import AnyHttpUrl +from mcp.server.fastmcp import FastMCP +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings + +VOUCH_ISSUER = os.environ.get('VOUCH_ISSUER', 'https://us.vouch.sh') +PORT = int(os.environ.get('PORT', '3000')) + +# JWKS client for token verification +jwks_client = PyJWKClient(f'{VOUCH_ISSUER}/oauth/jwks') + +# Store authenticated claims and raw token per-request +_current_claims = contextvars.ContextVar('current_claims', default=None) +_current_token = contextvars.ContextVar('current_token', default=None) + +AWS_STS_NS = '{https://sts.amazonaws.com/doc/2011-06-15/}' + + +class VouchTokenVerifier(TokenVerifier): + """Verify Vouch OIDC JWT tokens using JWKS.""" + + async def verify_token(self, token: str) -> AccessToken | None: + try: + signing_key = jwks_client.get_signing_key_from_jwt(token) + payload = jwt.decode( + token, + signing_key.key, + algorithms=['ES256'], + issuer=VOUCH_ISSUER, + options={'verify_aud': False}, + ) + _current_claims.set(payload) + _current_token.set(token) + return AccessToken( + token=token, + client_id=payload.get('sub'), + scopes=( + payload.get('scope', '').split() + if isinstance(payload.get('scope'), str) + else [] + ), + ) + except Exception: + return None + + +# Create MCP server with built-in auth and RFC 9728 metadata +mcp = FastMCP( + 'vouch-credential-broker', + host='0.0.0.0', + port=PORT, + json_response=True, + token_verifier=VouchTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl(VOUCH_ISSUER), + resource_server_url=AnyHttpUrl(f'http://localhost:{PORT}'), + required_scopes=[], + ), +) + + +@mcp.tool(name='get-aws-credentials') +async def get_aws_credentials(role_arn: str) -> str: + """Exchange the user's Vouch session for temporary AWS credentials. + + First obtains an AWS-specific ID token from Vouch, then exchanges it + with AWS STS AssumeRoleWithWebIdentity for temporary credentials.""" + token = _current_token.get() + if not token: + return json.dumps({'error': 'No authentication context'}, indent=2) + + async with httpx.AsyncClient() as client: + # Step 1: Get AWS-specific ID token from Vouch + vouch_resp = await client.get( + f'{VOUCH_ISSUER}/v1/credentials/aws/token', + headers={'Authorization': f'Bearer {token}'}, + ) + + if vouch_resp.status_code != 200: + return json.dumps({ + 'error': 'Vouch AWS token request failed', + 'status': vouch_resp.status_code, + 'body': vouch_resp.text, + }, indent=2) + + aws_id_token = vouch_resp.json()['id_token'] + + async with httpx.AsyncClient() as client: + # Step 2: Exchange ID token for AWS credentials via STS + sts_resp = await client.post( + 'https://sts.amazonaws.com/', + data={ + 'Action': 'AssumeRoleWithWebIdentity', + 'RoleArn': role_arn, + 'RoleSessionName': 'vouch-mcp', + 'WebIdentityToken': aws_id_token, + 'Version': '2011-06-15', + }, + ) + + if sts_resp.status_code != 200: + return json.dumps({ + 'error': 'AWS STS request failed', + 'status': sts_resp.status_code, + 'body': sts_resp.text, + }, indent=2) + + root = ET.fromstring(sts_resp.text) + creds = root.find( + f'{AWS_STS_NS}AssumeRoleWithWebIdentityResult' + f'/{AWS_STS_NS}Credentials' + ) + if creds is None: + return json.dumps({ + 'error': 'Failed to parse STS response', + 'body': sts_resp.text, + }, indent=2) + + return json.dumps({ + 'AccessKeyId': creds.findtext(f'{AWS_STS_NS}AccessKeyId'), + 'SecretAccessKey': creds.findtext( + f'{AWS_STS_NS}SecretAccessKey' + ), + 'SessionToken': creds.findtext(f'{AWS_STS_NS}SessionToken'), + 'Expiration': creds.findtext(f'{AWS_STS_NS}Expiration'), + }, indent=2) + + +@mcp.tool(name='get-github-token') +async def get_github_token( + owner: str = '', + repositories: list[str] | None = None, +) -> str: + """Get a GitHub installation token scoped to the user's identity + via Vouch.""" + token = _current_token.get() + if not token: + return json.dumps({'error': 'No authentication context'}, indent=2) + + body = {} + if owner: + body['owner'] = owner + if repositories: + body['repositories'] = repositories + + async with httpx.AsyncClient() as client: + resp = await client.post( + f'{VOUCH_ISSUER}/v1/credentials/github/token', + headers={'Authorization': f'Bearer {token}'}, + json=body, + ) + + if resp.status_code != 200: + return json.dumps({ + 'error': 'GitHub token request failed', + 'status': resp.status_code, + 'body': resp.text, + }, indent=2) + + return json.dumps(resp.json(), indent=2) + + +@mcp.tool(name='get-ssh-certificate') +async def get_ssh_certificate(public_key: str) -> str: + """Sign an SSH public key with a Vouch-issued certificate for the + authenticated user.""" + token = _current_token.get() + if not token: + return json.dumps({'error': 'No authentication context'}, indent=2) + + async with httpx.AsyncClient() as client: + resp = await client.post( + f'{VOUCH_ISSUER}/v1/credentials/ssh', + headers={'Authorization': f'Bearer {token}'}, + json={'public_key': public_key}, + ) + + if resp.status_code != 200: + return json.dumps({ + 'error': 'SSH certificate request failed', + 'status': resp.status_code, + 'body': resp.text, + }, indent=2) + + return json.dumps(resp.json(), indent=2) + + +if __name__ == '__main__': + print(f'MCP credential broker running on http://localhost:{PORT}') + print( + 'Protected Resource Metadata: ' + f'http://localhost:{PORT}/.well-known/oauth-protected-resource' + ) + print(f'MCP endpoint: http://localhost:{PORT}/mcp') + mcp.run(transport='streamable-http') diff --git a/native/python-agent-aws/Dockerfile b/native/python-agent-aws/Dockerfile new file mode 100644 index 0000000..b6be04e --- /dev/null +++ b/native/python-agent-aws/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.14-slim-trixie + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONUNBUFFERED=1 +CMD ["python", "agent.py"] diff --git a/native/python-agent-aws/README.md b/native/python-agent-aws/README.md new file mode 100644 index 0000000..1ca4ff7 --- /dev/null +++ b/native/python-agent-aws/README.md @@ -0,0 +1,65 @@ +# Python Agent: AWS Credential Brokering + +Native/CLI agent that authenticates via the Device Authorization Grant (RFC 8628), then uses Vouch's credential brokering API to obtain an AWS-specific ID token, which is exchanged with AWS STS for temporary credentials. Once temporary AWS credentials are obtained, the agent lists S3 buckets. + +No client secret is needed. The user authenticates by visiting a URL in their browser and entering a code. + +## How It Works + +1. **Device Authorization** -- The agent initiates the device flow and displays a verification URL and user code. +2. **Token Exchange** -- After the user authenticates, the agent receives an `access_token`. +3. **Vouch AWS Token** -- The agent calls `GET /v1/credentials/aws/token` with the access token to get a purpose-built OIDC ID token for AWS. +4. **AWS STS** -- The AWS ID token is passed to `AssumeRoleWithWebIdentity` to get temporary AWS credentials. +5. **S3 List** -- The agent uses the temporary credentials to list S3 buckets. + +## Prerequisites + +The AWS IAM role specified by `AWS_ROLE_ARN` must have a trust policy that allows Vouch as an OIDC identity provider. You need two things: + +1. **Register Vouch as an OIDC provider** in IAM (provider URL: your `VOUCH_ISSUER`, audience: your `VOUCH_ISSUER` URL). + +2. **Create an IAM role** with a trust policy like: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::123456789012:oidc-provider/us.vouch.sh" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "us.vouch.sh:aud": "https://us.vouch.sh" + }, + "StringLike": { + "us.vouch.sh:sub": "*@example.com" + } + } + } + ] +} +``` + +Replace `123456789012` with your AWS account ID, `us.vouch.sh` with your Vouch issuer hostname, and `*@example.com` with your domain. The `sub` condition restricts role assumption to users from your organization's email domain. See the [AWS docs on web identity federation](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc.html) for full details. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `VOUCH_ISSUER` | No | OIDC issuer URL (default: `https://us.vouch.sh`) | +| `VOUCH_CLIENT_ID` | Yes | The public client ID | +| `AWS_ROLE_ARN` | Yes | IAM role ARN that trusts Vouch as an OIDC provider | + +## Run with Docker + +```bash +docker build -t vouch-python-agent-aws . +docker run -it \ + -e VOUCH_ISSUER=https://us.vouch.sh \ + -e VOUCH_CLIENT_ID=your-client-id \ + -e AWS_ROLE_ARN=arn:aws:iam::123456789012:role/vouch-web-identity-role \ + vouch-python-agent-aws +``` diff --git a/native/python-agent-aws/agent.py b/native/python-agent-aws/agent.py new file mode 100644 index 0000000..5a7e2e4 --- /dev/null +++ b/native/python-agent-aws/agent.py @@ -0,0 +1,115 @@ +import os +import sys +import time +import requests +import boto3 +from botocore import UNSIGNED +from botocore.config import Config + +VOUCH_ISSUER = os.environ.get('VOUCH_ISSUER', 'https://us.vouch.sh') +CLIENT_ID = os.environ.get('VOUCH_CLIENT_ID') +AWS_ROLE_ARN = os.environ.get('AWS_ROLE_ARN') + +if not CLIENT_ID: + print('Error: VOUCH_CLIENT_ID environment variable is required') + sys.exit(1) + +if not AWS_ROLE_ARN: + print('Error: AWS_ROLE_ARN environment variable is required') + sys.exit(1) + +# Step 1: Request device code +response = requests.post( + f'{VOUCH_ISSUER}/oauth/device', + data={ + 'client_id': CLIENT_ID, + 'scope': 'openid email', + }, +) +response.raise_for_status() +device_data = response.json() + +# Step 2: Display instructions to user +print(f"\nTo sign in, visit: {device_data['verification_uri']}") +print(f"Enter code: {device_data['user_code']}\n") + +# Step 3: Poll for token +interval = device_data.get('interval', 5) +while True: + time.sleep(interval) + + token_response = requests.post( + f'{VOUCH_ISSUER}/oauth/token', + data={ + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_data['device_code'], + 'client_id': CLIENT_ID, + }, + ) + + if token_response.status_code == 200: + tokens = token_response.json() + print("Authenticated!") + print(f"Access token: {tokens['access_token'][:20]}...") + break + + error = token_response.json().get('error') + if error == 'authorization_pending': + continue + elif error == 'slow_down': + interval += 5 + elif error == 'expired_token': + print('Device code expired. Please try again.') + sys.exit(1) + elif error == 'access_denied': + print('Access denied by user.') + sys.exit(1) + else: + print(f'Error: {token_response.json()}') + sys.exit(1) + +# Step 4: Get an AWS-specific ID token from Vouch +print("\n--- Vouch AWS Credential Brokering ---") +aws_response = requests.get( + f'{VOUCH_ISSUER}/v1/credentials/aws/token', + headers={'Authorization': f'Bearer {tokens["access_token"]}'}, + timeout=10, +) +aws_response.raise_for_status() +aws_data = aws_response.json() +aws_id_token = aws_data['id_token'] +print(f"AWS ID token: {aws_id_token[:20]}...") +print(f"Expires in: {aws_data['expires_in']}s") + +# Step 5: Assume AWS role using the Vouch-issued ID token +print("\n--- AWS STS AssumeRoleWithWebIdentity ---") +# UNSIGNED prevents boto3 from looking for ambient AWS credentials (env vars, +# instance profiles, etc.). AssumeRoleWithWebIdentity authenticates solely +# via the web identity token — no AWS credentials are needed for the call. +sts = boto3.client('sts', config=Config(signature_version=UNSIGNED)) +sts_response = sts.assume_role_with_web_identity( + RoleArn=AWS_ROLE_ARN, + RoleSessionName='vouch-agent', + WebIdentityToken=aws_id_token, +) + +credentials = sts_response['Credentials'] +assumed_arn = sts_response['AssumedRoleUser']['Arn'] +expiration = credentials['Expiration'] +print(f"Assumed role: {assumed_arn}") +print(f"Credentials expire: {expiration}") + +# Step 6: List S3 buckets using temporary credentials +print("\n--- S3 Buckets ---") +s3 = boto3.client( + 's3', + aws_access_key_id=credentials['AccessKeyId'], + aws_secret_access_key=credentials['SecretAccessKey'], + aws_session_token=credentials['SessionToken'], +) +buckets = s3.list_buckets() +for bucket in buckets.get('Buckets', []): + print(f" {bucket['Name']}") + +if not buckets.get('Buckets'): + print(" (no buckets found)") diff --git a/native/python-agent-aws/requirements.txt b/native/python-agent-aws/requirements.txt new file mode 100644 index 0000000..5940a2c --- /dev/null +++ b/native/python-agent-aws/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.32 +boto3>=1.38 diff --git a/native/python-agent-github/Dockerfile b/native/python-agent-github/Dockerfile new file mode 100644 index 0000000..31eb91a --- /dev/null +++ b/native/python-agent-github/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.14-slim-trixie + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONUNBUFFERED=1 +CMD ["python", "agent.py"] diff --git a/native/python-agent-github/README.md b/native/python-agent-github/README.md new file mode 100644 index 0000000..3694ea4 --- /dev/null +++ b/native/python-agent-github/README.md @@ -0,0 +1,43 @@ +# Python Agent: GitHub Credential Brokering + +Native/CLI agent that authenticates via the Device Authorization Grant (RFC 8628), then uses Vouch's credential brokering API to obtain a GitHub installation token. Optionally clones a private repository using the brokered token. + +No client secret is needed. The user authenticates by visiting a URL in their browser and entering a code. + +## How It Works + +1. **Device auth flow** -- The agent requests a device code from Vouch and displays a verification URL and user code. The user signs in via their browser. +2. **GitHub token** -- After authentication, the agent calls Vouch's `/v1/credentials/github/token` endpoint with the access token to get a scoped GitHub installation token. +3. **Clone (optional)** -- If `GITHUB_REPO` is set, the agent clones the repository using the brokered token. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `VOUCH_ISSUER` | No | OIDC issuer URL (default: `https://us.vouch.sh`) | +| `VOUCH_CLIENT_ID` | Yes | The public client ID | +| `GITHUB_OWNER` | No | GitHub organization or user to scope the token to | +| `GITHUB_REPOSITORIES` | No | Comma-separated list of repository names to scope the token to | +| `GITHUB_REPO` | No | Repository name to clone after obtaining the token | + +## Run with Docker + +```bash +docker build -t vouch-python-agent-github . +docker run -it \ + -e VOUCH_ISSUER=https://us.vouch.sh \ + -e VOUCH_CLIENT_ID=your-client-id \ + -e GITHUB_OWNER=your-org \ + vouch-python-agent-github +``` + +To clone a private repository: + +```bash +docker run -it \ + -e VOUCH_ISSUER=https://us.vouch.sh \ + -e VOUCH_CLIENT_ID=your-client-id \ + -e GITHUB_OWNER=your-org \ + -e GITHUB_REPO=your-private-repo \ + vouch-python-agent-github +``` diff --git a/native/python-agent-github/agent.py b/native/python-agent-github/agent.py new file mode 100644 index 0000000..6cba8c8 --- /dev/null +++ b/native/python-agent-github/agent.py @@ -0,0 +1,117 @@ +import os +import subprocess +import sys +import time + +import requests + +VOUCH_ISSUER = os.environ.get('VOUCH_ISSUER', 'https://us.vouch.sh') +CLIENT_ID = os.environ.get('VOUCH_CLIENT_ID') +GITHUB_OWNER = os.environ.get('GITHUB_OWNER') +GITHUB_REPOSITORIES = os.environ.get('GITHUB_REPOSITORIES') +GITHUB_REPO = os.environ.get('GITHUB_REPO') + +if not CLIENT_ID: + print('Error: VOUCH_CLIENT_ID environment variable is required') + sys.exit(1) + +# Step 1: Request device code +response = requests.post( + f'{VOUCH_ISSUER}/oauth/device', + data={ + 'client_id': CLIENT_ID, + 'scope': 'openid email', + }, +) +response.raise_for_status() +device_data = response.json() + +# Step 2: Display instructions to user +print(f"\nTo sign in, visit: {device_data['verification_uri']}") +print(f"Enter code: {device_data['user_code']}\n") + +# Step 3: Poll for token +interval = device_data.get('interval', 5) +while True: + time.sleep(interval) + + token_response = requests.post( + f'{VOUCH_ISSUER}/oauth/token', + data={ + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_data['device_code'], + 'client_id': CLIENT_ID, + }, + ) + + if token_response.status_code == 200: + tokens = token_response.json() + print("Authenticated!") + print(f"Access token: {tokens['access_token'][:20]}...") + break + + error = token_response.json().get('error') + if error == 'authorization_pending': + continue + elif error == 'slow_down': + interval += 5 + elif error == 'expired_token': + print('Device code expired. Please try again.') + sys.exit(1) + elif error == 'access_denied': + print('Access denied by user.') + sys.exit(1) + else: + print(f'Error: {token_response.json()}') + sys.exit(1) + +# Step 4: Request GitHub token via Vouch credential brokering +print("\n--- GitHub Credential Brokering ---") + +body = {} +if GITHUB_OWNER: + body['owner'] = GITHUB_OWNER +if GITHUB_REPOSITORIES: + body['repositories'] = [ + r.strip() for r in GITHUB_REPOSITORIES.split(',') if r.strip() + ] + +github_response = requests.post( + f'{VOUCH_ISSUER}/v1/credentials/github/token', + headers={'Authorization': f'Bearer {tokens["access_token"]}'}, + json=body if body else None, + timeout=10, +) +github_response.raise_for_status() +github_data = github_response.json() + +token = github_data['token'] +print(f"GitHub token: {token[:12]}...") +if github_data.get('expires_at'): + print(f"Expires at: {github_data['expires_at']}") +if github_data.get('permissions'): + print(f"Permissions: {github_data['permissions']}") + +# Step 5: Optionally clone a repository +if GITHUB_REPO: + owner = GITHUB_OWNER or github_data.get('owner', '') + if not owner: + print('Error: GITHUB_OWNER is required when GITHUB_REPO is set') + sys.exit(1) + + # The installation token is short-lived (~1 hour) and never written to + # disk. This is the whole point of Vouch — ephemeral credentials backed + # by hardware attestation, replacing long-lived PATs or deploy keys. + clone_url = f'https://x-access-token:{token}@github.com/{owner}/{GITHUB_REPO}.git' + print(f"\nCloning {owner}/{GITHUB_REPO}...") + result = subprocess.run( + ['git', 'clone', clone_url], + check=False, + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f"Successfully cloned {owner}/{GITHUB_REPO}") + else: + print(f"Clone failed: {result.stderr}") + sys.exit(1) diff --git a/native/python-agent-github/requirements.txt b/native/python-agent-github/requirements.txt new file mode 100644 index 0000000..b92ca37 --- /dev/null +++ b/native/python-agent-github/requirements.txt @@ -0,0 +1 @@ +requests>=2.32 diff --git a/native/python-agent-multi/Dockerfile b/native/python-agent-multi/Dockerfile new file mode 100644 index 0000000..b6be04e --- /dev/null +++ b/native/python-agent-multi/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.14-slim-trixie + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONUNBUFFERED=1 +CMD ["python", "agent.py"] diff --git a/native/python-agent-multi/README.md b/native/python-agent-multi/README.md new file mode 100644 index 0000000..e15bd70 --- /dev/null +++ b/native/python-agent-multi/README.md @@ -0,0 +1,32 @@ +# Python Agent: Multi-Credential Brokering + +Native/CLI agent that authenticates once via the Device Authorization Grant (RFC 8628), then brokers three credential types from a single Vouch session: AWS temporary credentials, GitHub installation tokens, and SSH certificates. + +No client secret is needed. The user authenticates by visiting a URL in their browser and entering a code. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `VOUCH_ISSUER` | No | OIDC issuer URL (default: `https://us.vouch.sh`) | +| `VOUCH_CLIENT_ID` | Yes | The public client ID | +| `AWS_ROLE_ARN` | No | IAM role ARN for STS AssumeRoleWithWebIdentity (skipped if not set) | +| `GITHUB_OWNER` | No | GitHub organization or user to scope the installation token to | + +## Run with Docker + +```bash +docker build -t vouch-python-agent-multi . +docker run -it \ + -e VOUCH_ISSUER=https://us.vouch.sh \ + -e VOUCH_CLIENT_ID=your-client-id \ + -e AWS_ROLE_ARN=arn:aws:iam::123456789012:role/your-role \ + -e GITHUB_OWNER=your-org \ + vouch-python-agent-multi +``` + +## Credential Types + +- **AWS** -- Calls Vouch's `GET /v1/credentials/aws/token` to get an AWS-specific ID token, then exchanges it for temporary AWS credentials via STS AssumeRoleWithWebIdentity. Requires an IAM role configured to trust the Vouch issuer. Skipped if `AWS_ROLE_ARN` is not set. +- **GitHub** -- Requests a GitHub installation access token from the Vouch credential brokering API. Optionally scoped to a specific owner. Skipped if no GitHub app is configured. +- **SSH** -- Self-contained: generates a fresh Ed25519 keypair, sends the public key to Vouch, and receives a signed SSH certificate. The certificate is valid for the session duration (typically 8 hours). To use the certificate for real SSH connections, write the private key and certificate to files and point your SSH config at them (e.g., `IdentityFile` and `CertificateFile`), or use `vouch setup ssh` which handles this automatically. diff --git a/native/python-agent-multi/agent.py b/native/python-agent-multi/agent.py new file mode 100644 index 0000000..74a5a11 --- /dev/null +++ b/native/python-agent-multi/agent.py @@ -0,0 +1,159 @@ +import os +import sys +import time +import requests + +VOUCH_ISSUER = os.environ.get('VOUCH_ISSUER', 'https://us.vouch.sh') +CLIENT_ID = os.environ.get('VOUCH_CLIENT_ID') +AWS_ROLE_ARN = os.environ.get('AWS_ROLE_ARN') +GITHUB_OWNER = os.environ.get('GITHUB_OWNER') + +if not CLIENT_ID: + print('Error: VOUCH_CLIENT_ID environment variable is required') + sys.exit(1) + +# Step 1: Request device code +response = requests.post( + f'{VOUCH_ISSUER}/oauth/device', + data={ + 'client_id': CLIENT_ID, + 'scope': 'openid email', + }, +) +response.raise_for_status() +device_data = response.json() + +# Step 2: Display instructions to user +print(f"\nTo sign in, visit: {device_data['verification_uri']}") +print(f"Enter code: {device_data['user_code']}\n") + +# Step 3: Poll for token +interval = device_data.get('interval', 5) +while True: + time.sleep(interval) + + token_response = requests.post( + f'{VOUCH_ISSUER}/oauth/token', + data={ + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_data['device_code'], + 'client_id': CLIENT_ID, + }, + ) + + if token_response.status_code == 200: + tokens = token_response.json() + print("Authenticated!") + print(f"Access token: {tokens['access_token'][:20]}...") + break + + error = token_response.json().get('error') + if error == 'authorization_pending': + continue + elif error == 'slow_down': + interval += 5 + elif error == 'expired_token': + print('Device code expired. Please try again.') + sys.exit(1) + elif error == 'access_denied': + print('Access denied by user.') + sys.exit(1) + else: + print(f'Error: {token_response.json()}') + sys.exit(1) + +access_token = tokens['access_token'] + +# ── AWS Credentials ────────────────────────────────────────────────── + +print("\n--- AWS Credentials ---") + +if not AWS_ROLE_ARN: + print("Skipping AWS (AWS_ROLE_ARN not set)") +else: + import boto3 + + aws_resp = requests.get( + f'{VOUCH_ISSUER}/v1/credentials/aws/token', + headers={'Authorization': f'Bearer {access_token}'}, + timeout=10, + ) + aws_resp.raise_for_status() + aws_data = aws_resp.json() + aws_id_token = aws_data['id_token'] + print(f"AWS ID token: {aws_id_token[:20]}...") + + from botocore import UNSIGNED + from botocore.config import Config + + # UNSIGNED prevents boto3 from looking for ambient AWS credentials. + # AssumeRoleWithWebIdentity authenticates via the web identity token. + sts = boto3.client('sts', config=Config(signature_version=UNSIGNED)) + assumed = sts.assume_role_with_web_identity( + RoleArn=AWS_ROLE_ARN, + RoleSessionName='vouch-agent', + WebIdentityToken=aws_id_token, + ) + creds = assumed['Credentials'] + print(f"Assumed role: {assumed['AssumedRoleUser']['Arn']}") + print(f"Expires: {creds['Expiration']}") + +# ── GitHub Token ───────────────────────────────────────────────────── + +print("\n--- GitHub Token ---") + +try: + body = {} + if GITHUB_OWNER: + body['owner'] = GITHUB_OWNER + + gh_response = requests.post( + f'{VOUCH_ISSUER}/v1/credentials/github/token', + headers={'Authorization': f'Bearer {access_token}'}, + json=body, + timeout=10, + ) + gh_response.raise_for_status() + gh_data = gh_response.json() + token_prefix = gh_data.get('token', '')[:20] + print(f"Token: {token_prefix}...") + print(f"Expires at: {gh_data.get('expires_at', 'N/A')}") + print(f"Permissions: {gh_data.get('permissions', {})}") +except requests.RequestException as exc: + print(f"Skipping GitHub ({exc})") + +# ── SSH Certificate ────────────────────────────────────────────────── + +print("\n--- SSH Certificate ---") + +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, +) +from cryptography.hazmat.primitives import serialization + +private_key = Ed25519PrivateKey.generate() +public_key_bytes = private_key.public_key().public_bytes( + serialization.Encoding.OpenSSH, + serialization.PublicFormat.OpenSSH, +) +public_key_str = public_key_bytes.decode('utf-8') + +ssh_response = requests.post( + f'{VOUCH_ISSUER}/v1/credentials/ssh', + headers={'Authorization': f'Bearer {access_token}'}, + json={'public_key': public_key_str}, + timeout=10, +) +ssh_response.raise_for_status() +ssh_data = ssh_response.json() + +certificate = ssh_data.get('certificate', '') +if len(certificate) > 80: + print(f"Certificate: {certificate[:80]}...") +else: + print(f"Certificate: {certificate}") + +if ssh_data.get('principals'): + print(f"Principals: {ssh_data['principals']}") +if ssh_data.get('valid_before'): + print(f"Valid before: {ssh_data['valid_before']}") diff --git a/native/python-agent-multi/requirements.txt b/native/python-agent-multi/requirements.txt new file mode 100644 index 0000000..2174619 --- /dev/null +++ b/native/python-agent-multi/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.32 +boto3>=1.38 +cryptography>=46.0