A Rust service that validates ID tokens from third-party OIDC providers and exchanges them for self-issued access and refresh tokens. Built with hexagonal architecture for pluggable infrastructure, configurable via TOML, and deployable as a Lambda function or long-lived server from a single binary.
One-line install script (Linux/macOS, downloads the latest release binary):
curl -fsSL https://raw.githubusercontent.com/antstanley/oidc-exchange/main/install.sh | shDocker:
docker pull ghcr.io/antstanley/oidc-exchange:latest
docker run -p 8080:8080 ghcr.io/antstanley/oidc-exchange:latestNode.js (npm):
npm install @oidc-exchange/nodePython (pip):
pip install oidc-exchangeIf your application needs to authenticate users via Google, Apple, or other OIDC providers, you typically have three choices: a hosted auth service (Auth0, Cognito, Firebase Auth), a full-blown self-hosted OIDC server (Keycloak, Dex, Ory Hydra), or rolling your own token validation. Each comes with trade-offs that oidc-exchange is designed to avoid.
Hosted services are convenient but introduce external dependencies that affect cost, latency, and control:
- No per-MAU pricing — oidc-exchange runs on your own infrastructure. You pay for compute and storage, not per authenticated user.
- No vendor lock-in — your user data stays in your database, your tokens are signed with your keys, and your configuration is a TOML file in your repo.
- No opaque behavior — every decision (registration policy, claims mapping, token lifetime) is explicit in configuration. There are no hidden rules or console toggles to discover in production.
- Lower latency — token exchange happens in-process or within your VPC. There is no round-trip to a third-party service on every authentication.
Full OIDC servers are designed to be the identity provider — they manage user credentials, host login pages, and implement the full OAuth 2.0 authorization server spec. If you are delegating authentication to external providers and just need to issue your own tokens, they are dramatically over-scoped:
- No login UI to maintain — oidc-exchange does not host login pages or manage passwords. Your client handles the provider's OAuth flow and sends the resulting code or ID token. The service validates and exchanges.
- No session management — there are no server-side sessions, cookies, or consent screens. You get a JWT and a refresh token.
- Single-purpose — the entire codebase does one thing: validate upstream identity, issue downstream tokens. This makes it auditable, testable, and operationally simple.
- Minutes to deploy, not days — a single binary, a TOML config, and a DynamoDB table. No database migrations, no admin consoles, no clustering configuration.
Writing token validation and JWT issuance from scratch is straightforward until it isn't:
- Provider quirks handled — Apple requires generating a per-request ES256 client JWT instead of using a static client secret. Standard OIDC libraries don't account for this. oidc-exchange does.
- Security defaults — refresh tokens are stored hashed (SHA-256), access tokens are short-lived, registration policy enforcement and domain allowlists are built in.
- Audit trail included — every token exchange, revocation, and user event can be logged with syslog severity levels (stdout/stderr, with an SQS adapter for durable pipelines). Adding this after the fact is painful.
- Hexagonal architecture — swapping DynamoDB for Postgres or KMS for Vault means implementing a trait, not rewriting the service.
oidc-exchange is not a general-purpose authorization server. Choose a different tool if you need:
- Password-based authentication — oidc-exchange delegates authentication entirely to upstream providers.
- OAuth 2.0 authorization server — if you need to issue tokens to third-party clients with scopes and consent, use a full OIDC server.
- Multi-tenant SaaS auth — if you need organization management, RBAC, or SCIM provisioning, a hosted service like Auth0 or WorkOS is better suited.
- Federation between internal services — if you need service-to-service authentication (mTLS, SPIFFE), oidc-exchange is the wrong layer.
- Token Exchange — accepts authorization codes from OIDC providers, validates ID tokens, issues short-lived JWTs (default 15min) and long-lived refresh tokens (default 30 days)
- Pluggable Providers — three tiers: standard OIDC (Google, config-only), OIDC-with-quirks (Apple, ES256 client JWT), and non-OIDC (atproto, planned)
- Hexagonal Architecture — all infrastructure behind trait interfaces: database, key management, audit, user sync
- Registration Policy — open or existing-users-only mode with optional email domain/subdomain allowlists
- Per-User Claims — configurable custom JWT claims from TOML templates and per-user overrides via internal API
- Audit Trail — syslog severity levels, configurable blocking threshold, stdout/stderr sinks with an SQS adapter for durable pipelines
- OpenTelemetry — pluggable exporters (OTLP, X-Ray, stdout) via
tracingecosystem - Dual Runtime — same binary runs as an axum server or AWS Lambda function
- Internal Admin API — user CRUD and claims management with shared-secret authentication
crates/
├── core/ # Domain types, port traits, service logic (zero infra deps)
├── adapters/ # DynamoDB, KMS, SQS, OIDC, webhook implementations
├── providers/ # Non-standard provider modules (Apple; atproto planned)
├── server/ # Axum routes, middleware, telemetry, bootstrap
└── test-utils/ # Mock implementations for all ports
| Port | Purpose | Adapters |
|---|---|---|
Repository |
User and session storage | DynamoDB |
KeyManager |
JWT signing | Local (Ed25519), AWS KMS |
AuditLog |
Compliance event logging | Noop, Stdout/Stderr, SQS |
IdentityProvider |
OIDC provider interaction | Standard OIDC, Apple |
UserSync |
Bidirectional user sync | Webhook, Noop |
- Rust 1.75+
- cargo-nextest for testing
cargo build --releaseCreate a config.toml (or set OIDC_EXCHANGE_ENV to load config/{env}.toml):
[server]
host = "0.0.0.0"
port = 8080
issuer = "https://auth.example.com"
[registration]
mode = "open"
# domain_allowlist = ["example.com", "*.acme.corp"]
[token]
access_token_ttl = "15m"
refresh_token_ttl = "30d"
audience = "https://api.example.com"
[token.custom_claims]
org = "example"
role = "{{ user.metadata.role | default: 'user' }}"
[key_manager]
adapter = "local"
[key_manager.local]
private_key_path = "./keys/ed25519.pem"
algorithm = "EdDSA"
kid = "key-1"
[repository]
adapter = "dynamodb"
[repository.dynamodb]
table_name = "oidc-exchange"
[audit]
adapter = "noop"
blocking_threshold = "warning"
[providers.google]
adapter = "oidc"
issuer = "https://accounts.google.com"
client_id = "${GOOGLE_CLIENT_ID}"
client_secret = "${GOOGLE_CLIENT_SECRET}"
scopes = ["openid", "email", "profile"]./target/release/oidc-exchangeThe server starts on the configured host/port. Set AWS_LAMBDA_RUNTIME_API to run in Lambda mode.
| Method | Path | Description |
|---|---|---|
| POST | /token |
Token exchange (grant_type=authorization_code) and refresh (grant_type=refresh_token) |
| POST | /revoke |
Token revocation (RFC 7009) |
| GET | /keys |
JWKS endpoint |
| GET | /.well-known/openid-configuration |
OpenID Connect discovery |
| GET | /health |
Health check |
| Method | Path | Description |
|---|---|---|
| POST | /internal/users |
Create user |
| GET | /internal/users/{id} |
Get user |
| PATCH | /internal/users/{id} |
Update user |
| DELETE | /internal/users/{id} |
Soft-delete user |
| GET | /internal/users/{id}/claims |
Get user claims |
| PUT | /internal/users/{id}/claims |
Replace user claims |
| PATCH | /internal/users/{id}/claims |
Merge user claims |
| DELETE | /internal/users/{id}/claims |
Clear user claims |
Client → Authorization Code + Provider → POST /token
→ Provider validates code, returns ID token
→ Service validates ID token (signature, iss, aud, exp)
→ Registration policy check (domain allowlist, mode)
→ User lookup/creation
→ Generate refresh token (256-bit random, stored hashed)
→ Sign access token JWT (short-lived)
→ Return { access_token, refresh_token, token_type, expires_in }
Config is loaded in order:
config/default.tomlconfig/{OIDC_EXCHANGE_ENV}.toml(if set)- Environment variable overrides:
OIDC_EXCHANGE__{section}__{key} ${VAR_NAME}placeholder resolution from environment
See config/default.toml for the minimal default configuration.
# Run all tests
cargo nextest run --workspace
# Run only core logic tests
cargo nextest run -p oidc-exchange-core
# Run adapter tests (some require Docker)
cargo nextest run -p oidc-exchange-adapters
# Run server/E2E tests
cargo nextest run -p oidc-exchange
# Run DynamoDB integration tests (requires DynamoDB Local)
docker run -p 8000:8000 amazon/dynamodb-local
cargo nextest run -p oidc-exchange-adapters -- --ignoredSee docs/integration/ for detailed deployment guides:
| Guide | Best for |
|---|---|
| AWS Lambda | Serverless, pay-per-request |
| ECS Fargate | Auto-scaling containers with ALB |
| Linux + PostgreSQL | Relational storage, optional Valkey |
| Linux + SQLite | Single-server, zero dependencies |
| Generic Container | K8s, Cloud Run, any orchestrator |
| Generic Linux | On-prem, simple single-server |
oidc-exchange/
├── Cargo.toml # Workspace root
├── .config/nextest.toml # Test runner config
├── config/default.toml # Default configuration
├── schemas/
│ ├── datamodel.schema.json # Generic domain model (adapter-agnostic)
│ └── dynamodb/table-design.json # DynamoDB single-table design
├── crates/
│ ├── core/ # Domain + ports + service logic
│ ├── adapters/ # Infrastructure implementations
│ ├── providers/ # Non-standard OIDC providers
│ ├── server/ # HTTP layer + bootstrap
│ └── test-utils/ # Mock implementations
└── docs/
├── integration/ # Deployment guides
├── contributing.md # Contributing guide
└── superpowers/
├── specs/ # Design specification
└── plans/ # Implementation plan
MIT