Skip to content

Commit 332d7fb

Browse files
committed
feat(providers): add OAuth2 credential lifecycle support via credential poll loop
Add OAuth2 token exchange, caching, and refresh to the gateway proxy. The gateway server performs all OAuth2 operations (token exchange, refresh, rotation persistence) via a new TokenVendingService. The sandbox supervisor polls for fresh access tokens on a server-dictated interval, atomically updating the SecretResolver. Core design properties: - Real OAuth2 secrets (client_id, client_secret, refresh_token) never leave the gateway process - Short-lived access tokens follow the existing credential isolation path (placeholder in sandbox, resolved at proxy egress boundary) - Zero overhead for existing static-credential sandboxes (no poll loop spawned when refresh_after_secs=0) - Backward-compatible proto change (new field on existing response) Changes: - proto: add refresh_after_secs field to GetSandboxProviderEnvironmentResponse - openshell-server: new token_vending module with OAuth2 token exchange, per-provider caching with dedup, refresh token rotation persistence - openshell-server: extend resolve_provider_environment() to handle OAuth2 providers, filter internal credentials, compute refresh interval - openshell-server: add OAuth2 config validation at provider creation - openshell-sandbox: SecretResolver uses RwLock for atomic credential updates, add replace_secrets() method - openshell-sandbox: new run_credential_poll_loop() modeled on existing policy poll loop, with adaptive retry on failure - openshell-sandbox: grpc_client returns ProviderEnvironmentResult with refresh_after_secs - openshell-cli: provider get displays Auth method (Static vs OAuth2) - architecture: document OAuth2 lifecycle in sandbox-providers.md
1 parent 5c543af commit 332d7fb

12 files changed

Lines changed: 1630 additions & 75 deletions

File tree

Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] }
7575
# Clipboard (OSC 52)
7676
base64 = "0.22"
7777

78+
# Concurrent data structures
79+
dashmap = "6"
80+
7881
# Utilities
7982
futures = "0.3"
8083
bytes = "1"

architecture/sandbox-providers.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,145 @@ The gateway enforces:
374374

375375
Providers are stored with `object_type = "provider"` in the shared object store.
376376

377+
## OAuth2 Credential Lifecycle
378+
379+
### Overview
380+
381+
Providers can use OAuth2 for credential lifecycle management instead of static tokens.
382+
The gateway server performs all OAuth2 operations (token exchange, refresh, rotation
383+
persistence). The sandbox supervisor polls for fresh access tokens on a server-dictated
384+
interval, atomically updating the `SecretResolver`. Sandboxes with only static credentials
385+
incur zero overhead — no poll loop is spawned.
386+
387+
The core invariant is preserved: real credentials (access tokens, refresh tokens, client
388+
secrets) never enter the sandbox runtime. Child processes see only stable placeholder
389+
strings.
390+
391+
### Configuration
392+
393+
OAuth2 is an auth method, not a provider type. Any provider type (`github`, `gitlab`,
394+
`generic`, etc.) can use OAuth2 by setting config keys:
395+
396+
| Config Key | Required | Example | Purpose |
397+
|---|---|---|---|
398+
| `auth_method` | Yes | `oauth2` | Discriminator (absence means static) |
399+
| `oauth_token_endpoint` | Yes | `https://github.com/login/oauth/access_token` | Token exchange URL (HTTPS only) |
400+
| `oauth_grant_type` | Yes | `refresh_token` or `client_credentials` | OAuth2 flow type |
401+
| `oauth_scopes` | No | `api read_user` | Space-separated scopes |
402+
| `oauth_access_token_env` | No | `MY_TOKEN` | Override output env var name |
403+
404+
OAuth2 secret material is stored in `Provider.credentials`:
405+
406+
| Credential Key | Required For | Purpose |
407+
|---|---|---|
408+
| `OAUTH_CLIENT_ID` | All OAuth2 | Client identifier |
409+
| `OAUTH_CLIENT_SECRET` | All OAuth2 | Client secret |
410+
| `OAUTH_REFRESH_TOKEN` | `refresh_token` grant | Refresh token (may be rotated) |
411+
412+
### CLI Usage
413+
414+
```bash
415+
# Refresh token flow:
416+
openshell provider create \
417+
--name github-oauth --type github \
418+
--credential OAUTH_CLIENT_ID=Iv1.abc123 \
419+
--credential OAUTH_CLIENT_SECRET=secret456 \
420+
--credential OAUTH_REFRESH_TOKEN=ghr_xyz789 \
421+
--config auth_method=oauth2 \
422+
--config oauth_grant_type=refresh_token \
423+
--config oauth_token_endpoint=https://github.com/login/oauth/access_token
424+
425+
# Client credentials flow:
426+
openshell provider create \
427+
--name service-account --type generic \
428+
--credential OAUTH_CLIENT_ID=client-id \
429+
--credential OAUTH_CLIENT_SECRET=client-secret \
430+
--config auth_method=oauth2 \
431+
--config oauth_grant_type=client_credentials \
432+
--config oauth_token_endpoint=https://auth.example.com/oauth2/token
433+
```
434+
435+
### Gateway-Side Token Vending
436+
437+
The `TokenVendingService` (`crates/openshell-server/src/token_vending.rs`) handles all
438+
OAuth2 token exchange, caching, and refresh:
439+
440+
- **Per-provider caching**: access tokens are cached in memory with their TTL.
441+
- **Lazy refresh**: tokens are refreshed when a sandbox calls
442+
`GetSandboxProviderEnvironment` and the cached token is within its safety margin
443+
(`max(60s, ttl * 0.1)` before expiry).
444+
- **Concurrent deduplication**: a per-provider `tokio::sync::Mutex` ensures only one
445+
HTTP request to the IdP runs at a time; concurrent callers await the result.
446+
- **Refresh token rotation**: if the IdP returns a new refresh token, the gateway
447+
persists it to the store via `UpdateProvider`.
448+
449+
The `resolve_provider_environment()` function detects OAuth2 providers via
450+
`config["auth_method"] == "oauth2"`, calls the token vending service, and returns
451+
the access token as a credential entry (e.g., `GITHUB_ACCESS_TOKEN`). OAuth2 internal
452+
credentials (`OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_REFRESH_TOKEN`) are
453+
filtered out and never injected into the sandbox environment.
454+
455+
### Response-Driven Polling
456+
457+
`GetSandboxProviderEnvironmentResponse` includes a `refresh_after_secs` field:
458+
459+
- **0**: all credentials are static — supervisor does not spawn a poll loop.
460+
- **>0**: computed as `min(token_ttl / 2)` across all OAuth2 providers. The supervisor
461+
spawns a background credential poll loop.
462+
463+
### Supervisor Credential Poll Loop
464+
465+
When `refresh_after_secs > 0`, the sandbox supervisor spawns
466+
`run_credential_poll_loop()` (modeled on `run_policy_poll_loop()`):
467+
468+
1. Sleeps for the server-dictated interval.
469+
2. Calls `GetSandboxProviderEnvironment` via `CachedOpenShellClient`.
470+
3. Atomically replaces all `SecretResolver` mappings via `replace_secrets()`.
471+
4. On failure, tightens the retry interval to 30 seconds.
472+
5. On recovery, restores the server-dictated interval.
473+
6. If the server returns `refresh_after_secs == 0`, exits cleanly.
474+
475+
The `SecretResolver` uses `std::sync::RwLock<HashMap>` to allow atomic value replacement
476+
without blocking concurrent reads (credential resolution during request forwarding).
477+
478+
### Credential Isolation
479+
480+
| Secret | Gateway | Supervisor | Child Env | Proxy Wire |
481+
|---|---|---|---|---|
482+
| `OAUTH_CLIENT_ID` | ✅ Store + cache ||||
483+
| `OAUTH_CLIENT_SECRET` | ✅ Store + cache ||||
484+
| `OAUTH_REFRESH_TOKEN` | ✅ Store + cache ||||
485+
| Access token (ephemeral) | ✅ Cache | ✅ SecretResolver | ❌ Placeholder | ✅ Egress |
486+
487+
### End-to-End Flow (OAuth2)
488+
489+
```
490+
CLI: openshell provider create --type github --config auth_method=oauth2 ...
491+
|
492+
+-- Gateway validates OAuth2 config (HTTPS endpoint, required credentials)
493+
+-- Persists Provider with credentials + config
494+
|
495+
CLI: openshell sandbox create --provider github-oauth -- claude
496+
|
497+
+-- Gateway: create_sandbox() validates provider exists
498+
|
499+
Sandbox supervisor: run_sandbox()
500+
+-- GetSandboxProviderEnvironment
501+
| +-- Gateway: resolve_provider_environment()
502+
| | +-- Detects auth_method=oauth2
503+
| | +-- TokenVendingService::get_or_refresh()
504+
| | | +-- POST to oauth_token_endpoint (lazy refresh)
505+
| | | +-- Caches access_token with TTL
506+
| | +-- Returns {GITHUB_ACCESS_TOKEN: "gho-abc123...", refresh_after_secs: 1800}
507+
| | +-- Filters out OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REFRESH_TOKEN
508+
+-- SecretResolver::from_provider_env()
509+
| +-- child env: {GITHUB_ACCESS_TOKEN: "openshell:resolve:env:GITHUB_ACCESS_TOKEN"}
510+
| +-- resolver: {"openshell:resolve:env:GITHUB_ACCESS_TOKEN": "gho-abc123..."}
511+
+-- Spawns credential poll loop (refresh_after_secs=1800 → poll every 30min)
512+
| +-- Every 30min: GetSandboxProviderEnvironment → replace_secrets()
513+
+-- Proxy rewrites outbound headers with current access token
514+
```
515+
377516
## Security Notes
378517

379518
- Provider credentials are stored in `credentials` map and treated as sensitive.
@@ -385,6 +524,11 @@ Providers are stored with `object_type = "provider"` in the shared object store.
385524
placeholders, and the supervisor resolves those placeholders during outbound proxying.
386525
- `OPENSHELL_SSH_HANDSHAKE_SECRET` is required by the supervisor/SSH server path but is
387526
explicitly kept out of spawned sandbox child-process environments.
527+
- OAuth2 long-lived secrets (client ID, client secret, refresh token) never leave the
528+
gateway process. Only short-lived access tokens are sent to the supervisor.
529+
- OAuth2 token endpoints must use HTTPS (enforced at provider creation).
530+
- Token endpoint responses are validated: access token values pass through
531+
`validate_resolved_secret()` to reject header-injection characters.
388532

389533
## Test Strategy
390534

@@ -396,3 +540,7 @@ Providers are stored with `object_type = "provider"` in the shared object store.
396540
- sandbox unit tests validate placeholder generation and header rewriting.
397541
- E2E sandbox tests verify placeholders are visible in child env, outbound proxy traffic
398542
is rewritten with the real secret, and the SSH handshake secret is absent from exec env.
543+
- OAuth2 token vending unit tests in `crates/openshell-server/src/token_vending.rs`:
544+
mock HTTP server tests for token exchange, caching, rotation, and error handling.
545+
- `SecretResolver` concurrency tests validate `replace_secrets()` under concurrent
546+
read/write access.

crates/openshell-cli/src/run.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3450,11 +3450,27 @@ pub async fn provider_get(server: &str, name: &str, tls: &TlsOptions) -> Result<
34503450
let credential_keys = provider.credentials.keys().cloned().collect::<Vec<_>>();
34513451
let config_keys = provider.config.keys().cloned().collect::<Vec<_>>();
34523452

3453+
// Derive auth method display.
3454+
let auth_display = if provider
3455+
.config
3456+
.get("auth_method")
3457+
.is_some_and(|v| v == "oauth2")
3458+
{
3459+
let grant = provider
3460+
.config
3461+
.get("oauth_grant_type")
3462+
.map_or("unknown", |v| v.as_str());
3463+
format!("OAuth2 ({grant})")
3464+
} else {
3465+
"Static".to_string()
3466+
};
3467+
34533468
println!("{}", "Provider:".cyan().bold());
34543469
println!();
34553470
println!(" {} {}", "Id:".dimmed(), provider.id);
34563471
println!(" {} {}", "Name:".dimmed(), provider.name);
34573472
println!(" {} {}", "Type:".dimmed(), provider.r#type);
3473+
println!(" {} {}", "Auth:".dimmed(), auth_display);
34583474
println!(
34593475
" {} {}",
34603476
"Credential keys:".dimmed(),

crates/openshell-sandbox/src/grpc_client.rs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,22 @@ pub async fn sync_policy(endpoint: &str, sandbox: &str, policy: &ProtoSandboxPol
181181
sync_policy_with_client(&mut client, sandbox, policy).await
182182
}
183183

184+
/// Result of fetching provider environment.
185+
pub struct ProviderEnvironmentResult {
186+
/// Credential environment variables (key → secret value).
187+
pub environment: HashMap<String, String>,
188+
/// Seconds until next refresh. 0 = static credentials only.
189+
pub refresh_after_secs: u32,
190+
}
191+
184192
/// Fetch provider environment variables for a sandbox from OpenShell server via gRPC.
185193
///
186-
/// Returns a map of environment variable names to values derived from provider
187-
/// credentials configured on the sandbox. Returns an empty map if the sandbox
188-
/// has no providers or the call fails.
194+
/// Returns credential env vars and a refresh interval. A `refresh_after_secs`
195+
/// of 0 means all credentials are static and no polling is needed.
189196
pub async fn fetch_provider_environment(
190197
endpoint: &str,
191198
sandbox_id: &str,
192-
) -> Result<HashMap<String, String>> {
199+
) -> Result<ProviderEnvironmentResult> {
193200
debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Fetching provider environment");
194201

195202
let mut client = connect(endpoint).await?;
@@ -201,7 +208,11 @@ pub async fn fetch_provider_environment(
201208
.await
202209
.into_diagnostic()?;
203210

204-
Ok(response.into_inner().environment)
211+
let inner = response.into_inner();
212+
Ok(ProviderEnvironmentResult {
213+
environment: inner.environment,
214+
refresh_after_secs: inner.refresh_after_secs,
215+
})
205216
}
206217

207218
/// A reusable gRPC client for the OpenShell service.
@@ -221,7 +232,7 @@ pub struct SettingsPollResult {
221232
pub config_revision: u64,
222233
pub policy_source: PolicySource,
223234
/// Effective settings keyed by name.
224-
pub settings: std::collections::HashMap<String, openshell_core::proto::EffectiveSetting>,
235+
pub settings: HashMap<String, openshell_core::proto::EffectiveSetting>,
225236
/// When `policy_source` is `Global`, the version of the global policy revision.
226237
pub global_policy_version: u32,
227238
}
@@ -264,6 +275,27 @@ impl CachedOpenShellClient {
264275
})
265276
}
266277

278+
/// Fetch provider environment for credential refresh polling.
279+
pub async fn fetch_provider_environment(
280+
&self,
281+
sandbox_id: &str,
282+
) -> Result<ProviderEnvironmentResult> {
283+
let response = self
284+
.client
285+
.clone()
286+
.get_sandbox_provider_environment(GetSandboxProviderEnvironmentRequest {
287+
sandbox_id: sandbox_id.to_string(),
288+
})
289+
.await
290+
.into_diagnostic()?;
291+
292+
let inner = response.into_inner();
293+
Ok(ProviderEnvironmentResult {
294+
environment: inner.environment,
295+
refresh_after_secs: inner.refresh_after_secs,
296+
})
297+
}
298+
267299
/// Submit denial summaries for policy analysis.
268300
pub async fn submit_policy_analysis(
269301
&self,

0 commit comments

Comments
 (0)