Skip to content

Commit 0be82d4

Browse files
committed
feat(oauth): add bidirectional OAuth phantom token swap
Extend sluice's phantom token system to handle OAuth credentials bidirectionally. Previously, phantom swap was request-only (phantom to real in outbound requests). This adds response-side interception: when an OAuth token endpoint returns new access/refresh tokens, sluice captures the real tokens, stores them in the vault, and replaces them with phantom tokens in the response body before it reaches the agent. Key changes: Vault: new OAuthCredential type storing access_token, refresh_token, token_url, and expires_at as JSON blob in age-encrypted files. Phantom tokens are deterministic (SLUICE_PHANTOM:name.access/refresh). Store: new credential_meta table (migration 000002) tracking credential type and token_url. CRUD operations with upsert semantics. CLI: --type oauth and --token-url flags on sluice cred add. Tokens read from stdin to avoid shell history exposure. Two phantom files per OAuth credential (CRED_ACCESS, CRED_REFRESH). Proxy: OAuthIndex maps token URLs to credential names for fast lookup during response interception. Response handler uses singleflight for concurrent refresh dedup. Supports both JSON and form-encoded token responses per RFC 6749. Request-side swap extended to handle OAuth phantom pairs alongside static credentials. API: OpenAPI spec and REST handlers updated with type/token_url fields for credential CRUD endpoints. Hot-reload: OAuth index rebuilt on StoreResolver calls. Async vault writes after response interception do not block HTTP response delivery.
1 parent 348f0cb commit 0be82d4

33 files changed

Lines changed: 5327 additions & 283 deletions

CLAUDE.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ sluice mcp list
7373
sluice mcp remove <name>
7474
sluice mcp # start MCP gateway
7575
76-
sluice cred add <name> [--destination host] [--ports 443] [--header Authorization] [--template "Bearer {value}"]
76+
sluice cred add <name> [--type static|oauth] [--destination host] [--ports 443] [--header Authorization] [--template "Bearer {value}"]
77+
sluice cred add <name> --type oauth --token-url <url> --destination <host> --ports 443
7778
sluice cred list
7879
sluice cred remove <name>
7980
@@ -83,13 +84,15 @@ sluice audit verify # verify audit log hash chain integrity
8384

8485
When `--destination` is provided, `sluice cred add` also creates an allow rule and binding in the store. For HTTP/WebSocket upstreams, `--command` holds the URL. Env values prefixed with `vault:` are resolved from the credential vault at upstream spawn time.
8586

87+
Two credential types: `static` (default) for API keys and `oauth` for OAuth access/refresh token pairs. OAuth credentials prompt for tokens via stdin (not CLI flags) to avoid shell history exposure.
88+
8689
## Policy Store
8790

8891
All runtime policy state in SQLite (default: `sluice.db`). TOML files for initial seeding only via `sluice policy import`. See `examples/config.toml` for the full seed format.
8992

9093
Rules use `[[allow]]`/`[[deny]]`/`[[ask]]`/`[[redact]]` sections. Each entry carries exactly one of: `destination` (network), `tool` (MCP), or `pattern` (content inspection). The `rules` table uses a CHECK constraint enforcing mutual exclusivity of these columns. Import uses merge semantics (skip duplicates).
9194

92-
Store uses `modernc.org/sqlite` (pure Go, no CGO), WAL mode, `golang-migrate` for schema. 5 tables: `rules`, `config`, `bindings`, `mcp_upstreams`, `channels`.
95+
Store uses `modernc.org/sqlite` (pure Go, no CGO), WAL mode, `golang-migrate` for schema. 6 tables: `rules`, `config`, `bindings`, `mcp_upstreams`, `channels`, `credential_meta`.
9396

9497
## Credential Injection: Phantom Token Swap
9598

@@ -104,6 +107,27 @@ Three-pass injection in MITM: (1) binding-specific header injection, (2) scoped
104107

105108
All HTTPS connections are MITMed (not just those with bindings) so phantom tokens can never leak upstream. `SecureBytes.Release()` zeroes credentials immediately after injection.
106109

110+
### OAuth dynamic phantom swap
111+
112+
Extends phantom swap to handle OAuth credentials bidirectionally. Static credentials are request-only (phantom -> real). OAuth credentials add response-side interception for transparent token lifecycle management.
113+
114+
**Request side:** OAuth credentials produce two phantom pairs. `SLUICE_PHANTOM:cred.access` and `SLUICE_PHANTOM:cred.refresh` are swapped to real tokens in outbound requests. Works alongside static phantom pairs in the same three-pass injection.
115+
116+
**Response side:** When an OAuth token endpoint returns new tokens, sluice intercepts the response. Real tokens are replaced with deterministic phantoms before the response reaches the agent. Vault is updated asynchronously. If the vault write fails, the agent still receives phantom tokens (not real ones). The next refresh cycle corrects the state.
117+
118+
**Concurrent refresh protection:** `singleflight` keyed on credential name deduplicates async vault writes when multiple requests trigger simultaneous token refreshes. Each response is independently processed (phantom swap happens per-response), but vault persistence is deduplicated.
119+
120+
**Data model:** `credential_meta` table stores credential type and token_url. `OAuthIndex` maps token URLs to credential names for fast response matching. Both are hot-reloaded via `StoreResolver()`.
121+
122+
**Phantom file generation:** `GeneratePhantomEnv(credNames []string, providers ...Provider)` accepts an optional `Provider` to detect OAuth credentials. When a provider is given, OAuth credentials produce two files (`CRED_ACCESS`, `CRED_REFRESH`) instead of one. Callers without a provider get static phantom files only.
123+
124+
**Key files:**
125+
- `internal/vault/oauth.go` -- OAuthCredential struct, parse/marshal, token update
126+
- `internal/vault/phantom.go` -- `GeneratePhantomEnv`, `WriteOAuthPhantoms`
127+
- `internal/proxy/oauth_index.go` -- Token URL index for response matching
128+
- `internal/proxy/oauth_response.go` -- Response interception, phantom swap, async vault persistence
129+
- `internal/store/migrations/000002_credential_meta.up.sql` -- Schema for credential metadata
130+
107131
### Protocol-specific handling
108132

109133
| Protocol | Credential injection | Content inspection |

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ AI agents need credentials to be useful. Giving them real credentials is dangero
2222
| **MCP Gateway** | Tool names, arguments, responses | File writes, exec, deletions, any MCP tool call |
2323
| **SOCKS5 Proxy** | Every TCP and UDP connection | HTTP, HTTPS, WebSocket, gRPC, SSH, IMAP, SMTP, DNS, QUIC/HTTP3 |
2424

25-
**Phantom token swap:** OpenClaw gets phantom tokens that look like real API keys. Sluice's MITM proxy swaps them for real credentials in-flight. If a phantom token leaks, it is useless outside the proxy.
25+
**Phantom token swap:** OpenClaw gets phantom tokens that look like real API keys. Sluice's MITM proxy swaps them for real credentials in-flight. If a phantom token leaks, it is useless outside the proxy. OAuth credentials are handled bidirectionally: sluice intercepts token endpoint responses, captures real tokens, and returns phantom tokens to the agent. The entire OAuth lifecycle (initial auth, token refresh, token rotation) is transparent.
2626

2727
**Human approval:** Connections and tool calls matching "ask" policy rules trigger a notification via Telegram or HTTP webhook. OpenClaw blocks until a human responds with Allow or Deny.
2828

@@ -233,6 +233,43 @@ Sluice supports multiple credential backends. Set `provider` in `[vault]` config
233233

234234
Chain multiple providers with `providers = ["1password", "age"]`. First provider with the credential wins.
235235

236+
## OAuth Token Management
237+
238+
Sluice handles OAuth access and refresh tokens transparently through the phantom swap system. The agent never sees real tokens at any point in the OAuth lifecycle.
239+
240+
**Adding OAuth credentials:**
241+
242+
```bash
243+
# Tokens are read from stdin (not CLI flags) to avoid shell history exposure
244+
sluice cred add openai_oauth \
245+
--type oauth \
246+
--token-url https://auth0.openai.com/oauth/token \
247+
--destination api.openai.com \
248+
--ports 443
249+
# Prompts for: access token, refresh token (optional)
250+
```
251+
252+
**Listing credentials shows the type:**
253+
254+
```
255+
$ sluice cred list
256+
NAME TYPE DESTINATION
257+
openai_oauth oauth api.openai.com
258+
github_pat static api.github.com
259+
```
260+
261+
**How it works:**
262+
263+
1. Sluice stores real tokens in the vault and generates deterministic phantom tokens
264+
2. The agent receives phantom tokens and uses them normally with any SDK
265+
3. On outbound requests, sluice swaps phantom tokens for real tokens (same as static credentials)
266+
4. On token endpoint responses, sluice intercepts the response, captures new real tokens, and replaces them with phantoms before the response reaches the agent
267+
5. The vault is updated asynchronously. If the write fails, the agent still sees only phantom tokens
268+
269+
**Token refresh and rotation:** When an access token expires and the agent (or SDK) sends a refresh request, sluice swaps the phantom refresh token for the real one, forwards the request, intercepts the response with new tokens, and returns phantoms. Concurrent refresh requests are deduplicated so only one vault update occurs per credential.
270+
271+
**Supported response formats:** Both `application/json` and `application/x-www-form-urlencoded` token responses per RFC 6749.
272+
236273
## Approval Channels
237274

238275
Sluice broadcasts "ask" verdicts to all configured approval channels. The first channel to respond wins. Other channels get a cancellation notice.
@@ -255,6 +292,18 @@ Manage sluice from your phone. Approve connections and tool calls, add credentia
255292

256293
REST API on port 3000 for programmatic approval integration. `GET /api/approvals` lists pending requests, `POST /api/approvals/{id}/resolve` resolves them. Use this to build custom approval UIs or integrate with existing workflows.
257294

295+
Credential management endpoints support both static and OAuth types:
296+
297+
```bash
298+
# Add static credential
299+
curl -X POST http://localhost:3000/api/credentials \
300+
-d '{"name":"github_pat","value":"ghp_xxx","destination":"api.github.com"}'
301+
302+
# Add OAuth credential
303+
curl -X POST http://localhost:3000/api/credentials \
304+
-d '{"name":"openai_oauth","type":"oauth","token_url":"https://auth.example.com/token","access_token":"at-xxx","refresh_token":"rt-xxx","destination":"api.openai.com"}'
305+
```
306+
258307
## Audit Log
259308

260309
Tamper-evident JSON Lines log with blake3 hash chaining. Every connection, tool call, approval, and denial is recorded.

api/openapi.yaml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -716,16 +716,37 @@ components:
716716
properties:
717717
name:
718718
type: string
719+
type:
720+
type: string
721+
enum: [static, oauth]
722+
description: Credential type (static or oauth)
723+
token_url:
724+
type: string
725+
description: Token endpoint URL (only for oauth type)
719726

720727
CreateCredentialRequest:
721728
type: object
722-
required: [name, value]
729+
required: [name]
723730
properties:
724731
name:
725732
type: string
726733
value:
727734
type: string
728-
description: The credential secret value (stored encrypted)
735+
description: The credential secret value (stored encrypted). Required for static type.
736+
type:
737+
type: string
738+
enum: [static, oauth]
739+
default: static
740+
description: Credential type. Defaults to static.
741+
token_url:
742+
type: string
743+
description: Token endpoint URL (required when type is oauth)
744+
access_token:
745+
type: string
746+
description: OAuth access token (required when type is oauth)
747+
refresh_token:
748+
type: string
749+
description: OAuth refresh token (optional, for token rotation)
729750
destination:
730751
type: string
731752
description: If set, also creates an allow rule and binding

0 commit comments

Comments
 (0)