From d3342f10ea589c2fbdf50704b6cd3904228369f5 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 12:20:09 -0700 Subject: [PATCH 01/22] docs: add OIDC discovery-driven auth rework design Co-Authored-By: Claude Opus 4.6 --- .../2026-03-12-oidc-discovery-auth-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/plans/2026-03-12-oidc-discovery-auth-design.md diff --git a/docs/plans/2026-03-12-oidc-discovery-auth-design.md b/docs/plans/2026-03-12-oidc-discovery-auth-design.md new file mode 100644 index 0000000..142043d --- /dev/null +++ b/docs/plans/2026-03-12-oidc-discovery-auth-design.md @@ -0,0 +1,129 @@ +# OIDC Discovery-Driven Auth Rework + +## Summary + +Replace the current single-flow (Authorization Code + PKCE) auth with a discovery-driven system that reads the provider's OIDC discovery document and selects the best available flow. Adds Device Code and Refresh Token flows. + +## Decisions + +- **Scope**: Source Coop focused. STS exchange always required. Source Coop defaults retained. +- **Flows**: Authorization Code + PKCE, Device Code (RFC 8628), Refresh Token. +- **Flow priority**: device code (if provider supports it) > auth code. Override with `--flow`. +- **Refresh token storage**: Separate keyring entry (with file fallback), keyed by issuer. +- **Auto-refresh**: `source-coop creds` silently refreshes expired AWS credentials using cached refresh token. +- **Approach**: Modular flow architecture. Each flow is a separate module returning an ID token. + +## Architecture + +``` +source-coop login + | + v + Discovery Fetch & parse .well-known/openid-configuration + | + v + Flow Select --flow flag > device_code > auth_code + | + v + Flow Execution auth_code | device_code | refresh + | Each returns id_token + optional refresh_token + v + STS Exchange Unchanged: id_token -> AssumeRoleWithWebIdentity + | + v + Cache + Output Store AWS creds + refresh token separately +``` + +## Components + +### Discovery + +Parse the full OIDC discovery document: + +```rust +pub struct OidcDiscovery { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub device_authorization_endpoint: Option, + pub revocation_endpoint: Option, + pub userinfo_endpoint: Option, + pub grant_types_supported: Vec, + pub scopes_supported: Vec, + pub code_challenge_methods_supported: Vec, +} +``` + +`device_authorization_endpoint` is optional since not all providers expose it. `grant_types_supported` drives flow selection. + +### Flow Selection + +Priority: + +1. `--flow device-code` -> device code (error if unsupported) +2. `--flow auth-code` -> auth code (error if unsupported) +3. `--flow auto` (default): device code if `device_code` in `grant_types_supported` AND `device_authorization_endpoint` present, else auth code + PKCE + +### Device Code Flow (RFC 8628) + +1. POST `device_authorization_endpoint` with `client_id`, `scope` +2. Receive `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `interval`, `expires_in` +3. Display: "Visit {verification_uri} and enter code: {user_code}". Open `verification_uri_complete` in browser. +4. Poll `token_endpoint` every `interval` seconds with `grant_type=urn:ietf:params:oauth:grant-type:device_code` +5. Handle: `authorization_pending` (continue), `slow_down` (increase interval), `expired_token` (error), success (extract `id_token`) + +### Auth Code Flow (existing, reorganized) + +Existing PKCE flow moves into its own module. No functional changes. + +### Refresh Token + +**Storage**: Separate keyring entry keyed as `source-coop-cli:refresh:{issuer_hash}`. File fallback at `~/.cache/source-coop/refresh/{issuer_hash}.json` (0600 permissions). + +**During login**: Cache `refresh_token` from token response if present. + +**During `creds`**: If AWS creds expired and refresh token exists: +1. POST `token_endpoint` with `grant_type=refresh_token`, `refresh_token`, `client_id` +2. Get new `id_token` (and possibly rotated `refresh_token`) +3. STS exchange -> new AWS creds -> cache + +**Scope**: Default scope becomes `openid offline_access` to request refresh tokens. + +### CLI Changes + +**`login` additions:** +- `--flow ` (default: `auto`) +- Default scope: `openid offline_access` + +**`creds` additions:** +- Auto-refresh on expired creds (silent, using cached refresh token) +- `--no-refresh` flag to skip auto-refresh + +**New `logout` command:** +- Revoke refresh token via revocation endpoint +- Clear cached AWS credentials +- Clear cached refresh token + +### File Structure + +``` +src/ + main.rs CLI args, command dispatch + oidc/ + mod.rs Discovery, flow selection, OidcDiscovery struct + auth_code.rs Authorization Code + PKCE flow (moved from oidc.rs) + device_code.rs Device Code flow (new) + refresh.rs Refresh token handling (new) + sts.rs Unchanged + cache.rs Extended with refresh token storage + output.rs Unchanged +``` + +## Provider Reference + +Source Coop's OIDC discovery (`https://auth.source.coop/.well-known/openid-configuration`) advertises: +- `grant_types_supported`: authorization_code, implicit, client_credentials, refresh_token, device_code +- `device_authorization_endpoint`: present +- `revocation_endpoint`: present +- `scopes_supported`: openid, offline_access, offline +- `code_challenge_methods_supported`: plain, S256 From 599f3f80d6a51129ce1fdc039c10af45c58d0546 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:32:56 -0700 Subject: [PATCH 02/22] docs: add OIDC discovery auth implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-12-oidc-discovery-auth-plan.md | 1434 +++++++++++++++++ 1 file changed, 1434 insertions(+) create mode 100644 docs/plans/2026-03-12-oidc-discovery-auth-plan.md diff --git a/docs/plans/2026-03-12-oidc-discovery-auth-plan.md b/docs/plans/2026-03-12-oidc-discovery-auth-plan.md new file mode 100644 index 0000000..ca036cc --- /dev/null +++ b/docs/plans/2026-03-12-oidc-discovery-auth-plan.md @@ -0,0 +1,1434 @@ +# OIDC Discovery-Driven Auth Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace single-flow auth with a discovery-driven system supporting Authorization Code + PKCE, Device Code, and Refresh Token flows. + +**Architecture:** Parse the full OIDC discovery document, select the best flow (device code preferred, auth code fallback, user override via `--flow`), execute that flow to get an id_token + optional refresh_token, then STS exchange for AWS credentials. Refresh tokens cached separately in keyring enable silent credential renewal. + +**Tech Stack:** Rust, reqwest, serde, clap, keyring, tokio, sha2, base64, rand + +--- + +### Task 1: Restructure oidc.rs into oidc/ module with expanded discovery + +**Files:** +- Delete: `src/oidc.rs` +- Create: `src/oidc/mod.rs` +- Create: `src/oidc/auth_code.rs` + +**Step 1: Create the oidc directory and mod.rs with expanded OidcDiscovery struct** + +Create `src/oidc/mod.rs`: + +```rust +pub mod auth_code; + +use serde::Deserialize; + +/// Parsed OIDC discovery document. +#[derive(Debug, Clone, Deserialize)] +pub struct OidcDiscovery { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub device_authorization_endpoint: Option, + pub revocation_endpoint: Option, + pub grant_types_supported: Option>, + pub scopes_supported: Option>, + pub code_challenge_methods_supported: Option>, +} + +impl OidcDiscovery { + pub fn supports_grant_type(&self, grant_type: &str) -> bool { + self.grant_types_supported + .as_ref() + .map(|types| types.iter().any(|t| t == grant_type)) + .unwrap_or(false) + } + + pub fn supports_device_code(&self) -> bool { + self.supports_grant_type("urn:ietf:params:oauth:grant-type:device_code") + || self.supports_grant_type("device_code") + } +} + +/// Token response from the OIDC provider. +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub id_token: Option, + pub refresh_token: Option, + pub access_token: Option, + pub token_type: Option, + pub expires_in: Option, +} + +/// Fetch and parse the OIDC discovery document. +pub async fn discover(issuer: &str, verbose: bool) -> Result { + let discovery_url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + + if verbose { + eprintln!("[verbose] GET {discovery_url}"); + } + + let resp = reqwest::get(&discovery_url) + .await + .map_err(|e| format!("Failed to fetch OIDC discovery document: {e}"))?; + + if verbose { + eprintln!("[verbose] Response: {}", resp.status()); + } + + if !resp.status().is_success() { + return Err(format!("OIDC discovery returned status {}", resp.status())); + } + + let discovery: OidcDiscovery = resp + .json() + .await + .map_err(|e| format!("Failed to parse OIDC discovery document: {e}"))?; + + if verbose { + eprintln!("[verbose] Authorization endpoint: {}", discovery.authorization_endpoint); + eprintln!("[verbose] Token endpoint: {}", discovery.token_endpoint); + if let Some(ref dae) = discovery.device_authorization_endpoint { + eprintln!("[verbose] Device authorization endpoint: {dae}"); + } + if let Some(ref grants) = discovery.grant_types_supported { + eprintln!("[verbose] Supported grant types: {}", grants.join(", ")); + } + } + + Ok(discovery) +} +``` + +**Step 2: Move existing auth code flow into auth_code.rs** + +Create `src/oidc/auth_code.rs` with the existing flow logic, adapted to use `OidcDiscovery` and return `TokenResponse`: + +```rust +use super::{OidcDiscovery, TokenResponse}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use rand::Rng; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use tokio::net::TcpListener; +use url::Url; + +struct Pkce { + verifier: String, + challenge: String, +} + +fn generate_pkce() -> Pkce { + let mut rng = rand::thread_rng(); + let bytes: Vec = (0..32).map(|_| rng.gen()).collect(); + let verifier = URL_SAFE_NO_PAD.encode(&bytes); + + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + Pkce { + verifier, + challenge, + } +} + +/// Run the Authorization Code + PKCE flow. Returns a TokenResponse. +pub async fn login( + discovery: &OidcDiscovery, + client_id: &str, + scope: &str, + port: u16, + verbose: bool, +) -> Result { + let pkce = generate_pkce(); + let state: String = URL_SAFE_NO_PAD.encode(rand::thread_rng().gen::<[u8; 16]>()); + + // Bind local callback server + let listener = TcpListener::bind(format!("127.0.0.1:{port}")) + .await + .map_err(|e| format!("Failed to bind local server: {e}"))?; + + let local_addr = listener + .local_addr() + .map_err(|e| format!("Failed to get local address: {e}"))?; + let redirect_uri = format!("http://127.0.0.1:{}/callback", local_addr.port()); + + if verbose { + eprintln!("[verbose] Callback server listening on {local_addr}"); + eprintln!("[verbose] Redirect URI: {redirect_uri}"); + } + + // Build authorization URL + let mut auth_url = Url::parse(&discovery.authorization_endpoint) + .map_err(|e| format!("Invalid authorization endpoint URL: {e}"))?; + auth_url + .query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("scope", scope) + .append_pair("code_challenge", &pkce.challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &state); + + if verbose { + eprintln!("[verbose] Authorization URL: {auth_url}"); + } + + eprintln!("Opening browser for authentication..."); + if open::that(auth_url.as_str()).is_err() { + eprintln!( + "Could not open browser automatically. Please open this URL:\n{}", + auth_url + ); + } + + // Wait for callback + let (code, received_state) = wait_for_callback(&listener).await?; + + if verbose { + eprintln!("[verbose] Received authorization code callback"); + } + + if received_state != state { + return Err("State mismatch — possible CSRF attack".to_string()); + } + + // Exchange code for tokens + exchange_code( + &discovery.token_endpoint, + &code, + &redirect_uri, + client_id, + &pkce.verifier, + verbose, + ) + .await +} + +async fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> { + let (stream, _) = listener + .accept() + .await + .map_err(|e| format!("Failed to accept callback connection: {e}"))?; + + let std_stream = stream + .into_std() + .map_err(|e| format!("Failed to convert stream: {e}"))?; + std_stream + .set_nonblocking(false) + .map_err(|e| format!("Failed to set blocking: {e}"))?; + + let mut reader = BufReader::new(&std_stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .map_err(|e| format!("Failed to read request: {e}"))?; + + let path = request_line + .split_whitespace() + .nth(1) + .ok_or("Invalid HTTP request")?; + + let url = Url::parse(&format!("http://localhost{path}")) + .map_err(|e| format!("Failed to parse callback URL: {e}"))?; + + let params: HashMap = url.query_pairs().into_owned().collect(); + + if let Some(error) = params.get("error") { + let desc = params + .get("error_description") + .map(|d| format!(": {d}")) + .unwrap_or_default(); + let html = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ +

Authentication Failed

{error}{desc}

\ +

You can close this tab.

" + ); + let _ = (&std_stream).write_all(html.as_bytes()); + return Err(format!("Authentication error: {error}{desc}")); + } + + let code = params + .get("code") + .ok_or("No authorization code in callback")? + .clone(); + let received_state = params.get("state").ok_or("No state in callback")?.clone(); + + let html = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ +

Authentication Successful

\ +

You can close this tab and return to your terminal.

"; + (&std_stream) + .write_all(html.as_bytes()) + .map_err(|e| format!("Failed to send response: {e}"))?; + + Ok((code, received_state)) +} + +async fn exchange_code( + token_endpoint: &str, + code: &str, + redirect_uri: &str, + client_id: &str, + code_verifier: &str, + verbose: bool, +) -> Result { + if verbose { + eprintln!("[verbose] POST {token_endpoint}"); + eprintln!("[verbose] grant_type=authorization_code"); + eprintln!("[verbose] redirect_uri={redirect_uri}"); + eprintln!("[verbose] client_id={client_id}"); + } + + let client = reqwest::Client::new(); + let resp = client + .post(token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", client_id), + ("code_verifier", code_verifier), + ]) + .send() + .await + .map_err(|e| format!("Token exchange request failed: {e}"))?; + + if verbose { + eprintln!("[verbose] Response: {}", resp.status()); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token exchange failed (HTTP {status}): {body}")); + } + + let token_response: TokenResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse token response: {e}"))?; + + if token_response.id_token.is_none() { + return Err("No id_token in token response".to_string()); + } + + if verbose { + eprintln!("[verbose] Received id_token"); + if token_response.refresh_token.is_some() { + eprintln!("[verbose] Received refresh_token"); + } + } + + Ok(token_response) +} +``` + +**Step 3: Delete old oidc.rs** + +```bash +rm src/oidc.rs +``` + +**Step 4: Update main.rs module declaration** + +In `src/main.rs`, the `mod oidc;` declaration already works for both a file and a directory module. Update the `run_login` function to use the new types: + +Replace lines 123-161 of `src/main.rs` with: + +```rust +async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { + // 1. OIDC Discovery + eprintln!("Discovering OIDC endpoints..."); + let discovery = oidc::discover(&args.issuer, verbose).await?; + + // 2. Browser-based OIDC login + let token_response = + oidc::auth_code::login(&discovery, &args.client_id, &args.scope, args.port, verbose).await?; + let id_token = token_response + .id_token + .ok_or("No id_token in token response")?; + eprintln!("Authentication successful."); + + // 3. STS credential exchange + if verbose { + eprintln!("[verbose] Assuming role: {}", args.role_arn); + } + eprintln!("Exchanging token for credentials..."); + let creds = sts::assume_role( + &args.proxy_url, + &args.role_arn, + &id_token, + args.duration, + verbose, + ) + .await?; + + // 4. Cache credentials + if args.no_cache { + eprintln!("Skipping credential cache (--no-cache)"); + } else { + let location = cache::write_credentials(&args.role_arn, &creds)?; + eprintln!("Credentials cached to {location}"); + } + + // 5. Output + match args.format { + OutputFormat::CredentialProcess => output::print_credential_process(&creds), + OutputFormat::Env => output::print_env(&creds), + } + + Ok(()) +} +``` + +**Step 5: Verify it compiles and existing behavior is preserved** + +Run: `cargo build` +Expected: Compiles successfully. No functional changes yet — still uses auth code flow only. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "refactor: restructure oidc.rs into oidc/ module with expanded discovery" +``` + +--- + +### Task 2: Add device code flow + +**Files:** +- Create: `src/oidc/device_code.rs` +- Modify: `src/oidc/mod.rs` (add `pub mod device_code;`) + +**Step 1: Add device_code module declaration** + +In `src/oidc/mod.rs`, add after `pub mod auth_code;`: + +```rust +pub mod device_code; +``` + +**Step 2: Create device_code.rs** + +Create `src/oidc/device_code.rs`: + +```rust +use super::{OidcDiscovery, TokenResponse}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct DeviceAuthResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + #[serde(default = "default_interval")] + interval: u64, + expires_in: u64, +} + +fn default_interval() -> u64 { + 5 +} + +#[derive(Debug, Deserialize)] +struct DeviceTokenErrorResponse { + error: String, + error_description: Option, +} + +/// Run the Device Code flow (RFC 8628). Returns a TokenResponse. +pub async fn login( + discovery: &OidcDiscovery, + client_id: &str, + scope: &str, + verbose: bool, +) -> Result { + let device_endpoint = discovery + .device_authorization_endpoint + .as_ref() + .ok_or("Provider does not support device authorization")?; + + if verbose { + eprintln!("[verbose] POST {device_endpoint}"); + eprintln!("[verbose] client_id={client_id}"); + eprintln!("[verbose] scope={scope}"); + } + + // Step 1: Request device code + let client = reqwest::Client::new(); + let resp = client + .post(device_endpoint) + .form(&[("client_id", client_id), ("scope", scope)]) + .send() + .await + .map_err(|e| format!("Device authorization request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!( + "Device authorization failed (HTTP {status}): {body}" + )); + } + + let device_resp: DeviceAuthResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse device authorization response: {e}"))?; + + if verbose { + eprintln!("[verbose] Device code received"); + eprintln!("[verbose] User code: {}", device_resp.user_code); + eprintln!("[verbose] Verification URI: {}", device_resp.verification_uri); + eprintln!("[verbose] Poll interval: {}s", device_resp.interval); + eprintln!("[verbose] Expires in: {}s", device_resp.expires_in); + } + + // Step 2: Display instructions to user + eprintln!(); + eprintln!("To authenticate, visit:"); + eprintln!(" {}", device_resp.verification_uri); + eprintln!(); + eprintln!("And enter code: {}", device_resp.user_code); + eprintln!(); + + // Try to open browser with the complete URI + if let Some(ref complete_uri) = device_resp.verification_uri_complete { + eprintln!("Opening browser..."); + if open::that(complete_uri).is_err() { + eprintln!("Could not open browser automatically."); + } + } + + // Step 3: Poll for token + let mut interval = device_resp.interval; + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(device_resp.expires_in); + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + + if tokio::time::Instant::now() > deadline { + return Err("Device code expired. Please try again.".to_string()); + } + + if verbose { + eprintln!("[verbose] Polling token endpoint..."); + } + + let resp = client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", &device_resp.device_code), + ("client_id", client_id), + ]) + .send() + .await + .map_err(|e| format!("Token poll request failed: {e}"))?; + + if resp.status().is_success() { + let token_response: TokenResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse token response: {e}"))?; + + if token_response.id_token.is_none() { + return Err("No id_token in token response".to_string()); + } + + if verbose { + eprintln!("[verbose] Received id_token"); + if token_response.refresh_token.is_some() { + eprintln!("[verbose] Received refresh_token"); + } + } + + return Ok(token_response); + } + + // Parse error response + let body = resp + .text() + .await + .unwrap_or_default(); + + let error_resp: DeviceTokenErrorResponse = serde_json::from_str(&body) + .unwrap_or(DeviceTokenErrorResponse { + error: "unknown".to_string(), + error_description: Some(body.clone()), + }); + + match error_resp.error.as_str() { + "authorization_pending" => { + if verbose { + eprintln!("[verbose] Authorization pending, waiting..."); + } + continue; + } + "slow_down" => { + interval += 5; + if verbose { + eprintln!("[verbose] Slowing down, new interval: {interval}s"); + } + continue; + } + "expired_token" => { + return Err("Device code expired. Please try again.".to_string()); + } + "access_denied" => { + return Err("Access denied by user.".to_string()); + } + other => { + let desc = error_resp + .error_description + .map(|d| format!(": {d}")) + .unwrap_or_default(); + return Err(format!("Device code error ({other}){desc}")); + } + } + } +} +``` + +**Step 3: Verify it compiles** + +Run: `cargo build` +Expected: Compiles. device_code module exists but isn't called yet. + +**Step 4: Commit** + +```bash +git add -A && git commit -m "feat: add device code flow (RFC 8628)" +``` + +--- + +### Task 3: Add flow selection and --flow flag to CLI + +**Files:** +- Modify: `src/main.rs` (add `--flow` flag, flow selection logic) +- Modify: `src/oidc/mod.rs` (add FlowType enum) + +**Step 1: Add FlowType to oidc/mod.rs** + +Add at the top of `src/oidc/mod.rs` (after imports): + +```rust +use clap::ValueEnum; + +#[derive(Debug, Clone, ValueEnum)] +pub enum FlowType { + /// Automatically select the best flow + Auto, + /// Device code flow (works everywhere including headless) + DeviceCode, + /// Authorization code + PKCE flow (requires browser on same machine) + AuthCode, +} +``` + +**Step 2: Update LoginArgs in main.rs** + +Add the `--flow` flag to `LoginArgs`: + +```rust + /// Authentication flow to use + #[arg(long, default_value = "auto")] + flow: oidc::FlowType, +``` + +Change the default scope: + +```rust + /// OAuth2 scopes + #[arg(long, default_value = "openid offline_access")] + scope: String, +``` + +**Step 3: Update run_login with flow selection** + +Replace the OIDC login section (step 2) in `run_login`: + +```rust +async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { + // 1. OIDC Discovery + eprintln!("Discovering OIDC endpoints..."); + let discovery = oidc::discover(&args.issuer, verbose).await?; + + // 2. Select and execute auth flow + let flow = match args.flow { + oidc::FlowType::Auto => { + if discovery.supports_device_code() + && discovery.device_authorization_endpoint.is_some() + { + if verbose { + eprintln!("[verbose] Auto-selected device code flow"); + } + oidc::FlowType::DeviceCode + } else { + if verbose { + eprintln!("[verbose] Auto-selected authorization code flow"); + } + oidc::FlowType::AuthCode + } + } + explicit => explicit, + }; + + let token_response = match flow { + oidc::FlowType::DeviceCode => { + if !discovery.supports_device_code() + || discovery.device_authorization_endpoint.is_none() + { + return Err( + "Provider does not support device code flow. Use --flow auth-code.".to_string(), + ); + } + oidc::device_code::login(&discovery, &args.client_id, &args.scope, verbose).await? + } + oidc::FlowType::AuthCode => { + oidc::auth_code::login( + &discovery, + &args.client_id, + &args.scope, + args.port, + verbose, + ) + .await? + } + oidc::FlowType::Auto => unreachable!(), + }; + + let id_token = token_response + .id_token + .ok_or("No id_token in token response")?; + eprintln!("Authentication successful."); + + // 3. STS credential exchange + if verbose { + eprintln!("[verbose] Assuming role: {}", args.role_arn); + } + eprintln!("Exchanging token for credentials..."); + let creds = sts::assume_role( + &args.proxy_url, + &args.role_arn, + &id_token, + args.duration, + verbose, + ) + .await?; + + // 4. Cache credentials + if args.no_cache { + eprintln!("Skipping credential cache (--no-cache)"); + } else { + let location = cache::write_credentials(&args.role_arn, &creds)?; + eprintln!("Credentials cached to {location}"); + } + + // 5. Output + match args.format { + OutputFormat::CredentialProcess => output::print_credential_process(&creds), + OutputFormat::Env => output::print_env(&creds), + } + + Ok(()) +} +``` + +**Step 4: Verify it compiles** + +Run: `cargo build` +Expected: Compiles. `source-coop login --help` shows the new `--flow` flag. + +**Step 5: Verify help output** + +Run: `cargo run -- login --help` +Expected: Shows `--flow ` with auto, device-code, auth-code options. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add --flow flag with auto-selection (device code preferred)" +``` + +--- + +### Task 4: Add refresh token storage to cache.rs + +**Files:** +- Modify: `src/cache.rs` + +**Step 1: Add refresh token cache functions** + +Add these functions to `src/cache.rs`, after the existing `is_expired` function (before `#[cfg(test)]`): + +```rust +/// Compute a short, filesystem-safe key for an issuer URL. +fn issuer_key(issuer: &str) -> String { + sanitize_role_arn(issuer) +} + +/// Full path to the refresh token cache file for a given issuer. +fn refresh_cache_path(issuer: &str) -> Result { + let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; + let key = issuer_key(issuer); + Ok(cache_dir + .join("source-coop") + .join("refresh") + .join(format!("{key}.json"))) +} + +/// Refresh token data stored in cache. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshTokenData { + pub refresh_token: String, + pub issuer: String, + pub client_id: String, +} + +fn write_refresh_token_file(data: &RefreshTokenData) -> Result { + let path = refresh_cache_path(&data.issuer)?; + let dir = path.parent().unwrap(); + + fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create refresh cache directory {}: {e}", dir.display()))?; + + let json = serde_json::to_string_pretty(data) + .map_err(|e| format!("Failed to serialize refresh token: {e}"))?; + + fs::write(&path, &json) + .map_err(|e| format!("Failed to write refresh token cache {}: {e}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?; + } + + Ok(path.display().to_string()) +} + +fn read_refresh_token_file(issuer: &str) -> Result, String> { + let path = refresh_cache_path(issuer)?; + match fs::read_to_string(&path) { + Ok(contents) => { + let data: RefreshTokenData = serde_json::from_str(&contents) + .map_err(|e| format!("Failed to parse refresh token cache: {e}"))?; + Ok(Some(data)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!( + "Failed to read refresh token cache {}: {e}", + path.display() + )), + } +} + +const REFRESH_KEYRING_PREFIX: &str = "source-coop-cli:refresh"; + +/// Write refresh token, trying OS keyring first with file fallback. +pub fn write_refresh_token(data: &RefreshTokenData) -> Result { + let json = serde_json::to_string(data) + .map_err(|e| format!("Failed to serialize refresh token: {e}"))?; + + let key = issuer_key(&data.issuer); + let entry = keyring::Entry::new(REFRESH_KEYRING_PREFIX, &key) + .map_err(|e| format!("Failed to create keyring entry: {e}")); + + if let Ok(entry) = entry { + match entry.set_password(&json) { + Ok(()) => { + return Ok(format!("OS keyring (service: {REFRESH_KEYRING_PREFIX})")); + } + Err(ref e) if is_keyring_unavailable(e) => {} + Err(e) => { + return Err(format!("Failed to write refresh token to keyring: {e}")); + } + } + } + + write_refresh_token_file(data) +} + +/// Read refresh token, trying OS keyring first with file fallback. +pub fn read_refresh_token(issuer: &str) -> Result, String> { + let key = issuer_key(issuer); + let entry = keyring::Entry::new(REFRESH_KEYRING_PREFIX, &key) + .map_err(|e| format!("Failed to create keyring entry: {e}")); + + if let Ok(entry) = entry { + match entry.get_password() { + Ok(json) => { + let data: RefreshTokenData = serde_json::from_str(&json) + .map_err(|e| format!("Failed to parse refresh token from keyring: {e}"))?; + return Ok(Some(data)); + } + Err(keyring::Error::NoEntry) => {} + Err(ref e) if is_keyring_unavailable(e) => {} + Err(e) => { + return Err(format!("Failed to read refresh token from keyring: {e}")); + } + } + } + + read_refresh_token_file(issuer) +} + +/// Delete refresh token from both keyring and file cache. +pub fn delete_refresh_token(issuer: &str) -> Result<(), String> { + let key = issuer_key(issuer); + + // Try keyring + if let Ok(entry) = keyring::Entry::new(REFRESH_KEYRING_PREFIX, &key) { + let _ = entry.delete_credential(); + } + + // Try file + if let Ok(path) = refresh_cache_path(issuer) { + let _ = fs::remove_file(path); + } + + Ok(()) +} + +/// Delete AWS credentials from both keyring and file cache. +pub fn delete_credentials(role_arn: &str) -> Result<(), String> { + // Try keyring + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, role_arn) { + let _ = entry.delete_credential(); + } + + // Try file + if let Ok(path) = cache_path(role_arn) { + let _ = fs::remove_file(path); + } + + Ok(()) +} +``` + +Also add `use serde::Serialize;` to the imports at the top (Credentials already derives Serialize via sts.rs, but RefreshTokenData needs it for the module). + +Add to the top of cache.rs: + +```rust +use serde::{Deserialize, Serialize}; +``` + +Wait — `cache.rs` doesn't import serde directly. It uses `serde_json` for serialization but the `Credentials` struct derives `Serialize`/`Deserialize` in `sts.rs`. For `RefreshTokenData`, we need the derives in cache.rs itself. Add `use serde::{Serialize, Deserialize};` at the top of `cache.rs`. + +**Step 2: Verify it compiles** + +Run: `cargo build` +Expected: Compiles. New functions exist but aren't called yet. + +**Step 3: Commit** + +```bash +git add -A && git commit -m "feat: add refresh token cache with keyring and file fallback" +``` + +--- + +### Task 5: Add refresh token flow module + +**Files:** +- Create: `src/oidc/refresh.rs` +- Modify: `src/oidc/mod.rs` (add `pub mod refresh;`) + +**Step 1: Add refresh module declaration** + +In `src/oidc/mod.rs`, add: + +```rust +pub mod refresh; +``` + +**Step 2: Create refresh.rs** + +Create `src/oidc/refresh.rs`: + +```rust +use super::{OidcDiscovery, TokenResponse}; + +/// Exchange a refresh token for new tokens. +pub async fn refresh( + discovery: &OidcDiscovery, + client_id: &str, + refresh_token: &str, + verbose: bool, +) -> Result { + if verbose { + eprintln!("[verbose] POST {}", discovery.token_endpoint); + eprintln!("[verbose] grant_type=refresh_token"); + eprintln!("[verbose] client_id={client_id}"); + } + + let client = reqwest::Client::new(); + let resp = client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", client_id), + ]) + .send() + .await + .map_err(|e| format!("Refresh token request failed: {e}"))?; + + if verbose { + eprintln!("[verbose] Response: {}", resp.status()); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token refresh failed (HTTP {status}): {body}")); + } + + let token_response: TokenResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse token response: {e}"))?; + + if token_response.id_token.is_none() { + return Err("No id_token in refresh response".to_string()); + } + + if verbose { + eprintln!("[verbose] Received refreshed id_token"); + if token_response.refresh_token.is_some() { + eprintln!("[verbose] Received rotated refresh_token"); + } + } + + Ok(token_response) +} + +/// Revoke a refresh token at the provider's revocation endpoint. +pub async fn revoke( + discovery: &OidcDiscovery, + client_id: &str, + refresh_token: &str, + verbose: bool, +) -> Result<(), String> { + let revocation_endpoint = match &discovery.revocation_endpoint { + Some(ep) => ep, + None => { + if verbose { + eprintln!("[verbose] No revocation endpoint; skipping token revocation"); + } + return Ok(()); + } + }; + + if verbose { + eprintln!("[verbose] POST {revocation_endpoint}"); + eprintln!("[verbose] token_type_hint=refresh_token"); + eprintln!("[verbose] client_id={client_id}"); + } + + let client = reqwest::Client::new(); + let resp = client + .post(revocation_endpoint) + .form(&[ + ("token", refresh_token), + ("token_type_hint", "refresh_token"), + ("client_id", client_id), + ]) + .send() + .await + .map_err(|e| format!("Token revocation request failed: {e}"))?; + + if verbose { + eprintln!("[verbose] Revocation response: {}", resp.status()); + } + + // RFC 7009: revocation endpoint returns 200 even if token was already invalid + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + eprintln!("Warning: token revocation returned HTTP {status}: {body}"); + } + + Ok(()) +} +``` + +**Step 3: Verify it compiles** + +Run: `cargo build` +Expected: Compiles. + +**Step 4: Commit** + +```bash +git add -A && git commit -m "feat: add refresh token exchange and revocation" +``` + +--- + +### Task 6: Wire refresh token caching into login flow + +**Files:** +- Modify: `src/main.rs` (cache refresh token after login) + +**Step 1: Update run_login to cache refresh token** + +After the "Authentication successful" line in `run_login`, add refresh token caching: + +```rust + let id_token = token_response + .id_token + .ok_or("No id_token in token response")?; + eprintln!("Authentication successful."); + + // Cache refresh token if present + if let Some(ref refresh_token) = token_response.refresh_token { + let data = cache::RefreshTokenData { + refresh_token: refresh_token.clone(), + issuer: args.issuer.clone(), + client_id: args.client_id.clone(), + }; + match cache::write_refresh_token(&data) { + Ok(location) => { + if verbose { + eprintln!("[verbose] Refresh token cached to {location}"); + } + } + Err(e) => { + eprintln!("Warning: could not cache refresh token: {e}"); + } + } + } +``` + +**Step 2: Verify it compiles** + +Run: `cargo build` +Expected: Compiles. + +**Step 3: Commit** + +```bash +git add -A && git commit -m "feat: cache refresh token during login" +``` + +--- + +### Task 7: Add auto-refresh to creds command + +**Files:** +- Modify: `src/main.rs` (update `CredsArgs` and `run_creds`) + +**Step 1: Update CredsArgs with refresh-related fields** + +```rust +#[derive(Parser)] +struct CredsArgs { + /// Role ARN to read cached credentials for + #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] + role_arn: String, + + /// Output format + #[arg(long, default_value = "credential-process")] + format: OutputFormat, + + /// Skip automatic refresh of expired credentials + #[arg(long)] + no_refresh: bool, + + /// OIDC issuer URL (needed for auto-refresh) + #[arg(long, env = "SOURCE_OIDC_ISSUER", default_value = defaults::ISSUER)] + issuer: String, + + /// S3 proxy URL for STS (needed for auto-refresh) + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, +} +``` + +**Step 2: Make run_creds async and add auto-refresh logic** + +Change `run_creds` to async: + +```rust +async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { + let creds = cache::read_credentials(&args.role_arn)? + .ok_or("No cached credentials found. Run 'source-coop login' first.")?; + + if !cache::is_expired(&creds)? { + // Credentials still valid, output them + match args.format { + OutputFormat::CredentialProcess => output::print_credential_process(&creds), + OutputFormat::Env => output::print_env(&creds), + } + return Ok(()); + } + + // Credentials expired — try auto-refresh + if args.no_refresh { + return Err( + "Cached credentials have expired. Run 'source-coop login' to refresh.".to_string(), + ); + } + + let refresh_data = cache::read_refresh_token(&args.issuer)?; + let refresh_data = match refresh_data { + Some(data) => data, + None => { + return Err( + "Cached credentials have expired and no refresh token found. Run 'source-coop login'.".to_string(), + ); + } + }; + + if verbose { + eprintln!("[verbose] Credentials expired, attempting auto-refresh..."); + } + eprintln!("Credentials expired. Refreshing..."); + + // Discover endpoints for refresh + let discovery = oidc::discover(&refresh_data.issuer, verbose).await?; + + // Refresh tokens + let token_response = oidc::refresh::refresh( + &discovery, + &refresh_data.client_id, + &refresh_data.refresh_token, + verbose, + ) + .await?; + + let id_token = token_response + .id_token + .ok_or("No id_token in refresh response")?; + + // Update cached refresh token if rotated + if let Some(ref new_refresh_token) = token_response.refresh_token { + let new_data = cache::RefreshTokenData { + refresh_token: new_refresh_token.clone(), + issuer: refresh_data.issuer.clone(), + client_id: refresh_data.client_id.clone(), + }; + let _ = cache::write_refresh_token(&new_data); + } + + // STS exchange + let creds = sts::assume_role( + &args.proxy_url, + &args.role_arn, + &id_token, + None, + verbose, + ) + .await?; + + // Cache new credentials + let location = cache::write_credentials(&args.role_arn, &creds)?; + if verbose { + eprintln!("[verbose] Refreshed credentials cached to {location}"); + } + eprintln!("Credentials refreshed."); + + match args.format { + OutputFormat::CredentialProcess => output::print_credential_process(&creds), + OutputFormat::Env => output::print_env(&creds), + } + Ok(()) +} +``` + +**Step 3: Update the main() match arm for Creds** + +Update the `Commands::Creds` arm in `main()` to pass verbose and use `.await`: + +```rust + Commands::Creds(args) => { + if let Err(e) = run_creds(args, verbose).await { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } +``` + +**Step 4: Verify it compiles** + +Run: `cargo build` +Expected: Compiles. + +**Step 5: Verify help output** + +Run: `cargo run -- creds --help` +Expected: Shows `--no-refresh`, `--issuer`, `--proxy-url` flags. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add auto-refresh of expired credentials in creds command" +``` + +--- + +### Task 8: Add logout command + +**Files:** +- Modify: `src/main.rs` (add Logout command) + +**Step 1: Add LogoutArgs and Logout command variant** + +Add to the `Commands` enum: + +```rust + /// Clear cached credentials and revoke refresh token + Logout(LogoutArgs), +``` + +Add the struct: + +```rust +#[derive(Parser)] +struct LogoutArgs { + /// OIDC issuer URL + #[arg(long, env = "SOURCE_OIDC_ISSUER", default_value = defaults::ISSUER)] + issuer: String, + + /// OAuth2 client ID + #[arg(long, env = "SOURCE_OIDC_CLIENT_ID", default_value = defaults::CLIENT_ID)] + client_id: String, + + /// Role ARN to clear cached credentials for + #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] + role_arn: String, +} +``` + +**Step 2: Add run_logout function** + +```rust +async fn run_logout(args: LogoutArgs, verbose: bool) -> Result<(), String> { + // Revoke refresh token if we have one + if let Some(refresh_data) = cache::read_refresh_token(&args.issuer)? { + if verbose { + eprintln!("[verbose] Found cached refresh token, attempting revocation..."); + } + + // Best-effort discovery + revocation + match oidc::discover(&args.issuer, verbose).await { + Ok(discovery) => { + if let Err(e) = oidc::refresh::revoke( + &discovery, + &args.client_id, + &refresh_data.refresh_token, + verbose, + ) + .await + { + eprintln!("Warning: could not revoke refresh token: {e}"); + } + } + Err(e) => { + eprintln!("Warning: could not discover endpoints for revocation: {e}"); + } + } + } + + // Delete cached refresh token + cache::delete_refresh_token(&args.issuer)?; + eprintln!("Refresh token cleared."); + + // Delete cached AWS credentials + cache::delete_credentials(&args.role_arn)?; + eprintln!("Cached credentials cleared."); + + Ok(()) +} +``` + +**Step 3: Add the match arm in main()** + +```rust + Commands::Logout(args) => { + if let Err(e) = run_logout(args, verbose).await { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } +``` + +**Step 4: Verify it compiles** + +Run: `cargo build` +Expected: Compiles. `source-coop logout --help` shows expected flags. + +**Step 5: Commit** + +```bash +git add -A && git commit -m "feat: add logout command with token revocation" +``` + +--- + +### Task 9: Final integration verification + +**Files:** None (testing only) + +**Step 1: Verify full build** + +Run: `cargo build` +Expected: Clean compile. + +**Step 2: Verify all help output** + +Run: `cargo run -- --help` +Expected: Shows login, creds, logout commands. + +Run: `cargo run -- login --help` +Expected: Shows --flow, --issuer, --client-id, --scope (default "openid offline_access"), etc. + +Run: `cargo run -- creds --help` +Expected: Shows --no-refresh, --issuer, --proxy-url. + +Run: `cargo run -- logout --help` +Expected: Shows --issuer, --client-id, --role-arn. + +**Step 3: Run existing tests** + +Run: `cargo test` +Expected: All existing tests pass. + +**Step 4: Commit any final adjustments** + +```bash +git add -A && git commit -m "chore: final integration verification" +``` + +--- + +### Summary of all tasks + +| Task | Description | Key files | +|------|-------------|-----------| +| 1 | Restructure oidc.rs → oidc/ module | oidc/mod.rs, oidc/auth_code.rs | +| 2 | Add device code flow | oidc/device_code.rs | +| 3 | Add --flow flag and flow selection | main.rs, oidc/mod.rs | +| 4 | Add refresh token cache | cache.rs | +| 5 | Add refresh token flow module | oidc/refresh.rs | +| 6 | Wire refresh caching into login | main.rs | +| 7 | Add auto-refresh to creds | main.rs | +| 8 | Add logout command | main.rs | +| 9 | Final integration verification | — | From b35a0cd1965cdbe8587d7edf234e1d9fe99f332d Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:37:52 -0700 Subject: [PATCH 03/22] refactor: restructure oidc.rs into oidc/ module with expanded discovery Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 8 ++- src/{oidc.rs => oidc/auth_code.rs} | 87 ++++++------------------------ src/oidc/mod.rs | 77 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 73 deletions(-) rename src/{oidc.rs => oidc/auth_code.rs} (73%) create mode 100644 src/oidc/mod.rs diff --git a/src/main.rs b/src/main.rs index fb41de4..541b0ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,8 +126,12 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { let endpoints = oidc::discover(&args.issuer, verbose).await?; // 2. Browser-based OIDC login - let id_token = - oidc::login(&endpoints, &args.client_id, &args.scope, args.port, verbose).await?; + let token_response = + oidc::auth_code::login(&endpoints, &args.client_id, &args.scope, args.port, verbose) + .await?; + let id_token = token_response + .id_token + .ok_or("No id_token in token response")?; eprintln!("Authentication successful."); // 3. STS credential exchange diff --git a/src/oidc.rs b/src/oidc/auth_code.rs similarity index 73% rename from src/oidc.rs rename to src/oidc/auth_code.rs index 49dceae..3ba8ffa 100644 --- a/src/oidc.rs +++ b/src/oidc/auth_code.rs @@ -7,71 +7,18 @@ use std::io::{BufRead, BufReader, Write}; use tokio::net::TcpListener; use url::Url; -#[derive(Debug)] -pub struct OidcEndpoints { - pub authorization_endpoint: String, - pub token_endpoint: String, -} - -/// Fetch OIDC discovery document and extract endpoints. -pub async fn discover(issuer: &str, verbose: bool) -> Result { - let discovery_url = format!( - "{}/.well-known/openid-configuration", - issuer.trim_end_matches('/') - ); - - if verbose { - eprintln!("[verbose] GET {discovery_url}"); - } - - let resp = reqwest::get(&discovery_url) - .await - .map_err(|e| format!("Failed to fetch OIDC discovery document: {e}"))?; - - if verbose { - eprintln!("[verbose] Response: {}", resp.status()); - } - - if !resp.status().is_success() { - return Err(format!("OIDC discovery returned status {}", resp.status())); - } - - let doc: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Failed to parse OIDC discovery document: {e}"))?; - - let authorization_endpoint = doc["authorization_endpoint"] - .as_str() - .ok_or("Missing authorization_endpoint in discovery document")? - .to_string(); - - let token_endpoint = doc["token_endpoint"] - .as_str() - .ok_or("Missing token_endpoint in discovery document")? - .to_string(); - - if verbose { - eprintln!("[verbose] Authorization endpoint: {authorization_endpoint}"); - eprintln!("[verbose] Token endpoint: {token_endpoint}"); - } - - Ok(OidcEndpoints { - authorization_endpoint, - token_endpoint, - }) -} +use super::{OidcDiscovery, TokenResponse}; /// Run the browser-based OAuth2 Authorization Code flow with PKCE. /// Opens the user's browser to the OIDC provider, waits for the callback, -/// and returns the `id_token`. +/// and returns a `TokenResponse`. pub async fn login( - endpoints: &OidcEndpoints, + discovery: &OidcDiscovery, client_id: &str, scope: &str, port: u16, verbose: bool, -) -> Result { +) -> Result { let pkce = generate_pkce(); let state: String = URL_SAFE_NO_PAD.encode(rand::thread_rng().gen::<[u8; 16]>()); @@ -91,7 +38,7 @@ pub async fn login( } // Build authorization URL - let mut auth_url = Url::parse(&endpoints.authorization_endpoint) + let mut auth_url = Url::parse(&discovery.authorization_endpoint) .map_err(|e| format!("Invalid authorization endpoint URL: {e}"))?; auth_url .query_pairs_mut() @@ -128,7 +75,7 @@ pub async fn login( // Exchange code for tokens exchange_code( - &endpoints.token_endpoint, + &discovery.token_endpoint, &code, &redirect_uri, client_id, @@ -198,7 +145,7 @@ async fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), S Ok((code, received_state)) } -/// Exchange authorization code for tokens, return the `id_token`. +/// Exchange authorization code for tokens, return a `TokenResponse`. async fn exchange_code( token_endpoint: &str, code: &str, @@ -206,7 +153,7 @@ async fn exchange_code( client_id: &str, code_verifier: &str, verbose: bool, -) -> Result { +) -> Result { if verbose { eprintln!("[verbose] POST {token_endpoint}"); eprintln!("[verbose] grant_type=authorization_code"); @@ -238,23 +185,21 @@ async fn exchange_code( return Err(format!("Token exchange failed (HTTP {status}): {body}")); } - let body: serde_json::Value = resp + let token_response: TokenResponse = resp .json() .await .map_err(|e| format!("Failed to parse token response: {e}"))?; if verbose { - let keys: Vec<&str> = body - .as_object() - .map(|o| o.keys().map(|k| k.as_str()).collect()) - .unwrap_or_default(); - eprintln!("[verbose] Token response keys: {}", keys.join(", ")); + eprintln!( + "[verbose] Token response contains: id_token={}, refresh_token={}, access_token={}", + token_response.id_token.is_some(), + token_response.refresh_token.is_some(), + token_response.access_token.is_some(), + ); } - body["id_token"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "No id_token in token response".to_string()) + Ok(token_response) } struct Pkce { diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs new file mode 100644 index 0000000..0e0d01a --- /dev/null +++ b/src/oidc/mod.rs @@ -0,0 +1,77 @@ +pub mod auth_code; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct OidcDiscovery { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub device_authorization_endpoint: Option, + pub revocation_endpoint: Option, + pub grant_types_supported: Option>, + pub scopes_supported: Option>, + pub code_challenge_methods_supported: Option>, +} + +impl OidcDiscovery { + pub fn supports_grant_type(&self, grant_type: &str) -> bool { + self.grant_types_supported + .as_ref() + .map(|types| types.iter().any(|t| t == grant_type)) + .unwrap_or(false) + } + + pub fn supports_device_code(&self) -> bool { + self.supports_grant_type("urn:ietf:params:oauth:grant-type:device_code") + || self.supports_grant_type("device_code") + } +} + +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub id_token: Option, + pub refresh_token: Option, + pub access_token: Option, + pub token_type: Option, + pub expires_in: Option, +} + +/// Fetch OIDC discovery document and deserialize into OidcDiscovery. +pub async fn discover(issuer: &str, verbose: bool) -> Result { + let discovery_url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + + if verbose { + eprintln!("[verbose] GET {discovery_url}"); + } + + let resp = reqwest::get(&discovery_url) + .await + .map_err(|e| format!("Failed to fetch OIDC discovery document: {e}"))?; + + if verbose { + eprintln!("[verbose] Response: {}", resp.status()); + } + + if !resp.status().is_success() { + return Err(format!("OIDC discovery returned status {}", resp.status())); + } + + let discovery: OidcDiscovery = resp + .json() + .await + .map_err(|e| format!("Failed to parse OIDC discovery document: {e}"))?; + + if verbose { + eprintln!( + "[verbose] Authorization endpoint: {}", + discovery.authorization_endpoint + ); + eprintln!("[verbose] Token endpoint: {}", discovery.token_endpoint); + } + + Ok(discovery) +} From e42901c5603fa364df0d8891c994f7ffbc620b87 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:41:11 -0700 Subject: [PATCH 04/22] refactor: rename endpoints -> discovery in run_login Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 541b0ca..311aaef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,11 +123,11 @@ async fn main() { async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { // 1. OIDC Discovery eprintln!("Discovering OIDC endpoints..."); - let endpoints = oidc::discover(&args.issuer, verbose).await?; + let discovery = oidc::discover(&args.issuer, verbose).await?; // 2. Browser-based OIDC login let token_response = - oidc::auth_code::login(&endpoints, &args.client_id, &args.scope, args.port, verbose) + oidc::auth_code::login(&discovery, &args.client_id, &args.scope, args.port, verbose) .await?; let id_token = token_response .id_token From c914d18538dd1922dd8b29deacdee23dbf966206 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:44:56 -0700 Subject: [PATCH 05/22] feat: add device code flow (RFC 8628) Co-Authored-By: Claude Opus 4.6 --- src/oidc/device_code.rs | 193 ++++++++++++++++++++++++++++++++++++++++ src/oidc/mod.rs | 1 + 2 files changed, 194 insertions(+) create mode 100644 src/oidc/device_code.rs diff --git a/src/oidc/device_code.rs b/src/oidc/device_code.rs new file mode 100644 index 0000000..2467351 --- /dev/null +++ b/src/oidc/device_code.rs @@ -0,0 +1,193 @@ +use serde::Deserialize; +use std::time::{Duration, Instant}; + +use super::{OidcDiscovery, TokenResponse}; + +#[derive(Debug, Deserialize)] +struct DeviceAuthResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + #[serde(default = "default_interval")] + interval: u64, + expires_in: u64, +} + +fn default_interval() -> u64 { + 5 +} + +#[derive(Debug, Deserialize)] +struct DeviceTokenErrorResponse { + error: String, + error_description: Option, +} + +/// Run the RFC 8628 device code flow. +/// Requests a device code, prompts the user to visit a verification URL, +/// and polls the token endpoint until authentication completes or times out. +pub async fn login( + discovery: &OidcDiscovery, + client_id: &str, + scope: &str, + verbose: bool, +) -> Result { + let device_endpoint = discovery + .device_authorization_endpoint + .as_deref() + .ok_or("OIDC provider does not support device authorization")?; + + if verbose { + eprintln!("[verbose] POST {device_endpoint}"); + eprintln!("[verbose] client_id={client_id}"); + eprintln!("[verbose] scope={scope}"); + } + + // Step 1: Request device authorization + let client = reqwest::Client::new(); + let resp = client + .post(device_endpoint) + .form(&[("client_id", client_id), ("scope", scope)]) + .send() + .await + .map_err(|e| format!("Device authorization request failed: {e}"))?; + + if verbose { + eprintln!("[verbose] Response: {}", resp.status()); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!( + "Device authorization failed (HTTP {status}): {body}" + )); + } + + let device_auth: DeviceAuthResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse device authorization response: {e}"))?; + + if verbose { + eprintln!( + "[verbose] Device code received, expires in {}s, poll interval {}s", + device_auth.expires_in, device_auth.interval + ); + } + + // Step 2: Display verification info to user + eprintln!(); + eprintln!( + "To sign in, open this URL in your browser:\n {}", + device_auth.verification_uri + ); + eprintln!(); + eprintln!("Then enter the code:\n {}", device_auth.user_code); + eprintln!(); + + // Step 3: Try to open verification_uri_complete in browser + if let Some(ref uri_complete) = device_auth.verification_uri_complete { + if verbose { + eprintln!("[verbose] Opening {uri_complete} in browser"); + } + if open::that(uri_complete).is_err() { + if verbose { + eprintln!("[verbose] Could not open browser automatically"); + } + } + } + + // Step 4: Poll token endpoint + let deadline = Instant::now() + Duration::from_secs(device_auth.expires_in); + let mut interval = device_auth.interval; + + loop { + tokio::time::sleep(Duration::from_secs(interval)).await; + + if Instant::now() >= deadline { + return Err("Device code expired — please try again".to_string()); + } + + if verbose { + eprintln!( + "[verbose] POST {} (polling, interval={}s)", + discovery.token_endpoint, interval + ); + } + + let resp = client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", &device_auth.device_code), + ("client_id", client_id), + ]) + .send() + .await + .map_err(|e| format!("Token poll request failed: {e}"))?; + + let status = resp.status(); + + if verbose { + eprintln!("[verbose] Poll response: {status}"); + } + + if status.is_success() { + let token_response: TokenResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse token response: {e}"))?; + + if verbose { + eprintln!( + "[verbose] Token response contains: id_token={}, refresh_token={}, access_token={}", + token_response.id_token.is_some(), + token_response.refresh_token.is_some(), + token_response.access_token.is_some(), + ); + } + + return Ok(token_response); + } + + // Parse the error response + let body = resp + .text() + .await + .map_err(|e| format!("Failed to read token error response: {e}"))?; + + let error_resp: DeviceTokenErrorResponse = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse token error response: {e}"))?; + + match error_resp.error.as_str() { + "authorization_pending" => { + if verbose { + eprintln!("[verbose] Authorization pending, will retry..."); + } + } + "slow_down" => { + interval += 5; + if verbose { + eprintln!( + "[verbose] Received slow_down, increasing interval to {interval}s" + ); + } + } + "expired_token" => { + return Err("Device code expired — please try again".to_string()); + } + "access_denied" => { + return Err("Access denied by user".to_string()); + } + other => { + let desc = error_resp + .error_description + .map(|d| format!(": {d}")) + .unwrap_or_default(); + return Err(format!("Token request failed ({other}){desc}")); + } + } + } +} diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index 0e0d01a..86602e0 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -1,4 +1,5 @@ pub mod auth_code; +pub mod device_code; use serde::Deserialize; From defc18e8d2636844908e5512cf613fdce02a4085 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:47:06 -0700 Subject: [PATCH 06/22] fix: open browser to verification_uri as fallback, add waiting message Co-Authored-By: Claude Opus 4.6 --- src/oidc/device_code.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/oidc/device_code.rs b/src/oidc/device_code.rs index 2467351..36ac8e3 100644 --- a/src/oidc/device_code.rs +++ b/src/oidc/device_code.rs @@ -87,17 +87,19 @@ pub async fn login( eprintln!("Then enter the code:\n {}", device_auth.user_code); eprintln!(); - // Step 3: Try to open verification_uri_complete in browser - if let Some(ref uri_complete) = device_auth.verification_uri_complete { - if verbose { - eprintln!("[verbose] Opening {uri_complete} in browser"); - } - if open::that(uri_complete).is_err() { - if verbose { - eprintln!("[verbose] Could not open browser automatically"); - } - } + // Step 3: Try to open browser + let browser_url = device_auth + .verification_uri_complete + .as_deref() + .unwrap_or(&device_auth.verification_uri); + if verbose { + eprintln!("[verbose] Opening {browser_url} in browser"); } + if open::that(browser_url).is_err() { + eprintln!("Could not open browser automatically."); + } + + eprintln!("Waiting for authentication..."); // Step 4: Poll token endpoint let deadline = Instant::now() + Duration::from_secs(device_auth.expires_in); From 6be773dd1a786af1137b433617ac431b65185f4b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:52:15 -0700 Subject: [PATCH 07/22] feat: add --flow flag with auto-selection (device code preferred) Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 56 +++++++++++++++++++++++++++++++++++++++++++------ src/oidc/mod.rs | 11 ++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 311aaef..8cdb768 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,11 +66,15 @@ struct LoginArgs { #[arg(long)] duration: Option, + /// Authentication flow to use + #[arg(long, default_value = "auto")] + flow: oidc::FlowType, + /// OAuth2 scopes - #[arg(long, default_value = "openid")] + #[arg(long, default_value = "openid offline_access")] scope: String, - /// Local callback port (0 for random available port) + /// Local callback port for auth-code flow (0 for random available port) #[arg(long, default_value = "0")] port: u16, @@ -125,10 +129,50 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { eprintln!("Discovering OIDC endpoints..."); let discovery = oidc::discover(&args.issuer, verbose).await?; - // 2. Browser-based OIDC login - let token_response = - oidc::auth_code::login(&discovery, &args.client_id, &args.scope, args.port, verbose) - .await?; + // 2. Select and execute auth flow + let flow = match args.flow { + oidc::FlowType::Auto => { + if discovery.supports_device_code() + && discovery.device_authorization_endpoint.is_some() + { + if verbose { + eprintln!("[verbose] Auto-selected device code flow"); + } + oidc::FlowType::DeviceCode + } else { + if verbose { + eprintln!("[verbose] Auto-selected authorization code flow"); + } + oidc::FlowType::AuthCode + } + } + explicit => explicit, + }; + + let token_response = match flow { + oidc::FlowType::DeviceCode => { + if !discovery.supports_device_code() + || discovery.device_authorization_endpoint.is_none() + { + return Err( + "Provider does not support device code flow. Use --flow auth-code.".to_string(), + ); + } + oidc::device_code::login(&discovery, &args.client_id, &args.scope, verbose).await? + } + oidc::FlowType::AuthCode => { + oidc::auth_code::login( + &discovery, + &args.client_id, + &args.scope, + args.port, + verbose, + ) + .await? + } + oidc::FlowType::Auto => unreachable!(), + }; + let id_token = token_response .id_token .ok_or("No id_token in token response")?; diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index 86602e0..f9d4593 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -1,8 +1,19 @@ pub mod auth_code; pub mod device_code; +use clap::ValueEnum; use serde::Deserialize; +#[derive(Debug, Clone, ValueEnum)] +pub enum FlowType { + /// Automatically select the best available flow + Auto, + /// Device code flow (works everywhere including headless/SSH) + DeviceCode, + /// Authorization code + PKCE flow (requires browser on same machine) + AuthCode, +} + #[derive(Debug, Clone, Deserialize)] pub struct OidcDiscovery { pub issuer: String, From 5160da2b2d6714366bdf5d4079c38469597e0f14 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:53:54 -0700 Subject: [PATCH 08/22] feat: add refresh token cache with keyring and file fallback Co-Authored-By: Claude Opus 4.6 --- src/cache.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/src/cache.rs b/src/cache.rs index 3ce06df..6d50562 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,5 +1,6 @@ use crate::sts::Credentials; use chrono::Utc; +use serde::{Deserialize, Serialize}; use std::fs; use std::io; use std::path::PathBuf; @@ -154,6 +155,179 @@ pub fn is_expired(creds: &Credentials) -> Result { Ok(expiration <= now + buffer) } +// --------------------------------------------------------------------------- +// Refresh token caching (keyring + file fallback) +// --------------------------------------------------------------------------- + +const REFRESH_KEYRING_SERVICE: &str = "source-coop-cli:refresh"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshTokenData { + pub refresh_token: String, + pub issuer: String, + pub client_id: String, +} + +/// Produce a filesystem-safe key from an issuer URL. +fn issuer_key(issuer: &str) -> String { + sanitize_role_arn(issuer) +} + +/// Full path to the refresh-token cache file for a given issuer. +fn refresh_cache_path(issuer: &str) -> Result { + let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; + let key = issuer_key(issuer); + Ok(cache_dir + .join("source-coop") + .join("refresh") + .join(format!("{key}.json"))) +} + +/// Write refresh token data to a cache file. Returns the file path as a string. +fn write_refresh_token_file(data: &RefreshTokenData) -> Result { + let path = refresh_cache_path(&data.issuer)?; + let dir = path.parent().unwrap(); + + fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create refresh cache directory {}: {e}", dir.display()))?; + + let json = serde_json::to_string_pretty(data) + .map_err(|e| format!("Failed to serialize refresh token data: {e}"))?; + + fs::write(&path, &json) + .map_err(|e| format!("Failed to write refresh token cache {}: {e}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?; + } + + Ok(path.display().to_string()) +} + +/// Read refresh token data from a cache file. Returns `None` if the file does not exist. +fn read_refresh_token_file(issuer: &str) -> Result, String> { + let path = refresh_cache_path(issuer)?; + match fs::read_to_string(&path) { + Ok(contents) => { + let data: RefreshTokenData = serde_json::from_str(&contents) + .map_err(|e| format!("Failed to parse refresh token cache: {e}"))?; + Ok(Some(data)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!( + "Failed to read refresh token cache {}: {e}", + path.display() + )), + } +} + +/// Write a refresh token, trying the OS keyring first with file fallback. +/// Returns a human-readable description of where the token was stored. +pub fn write_refresh_token(data: &RefreshTokenData) -> Result { + let json = serde_json::to_string(data) + .map_err(|e| format!("Failed to serialize refresh token data: {e}"))?; + + let key = issuer_key(&data.issuer); + let entry = keyring::Entry::new(REFRESH_KEYRING_SERVICE, &key) + .map_err(|e| format!("Failed to create keyring entry: {e}")); + + if let Ok(entry) = entry { + match entry.set_password(&json) { + Ok(()) => { + return Ok(format!("OS keyring (service: {REFRESH_KEYRING_SERVICE})")); + } + Err(ref e) if is_keyring_unavailable(e) => { + // Fall through to file-based caching + } + Err(e) => { + return Err(format!("Failed to write refresh token to keyring: {e}")); + } + } + } + + write_refresh_token_file(data) +} + +/// Read a refresh token, trying the OS keyring first with file fallback. +/// Returns `None` if no cached refresh token is found in either location. +pub fn read_refresh_token(issuer: &str) -> Result, String> { + let key = issuer_key(issuer); + let entry = keyring::Entry::new(REFRESH_KEYRING_SERVICE, &key) + .map_err(|e| format!("Failed to create keyring entry: {e}")); + + if let Ok(entry) = entry { + match entry.get_password() { + Ok(json) => { + let data: RefreshTokenData = serde_json::from_str(&json) + .map_err(|e| format!("Failed to parse refresh token from keyring: {e}"))?; + return Ok(Some(data)); + } + Err(keyring::Error::NoEntry) => { + // Keyring works but nothing stored — fall through to file + } + Err(ref e) if is_keyring_unavailable(e) => { + // Keyring unavailable — fall through to file + } + Err(e) => { + return Err(format!("Failed to read refresh token from keyring: {e}")); + } + } + } + + read_refresh_token_file(issuer) +} + +/// Delete a refresh token from both keyring and file cache. +pub fn delete_refresh_token(issuer: &str) -> Result<(), String> { + let key = issuer_key(issuer); + + // Try deleting from keyring + if let Ok(entry) = keyring::Entry::new(REFRESH_KEYRING_SERVICE, &key) { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(ref e) if is_keyring_unavailable(e) => {} + Err(e) => { + return Err(format!( + "Failed to delete refresh token from keyring: {e}" + )); + } + } + } + + // Try deleting from file + let path = refresh_cache_path(issuer)?; + match fs::remove_file(&path) { + Ok(()) | Err(_) => {} // ignore file-not-found or other errors + } + + Ok(()) +} + +/// Delete credentials from both keyring and file cache (for logout). +pub fn delete_credentials(role_arn: &str) -> Result<(), String> { + // Try deleting from keyring + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, role_arn) { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(ref e) if is_keyring_unavailable(e) => {} + Err(e) => { + return Err(format!("Failed to delete credentials from keyring: {e}")); + } + } + } + + // Try deleting from file + let path = cache_path(role_arn)?; + match fs::remove_file(&path) { + Ok(()) | Err(_) => {} // ignore file-not-found or other errors + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From 4f629f66b15cae61d1759c7dd87b358494abe87b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:55:12 -0700 Subject: [PATCH 09/22] feat: add refresh token exchange and revocation Co-Authored-By: Claude Opus 4.6 --- src/oidc/mod.rs | 1 + src/oidc/refresh.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/oidc/refresh.rs diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index f9d4593..325c1bc 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -1,5 +1,6 @@ pub mod auth_code; pub mod device_code; +pub mod refresh; use clap::ValueEnum; use serde::Deserialize; diff --git a/src/oidc/refresh.rs b/src/oidc/refresh.rs new file mode 100644 index 0000000..53f8381 --- /dev/null +++ b/src/oidc/refresh.rs @@ -0,0 +1,104 @@ +use super::{OidcDiscovery, TokenResponse}; + +/// Exchange a refresh token for new tokens. +pub async fn refresh( + discovery: &OidcDiscovery, + client_id: &str, + refresh_token: &str, + verbose: bool, +) -> Result { + if verbose { + eprintln!("[verbose] POST {}", discovery.token_endpoint); + eprintln!("[verbose] grant_type=refresh_token"); + eprintln!("[verbose] client_id={client_id}"); + } + + let client = reqwest::Client::new(); + let resp = client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", client_id), + ]) + .send() + .await + .map_err(|e| format!("Refresh token request failed: {e}"))?; + + if verbose { + eprintln!("[verbose] Response: {}", resp.status()); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token refresh failed (HTTP {status}): {body}")); + } + + let token_response: TokenResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse token response: {e}"))?; + + if token_response.id_token.is_none() { + return Err("No id_token in refresh response".to_string()); + } + + if verbose { + eprintln!("[verbose] Received refreshed id_token"); + if token_response.refresh_token.is_some() { + eprintln!("[verbose] Received rotated refresh_token"); + } + } + + Ok(token_response) +} + +/// Revoke a refresh token at the provider's revocation endpoint. +pub async fn revoke( + discovery: &OidcDiscovery, + client_id: &str, + refresh_token: &str, + verbose: bool, +) -> Result<(), String> { + let revocation_endpoint = match &discovery.revocation_endpoint { + Some(ep) => ep, + None => { + if verbose { + eprintln!("[verbose] No revocation endpoint; skipping token revocation"); + } + return Ok(()); + } + }; + + if verbose { + eprintln!("[verbose] POST {revocation_endpoint}"); + eprintln!("[verbose] token_type_hint=refresh_token"); + eprintln!("[verbose] client_id={client_id}"); + } + + let client = reqwest::Client::new(); + let resp = client + .post(revocation_endpoint) + .form(&[ + ("token", refresh_token), + ("token_type_hint", "refresh_token"), + ("client_id", client_id), + ]) + .send() + .await + .map_err(|e| format!("Token revocation request failed: {e}"))?; + + if verbose { + eprintln!("[verbose] Revocation response: {}", resp.status()); + } + + // RFC 7009: revocation endpoint returns 200 even if token was already invalid + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + eprintln!("Warning: token revocation returned HTTP {status}: {body}"); + } + + Ok(()) +} From b04d588df63096a60ab98e9851c30a82df0b5e3a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:56:38 -0700 Subject: [PATCH 10/22] feat: cache refresh token during login Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main.rs b/src/main.rs index 8cdb768..ec596e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,6 +178,25 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { .ok_or("No id_token in token response")?; eprintln!("Authentication successful."); + // Cache refresh token if present + if let Some(ref refresh_token) = token_response.refresh_token { + let data = cache::RefreshTokenData { + refresh_token: refresh_token.clone(), + issuer: args.issuer.clone(), + client_id: args.client_id.clone(), + }; + match cache::write_refresh_token(&data) { + Ok(location) => { + if verbose { + eprintln!("[verbose] Refresh token cached to {location}"); + } + } + Err(e) => { + eprintln!("Warning: could not cache refresh token: {e}"); + } + } + } + // 3. STS credential exchange if verbose { eprintln!("[verbose] Assuming role: {}", args.role_arn); From c9f822296579e1332387925cb0d16f4cd46127ec Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:57:19 -0700 Subject: [PATCH 11/22] feat: add auto-refresh of expired credentials in creds command Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index ec596e7..625cb96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,18 @@ struct CredsArgs { /// Output format #[arg(long, default_value = "credential-process")] format: OutputFormat, + + /// Do not attempt to refresh expired credentials automatically + #[arg(long)] + no_refresh: bool, + + /// OIDC issuer URL (used for auto-refresh) + #[arg(long, env = "SOURCE_OIDC_ISSUER", default_value = defaults::ISSUER)] + issuer: String, + + /// S3 proxy URL for STS (used for auto-refresh) + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, } #[derive(Clone, ValueEnum)] @@ -116,7 +128,7 @@ async fn main() { } } Commands::Creds(args) => { - if let Err(e) = run_creds(args) { + if let Err(e) = run_creds(args, verbose).await { eprintln!("Error: {e}"); std::process::exit(1); } @@ -228,19 +240,93 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { Ok(()) } -fn run_creds(args: CredsArgs) -> Result<(), String> { - let creds = cache::read_credentials(&args.role_arn)? - .ok_or("No cached credentials found. Run 'source-coop login' first.")?; +async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { + let creds = cache::read_credentials(&args.role_arn)?; + + // If we have valid (non-expired) cached credentials, output them directly + if let Some(ref c) = creds { + if !cache::is_expired(c)? { + match args.format { + OutputFormat::CredentialProcess => output::print_credential_process(c), + OutputFormat::Env => output::print_env(c), + } + return Ok(()); + } + } - if cache::is_expired(&creds)? { + // Credentials are missing or expired + if args.no_refresh { return Err( "Cached credentials have expired. Run 'source-coop login' to refresh.".to_string(), ); } + // Attempt auto-refresh using cached refresh token + let refresh_data = cache::read_refresh_token(&args.issuer)? + .ok_or("Cached credentials have expired and no refresh token is available. Run 'source-coop login' to re-authenticate.")?; + + eprintln!("Credentials expired. Refreshing..."); + + // 1. OIDC Discovery + if verbose { + eprintln!("[verbose] Discovering OIDC endpoints for auto-refresh..."); + } + let discovery = oidc::discover(&refresh_data.issuer, verbose).await?; + + // 2. Refresh the token + let token_response = oidc::refresh::refresh( + &discovery, + &refresh_data.client_id, + &refresh_data.refresh_token, + verbose, + ) + .await?; + + let id_token = token_response + .id_token + .ok_or("No id_token in refresh response")?; + + // 3. Cache rotated refresh token if present + if let Some(ref new_refresh_token) = token_response.refresh_token { + let new_data = cache::RefreshTokenData { + refresh_token: new_refresh_token.clone(), + issuer: refresh_data.issuer.clone(), + client_id: refresh_data.client_id.clone(), + }; + match cache::write_refresh_token(&new_data) { + Ok(location) => { + if verbose { + eprintln!("[verbose] Rotated refresh token cached to {location}"); + } + } + Err(e) => { + eprintln!("Warning: could not cache rotated refresh token: {e}"); + } + } + } + + // 4. STS credential exchange + if verbose { + eprintln!("[verbose] Assuming role: {}", args.role_arn); + } + let new_creds = sts::assume_role( + &args.proxy_url, + &args.role_arn, + &id_token, + None, + verbose, + ) + .await?; + + // 5. Cache new credentials + let location = cache::write_credentials(&args.role_arn, &new_creds)?; + eprintln!("Credentials refreshed and cached to {location}"); + + // 6. Output match args.format { - OutputFormat::CredentialProcess => output::print_credential_process(&creds), - OutputFormat::Env => output::print_env(&creds), + OutputFormat::CredentialProcess => output::print_credential_process(&new_creds), + OutputFormat::Env => output::print_env(&new_creds), } + Ok(()) } From 0d5e2009de9826a644b7b074705f7276441afb90 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 15:59:11 -0700 Subject: [PATCH 12/22] feat: add logout command with token revocation Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/main.rs b/src/main.rs index 625cb96..df5837b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,8 @@ enum Commands { Login(LoginArgs), /// Output cached credentials as credential_process JSON or shell env vars Creds(CredsArgs), + /// Clear cached credentials and revoke refresh token + Logout(LogoutArgs), } #[derive(Parser)] @@ -106,6 +108,21 @@ struct CredsArgs { proxy_url: String, } +#[derive(Parser)] +struct LogoutArgs { + /// OIDC issuer URL + #[arg(long, env = "SOURCE_OIDC_ISSUER", default_value = defaults::ISSUER)] + issuer: String, + + /// OAuth2 client ID + #[arg(long, env = "SOURCE_OIDC_CLIENT_ID", default_value = defaults::CLIENT_ID)] + client_id: String, + + /// Role ARN to clear cached credentials for + #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] + role_arn: String, +} + #[derive(Clone, ValueEnum)] enum OutputFormat { /// AWS credential_process JSON format @@ -133,6 +150,12 @@ async fn main() { std::process::exit(1); } } + Commands::Logout(args) => { + if let Err(e) = run_logout(args, verbose).await { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } } } @@ -330,3 +353,44 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { Ok(()) } + +async fn run_logout(args: LogoutArgs, verbose: bool) -> Result<(), String> { + // 1. Try to revoke the refresh token at the provider (best-effort) + match cache::read_refresh_token(&args.issuer)? { + Some(refresh_data) => { + match oidc::discover(&args.issuer, verbose).await { + Ok(discovery) => { + if let Err(e) = oidc::refresh::revoke( + &discovery, + &refresh_data.client_id, + &refresh_data.refresh_token, + verbose, + ) + .await + { + eprintln!("Warning: could not revoke refresh token: {e}"); + } + } + Err(e) => { + eprintln!("Warning: could not discover OIDC endpoints for revocation: {e}"); + } + } + } + None => { + if verbose { + eprintln!("[verbose] No cached refresh token found; skipping revocation"); + } + } + } + + // 2. Delete cached refresh token + cache::delete_refresh_token(&args.issuer)?; + eprintln!("Refresh token cleared."); + + // 3. Delete cached AWS credentials + cache::delete_credentials(&args.role_arn)?; + eprintln!("Cached credentials cleared."); + + eprintln!("Logged out successfully."); + Ok(()) +} From 978965f19db3f13d9a0504251c98317d586af119 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 16:01:15 -0700 Subject: [PATCH 13/22] chore: suppress dead_code warnings on serde deserialization structs Co-Authored-By: Claude Opus 4.6 --- src/oidc/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index 325c1bc..ff3a3cb 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -16,6 +16,7 @@ pub enum FlowType { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct OidcDiscovery { pub issuer: String, pub authorization_endpoint: String, @@ -42,6 +43,7 @@ impl OidcDiscovery { } #[derive(Debug, Deserialize)] +#[allow(dead_code)] pub struct TokenResponse { pub id_token: Option, pub refresh_token: Option, From e93709c09bcbeffdd824b54142f614427ca7699b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 12 Mar 2026 16:06:14 -0700 Subject: [PATCH 14/22] refactor: rename sanitize_role_arn to sanitize_for_filename, improve delete error handling Co-Authored-By: Claude Opus 4.6 --- src/cache.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 6d50562..378d7b7 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -20,8 +20,8 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { } /// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. -fn sanitize_role_arn(role_arn: &str) -> String { - role_arn +fn sanitize_for_filename(s: &str) -> String { + s .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { @@ -38,7 +38,7 @@ fn sanitize_role_arn(role_arn: &str) -> String { /// `~/.cache` on Linux, `%LocalAppData%` on Windows). fn cache_path(role_arn: &str) -> Result { let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; - let sanitized = sanitize_role_arn(role_arn); + let sanitized = sanitize_for_filename(role_arn); Ok(cache_dir .join("source-coop") .join("credentials") @@ -170,7 +170,7 @@ pub struct RefreshTokenData { /// Produce a filesystem-safe key from an issuer URL. fn issuer_key(issuer: &str) -> String { - sanitize_role_arn(issuer) + sanitize_for_filename(issuer) } /// Full path to the refresh-token cache file for a given issuer. @@ -300,7 +300,11 @@ pub fn delete_refresh_token(issuer: &str) -> Result<(), String> { // Try deleting from file let path = refresh_cache_path(issuer)?; match fs::remove_file(&path) { - Ok(()) | Err(_) => {} // ignore file-not-found or other errors + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + eprintln!("Warning: could not delete {}: {e}", path.display()); + } } Ok(()) @@ -322,7 +326,11 @@ pub fn delete_credentials(role_arn: &str) -> Result<(), String> { // Try deleting from file let path = cache_path(role_arn)?; match fs::remove_file(&path) { - Ok(()) | Err(_) => {} // ignore file-not-found or other errors + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + eprintln!("Warning: could not delete {}: {e}", path.display()); + } } Ok(()) @@ -343,20 +351,20 @@ mod tests { #[test] fn sanitize_simple_name() { - assert_eq!(sanitize_role_arn("source-coop-user"), "source-coop-user"); + assert_eq!(sanitize_for_filename("source-coop-user"), "source-coop-user"); } #[test] fn sanitize_arn_with_special_chars() { assert_eq!( - sanitize_role_arn("arn:aws:iam::123:role/Foo"), + sanitize_for_filename("arn:aws:iam::123:role/Foo"), "arn_aws_iam__123_role_Foo" ); } #[test] fn sanitize_preserves_underscores() { - assert_eq!(sanitize_role_arn("my_role-name"), "my_role-name"); + assert_eq!(sanitize_for_filename("my_role-name"), "my_role-name"); } #[test] From d74b881df9aed259124f3502b958e7da3a844ac4 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 7 Apr 2026 16:57:21 -0700 Subject: [PATCH 15/22] feat: add proxy_url and role_arn to RefreshTokenData struct and update login/creds flow --- src/cache.rs | 4 ++++ src/main.rs | 51 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 378d7b7..03b178d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -166,6 +166,10 @@ pub struct RefreshTokenData { pub refresh_token: String, pub issuer: String, pub client_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role_arn: Option, } /// Produce a filesystem-safe key from an issuer URL. diff --git a/src/main.rs b/src/main.rs index df5837b..7728e7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use clap::{Parser, Subcommand, ValueEnum}; mod defaults { pub const ISSUER: &str = "https://auth.staging.source.coop"; pub const CLIENT_ID: &str = "c445cc61-9884-44a8-b051-8d8f7273ffc1"; - pub const PROXY_URL: &str = "https://staging.data.source.coop"; + pub const PROXY_URL: &str = "https://staging.data.source.coop/.sts"; pub const ROLE_ARN: &str = "default"; } @@ -17,7 +17,7 @@ mod defaults { mod defaults { pub const ISSUER: &str = "https://auth.source.coop"; pub const CLIENT_ID: &str = "d037d00b-09c7-4815-ac39-2a0b9fae40c6"; - pub const PROXY_URL: &str = "https://data.source.coop"; + pub const PROXY_URL: &str = "https://data.source.coop/.sts"; pub const ROLE_ARN: &str = "default"; } @@ -165,21 +165,14 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { let discovery = oidc::discover(&args.issuer, verbose).await?; // 2. Select and execute auth flow + // Auto defaults to auth-code because device-code requires per-client + // grant configuration that the discovery document doesn't reflect. let flow = match args.flow { oidc::FlowType::Auto => { - if discovery.supports_device_code() - && discovery.device_authorization_endpoint.is_some() - { - if verbose { - eprintln!("[verbose] Auto-selected device code flow"); - } - oidc::FlowType::DeviceCode - } else { - if verbose { - eprintln!("[verbose] Auto-selected authorization code flow"); - } - oidc::FlowType::AuthCode + if verbose { + eprintln!("[verbose] Auto-selected authorization code flow"); } + oidc::FlowType::AuthCode } explicit => explicit, }; @@ -219,6 +212,8 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { refresh_token: refresh_token.clone(), issuer: args.issuer.clone(), client_id: args.client_id.clone(), + proxy_url: Some(args.proxy_url.clone()), + role_arn: Some(args.role_arn.clone()), }; match cache::write_refresh_token(&data) { Ok(location) => { @@ -264,7 +259,20 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { } async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { - let creds = cache::read_credentials(&args.role_arn)?; + // Load refresh data early so we can resolve the role_arn and proxy_url + // from the original login session (falling back to CLI args/defaults). + let refresh_data = cache::read_refresh_token(&args.issuer)?; + + let role_arn = refresh_data + .as_ref() + .and_then(|r| r.role_arn.clone()) + .unwrap_or_else(|| args.role_arn.clone()); + let proxy_url = refresh_data + .as_ref() + .and_then(|r| r.proxy_url.clone()) + .unwrap_or_else(|| args.proxy_url.clone()); + + let creds = cache::read_credentials(&role_arn)?; // If we have valid (non-expired) cached credentials, output them directly if let Some(ref c) = creds { @@ -284,8 +292,7 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { ); } - // Attempt auto-refresh using cached refresh token - let refresh_data = cache::read_refresh_token(&args.issuer)? + let refresh_data = refresh_data .ok_or("Cached credentials have expired and no refresh token is available. Run 'source-coop login' to re-authenticate.")?; eprintln!("Credentials expired. Refreshing..."); @@ -315,6 +322,8 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { refresh_token: new_refresh_token.clone(), issuer: refresh_data.issuer.clone(), client_id: refresh_data.client_id.clone(), + proxy_url: refresh_data.proxy_url.clone(), + role_arn: refresh_data.role_arn.clone(), }; match cache::write_refresh_token(&new_data) { Ok(location) => { @@ -330,11 +339,11 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { // 4. STS credential exchange if verbose { - eprintln!("[verbose] Assuming role: {}", args.role_arn); + eprintln!("[verbose] Assuming role: {role_arn}"); } let new_creds = sts::assume_role( - &args.proxy_url, - &args.role_arn, + &proxy_url, + &role_arn, &id_token, None, verbose, @@ -342,7 +351,7 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { .await?; // 5. Cache new credentials - let location = cache::write_credentials(&args.role_arn, &new_creds)?; + let location = cache::write_credentials(&role_arn, &new_creds)?; eprintln!("Credentials refreshed and cached to {location}"); // 6. Output From 84af64b456abd59812ea96b74e71548e94cb4971 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 7 Apr 2026 23:11:38 -0700 Subject: [PATCH 16/22] add debug logging --- src/main.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main.rs b/src/main.rs index 7728e7a..7266e8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -206,6 +206,21 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { .ok_or("No id_token in token response")?; eprintln!("Authentication successful."); + if verbose { + // Decode and display the JWT claims (header.payload.signature) + if let Some(payload) = id_token.split('.').nth(1) { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + match URL_SAFE_NO_PAD.decode(payload) { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(json) => eprintln!("[verbose] ID token claims: {json}"), + Err(_) => eprintln!("[verbose] Could not decode ID token payload as UTF-8"), + }, + Err(_) => eprintln!("[verbose] Could not base64-decode ID token payload"), + } + } + } + // Cache refresh token if present if let Some(ref refresh_token) = token_response.refresh_token { let data = cache::RefreshTokenData { From 5f1e8637539478b80d74f54a5e0d9104e7d8cb38 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jun 2026 15:19:32 -0700 Subject: [PATCH 17/22] chore: cargo fmt Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cache.rs | 28 ++++++++++++++-------- src/main.rs | 51 +++++++++++++++-------------------------- src/oidc/device_code.rs | 4 +--- 3 files changed, 37 insertions(+), 46 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 03b178d..e72c80b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -21,8 +21,7 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { /// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. fn sanitize_for_filename(s: &str) -> String { - s - .chars() + s.chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c @@ -192,14 +191,22 @@ fn write_refresh_token_file(data: &RefreshTokenData) -> Result { let path = refresh_cache_path(&data.issuer)?; let dir = path.parent().unwrap(); - fs::create_dir_all(dir) - .map_err(|e| format!("Failed to create refresh cache directory {}: {e}", dir.display()))?; + fs::create_dir_all(dir).map_err(|e| { + format!( + "Failed to create refresh cache directory {}: {e}", + dir.display() + ) + })?; let json = serde_json::to_string_pretty(data) .map_err(|e| format!("Failed to serialize refresh token data: {e}"))?; - fs::write(&path, &json) - .map_err(|e| format!("Failed to write refresh token cache {}: {e}", path.display()))?; + fs::write(&path, &json).map_err(|e| { + format!( + "Failed to write refresh token cache {}: {e}", + path.display() + ) + })?; #[cfg(unix)] { @@ -294,9 +301,7 @@ pub fn delete_refresh_token(issuer: &str) -> Result<(), String> { Ok(()) | Err(keyring::Error::NoEntry) => {} Err(ref e) if is_keyring_unavailable(e) => {} Err(e) => { - return Err(format!( - "Failed to delete refresh token from keyring: {e}" - )); + return Err(format!("Failed to delete refresh token from keyring: {e}")); } } } @@ -355,7 +360,10 @@ mod tests { #[test] fn sanitize_simple_name() { - assert_eq!(sanitize_for_filename("source-coop-user"), "source-coop-user"); + assert_eq!( + sanitize_for_filename("source-coop-user"), + "source-coop-user" + ); } #[test] diff --git a/src/main.rs b/src/main.rs index 7266e8c..e233e81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -189,14 +189,8 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { oidc::device_code::login(&discovery, &args.client_id, &args.scope, verbose).await? } oidc::FlowType::AuthCode => { - oidc::auth_code::login( - &discovery, - &args.client_id, - &args.scope, - args.port, - verbose, - ) - .await? + oidc::auth_code::login(&discovery, &args.client_id, &args.scope, args.port, verbose) + .await? } oidc::FlowType::Auto => unreachable!(), }; @@ -356,14 +350,7 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { if verbose { eprintln!("[verbose] Assuming role: {role_arn}"); } - let new_creds = sts::assume_role( - &proxy_url, - &role_arn, - &id_token, - None, - verbose, - ) - .await?; + let new_creds = sts::assume_role(&proxy_url, &role_arn, &id_token, None, verbose).await?; // 5. Cache new credentials let location = cache::write_credentials(&role_arn, &new_creds)?; @@ -381,25 +368,23 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { async fn run_logout(args: LogoutArgs, verbose: bool) -> Result<(), String> { // 1. Try to revoke the refresh token at the provider (best-effort) match cache::read_refresh_token(&args.issuer)? { - Some(refresh_data) => { - match oidc::discover(&args.issuer, verbose).await { - Ok(discovery) => { - if let Err(e) = oidc::refresh::revoke( - &discovery, - &refresh_data.client_id, - &refresh_data.refresh_token, - verbose, - ) - .await - { - eprintln!("Warning: could not revoke refresh token: {e}"); - } - } - Err(e) => { - eprintln!("Warning: could not discover OIDC endpoints for revocation: {e}"); + Some(refresh_data) => match oidc::discover(&args.issuer, verbose).await { + Ok(discovery) => { + if let Err(e) = oidc::refresh::revoke( + &discovery, + &refresh_data.client_id, + &refresh_data.refresh_token, + verbose, + ) + .await + { + eprintln!("Warning: could not revoke refresh token: {e}"); } } - } + Err(e) => { + eprintln!("Warning: could not discover OIDC endpoints for revocation: {e}"); + } + }, None => { if verbose { eprintln!("[verbose] No cached refresh token found; skipping revocation"); diff --git a/src/oidc/device_code.rs b/src/oidc/device_code.rs index 36ac8e3..bc1f03e 100644 --- a/src/oidc/device_code.rs +++ b/src/oidc/device_code.rs @@ -172,9 +172,7 @@ pub async fn login( "slow_down" => { interval += 5; if verbose { - eprintln!( - "[verbose] Received slow_down, increasing interval to {interval}s" - ); + eprintln!("[verbose] Received slow_down, increasing interval to {interval}s"); } } "expired_token" => { From 83a52416178941f99154ab580152f5a2778dfdf0 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jun 2026 15:52:36 -0700 Subject: [PATCH 18/22] refactor: trim OIDC structs, collapse flow selection, dedupe cache deletes - Drop unused OidcDiscovery/TokenResponse fields and the FlowType::Auto variant (it always resolved to AuthCode); default --flow is now auth-code. - Remove verbose JWT-claims decode block from run_login. - Inline issuer_key and merge delete_refresh_token/delete_credentials into a shared delete_entry helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cache.rs | 64 +++++++++++++++---------------------------------- src/main.rs | 32 ++----------------------- src/oidc/mod.rs | 11 +++------ 3 files changed, 24 insertions(+), 83 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index e72c80b..4d2f2f8 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -171,15 +171,10 @@ pub struct RefreshTokenData { pub role_arn: Option, } -/// Produce a filesystem-safe key from an issuer URL. -fn issuer_key(issuer: &str) -> String { - sanitize_for_filename(issuer) -} - /// Full path to the refresh-token cache file for a given issuer. fn refresh_cache_path(issuer: &str) -> Result { let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; - let key = issuer_key(issuer); + let key = sanitize_for_filename(issuer); Ok(cache_dir .join("source-coop") .join("refresh") @@ -241,7 +236,7 @@ pub fn write_refresh_token(data: &RefreshTokenData) -> Result { let json = serde_json::to_string(data) .map_err(|e| format!("Failed to serialize refresh token data: {e}"))?; - let key = issuer_key(&data.issuer); + let key = sanitize_for_filename(&data.issuer); let entry = keyring::Entry::new(REFRESH_KEYRING_SERVICE, &key) .map_err(|e| format!("Failed to create keyring entry: {e}")); @@ -265,7 +260,7 @@ pub fn write_refresh_token(data: &RefreshTokenData) -> Result { /// Read a refresh token, trying the OS keyring first with file fallback. /// Returns `None` if no cached refresh token is found in either location. pub fn read_refresh_token(issuer: &str) -> Result, String> { - let key = issuer_key(issuer); + let key = sanitize_for_filename(issuer); let entry = keyring::Entry::new(REFRESH_KEYRING_SERVICE, &key) .map_err(|e| format!("Failed to create keyring entry: {e}")); @@ -291,58 +286,37 @@ pub fn read_refresh_token(issuer: &str) -> Result, Stri read_refresh_token_file(issuer) } -/// Delete a refresh token from both keyring and file cache. -pub fn delete_refresh_token(issuer: &str) -> Result<(), String> { - let key = issuer_key(issuer); - - // Try deleting from keyring - if let Ok(entry) = keyring::Entry::new(REFRESH_KEYRING_SERVICE, &key) { +/// Delete a keyring entry (ignoring "not found") and its file-cache fallback. +fn delete_entry(service: &str, key: &str, path: &PathBuf) -> Result<(), String> { + if let Ok(entry) = keyring::Entry::new(service, key) { match entry.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => {} Err(ref e) if is_keyring_unavailable(e) => {} - Err(e) => { - return Err(format!("Failed to delete refresh token from keyring: {e}")); - } + Err(e) => return Err(format!("Failed to delete from keyring: {e}")), } } - // Try deleting from file - let path = refresh_cache_path(issuer)?; - match fs::remove_file(&path) { + match fs::remove_file(path) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::NotFound => {} - Err(e) => { - eprintln!("Warning: could not delete {}: {e}", path.display()); - } + Err(e) => eprintln!("Warning: could not delete {}: {e}", path.display()), } Ok(()) } +/// Delete a refresh token from both keyring and file cache. +pub fn delete_refresh_token(issuer: &str) -> Result<(), String> { + delete_entry( + REFRESH_KEYRING_SERVICE, + &sanitize_for_filename(issuer), + &refresh_cache_path(issuer)?, + ) +} + /// Delete credentials from both keyring and file cache (for logout). pub fn delete_credentials(role_arn: &str) -> Result<(), String> { - // Try deleting from keyring - if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, role_arn) { - match entry.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => {} - Err(ref e) if is_keyring_unavailable(e) => {} - Err(e) => { - return Err(format!("Failed to delete credentials from keyring: {e}")); - } - } - } - - // Try deleting from file - let path = cache_path(role_arn)?; - match fs::remove_file(&path) { - Ok(()) => {} - Err(e) if e.kind() == io::ErrorKind::NotFound => {} - Err(e) => { - eprintln!("Warning: could not delete {}: {e}", path.display()); - } - } - - Ok(()) + delete_entry(KEYRING_SERVICE, role_arn, &cache_path(role_arn)?) } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index e233e81..0774617 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,7 +69,7 @@ struct LoginArgs { duration: Option, /// Authentication flow to use - #[arg(long, default_value = "auto")] + #[arg(long, default_value = "auth-code")] flow: oidc::FlowType, /// OAuth2 scopes @@ -165,19 +165,7 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { let discovery = oidc::discover(&args.issuer, verbose).await?; // 2. Select and execute auth flow - // Auto defaults to auth-code because device-code requires per-client - // grant configuration that the discovery document doesn't reflect. - let flow = match args.flow { - oidc::FlowType::Auto => { - if verbose { - eprintln!("[verbose] Auto-selected authorization code flow"); - } - oidc::FlowType::AuthCode - } - explicit => explicit, - }; - - let token_response = match flow { + let token_response = match args.flow { oidc::FlowType::DeviceCode => { if !discovery.supports_device_code() || discovery.device_authorization_endpoint.is_none() @@ -192,7 +180,6 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { oidc::auth_code::login(&discovery, &args.client_id, &args.scope, args.port, verbose) .await? } - oidc::FlowType::Auto => unreachable!(), }; let id_token = token_response @@ -200,21 +187,6 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { .ok_or("No id_token in token response")?; eprintln!("Authentication successful."); - if verbose { - // Decode and display the JWT claims (header.payload.signature) - if let Some(payload) = id_token.split('.').nth(1) { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine; - match URL_SAFE_NO_PAD.decode(payload) { - Ok(bytes) => match String::from_utf8(bytes) { - Ok(json) => eprintln!("[verbose] ID token claims: {json}"), - Err(_) => eprintln!("[verbose] Could not decode ID token payload as UTF-8"), - }, - Err(_) => eprintln!("[verbose] Could not base64-decode ID token payload"), - } - } - } - // Cache refresh token if present if let Some(ref refresh_token) = token_response.refresh_token { let data = cache::RefreshTokenData { diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index ff3a3cb..d766a74 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -7,8 +7,6 @@ use serde::Deserialize; #[derive(Debug, Clone, ValueEnum)] pub enum FlowType { - /// Automatically select the best available flow - Auto, /// Device code flow (works everywhere including headless/SSH) DeviceCode, /// Authorization code + PKCE flow (requires browser on same machine) @@ -24,12 +22,10 @@ pub struct OidcDiscovery { pub device_authorization_endpoint: Option, pub revocation_endpoint: Option, pub grant_types_supported: Option>, - pub scopes_supported: Option>, - pub code_challenge_methods_supported: Option>, } impl OidcDiscovery { - pub fn supports_grant_type(&self, grant_type: &str) -> bool { + fn supports_grant_type(&self, grant_type: &str) -> bool { self.grant_types_supported .as_ref() .map(|types| types.iter().any(|t| t == grant_type)) @@ -37,19 +33,18 @@ impl OidcDiscovery { } pub fn supports_device_code(&self) -> bool { + // Source Coop advertises the short form `device_code`; the RFC 8628 + // registered name is the URN. Accept either. self.supports_grant_type("urn:ietf:params:oauth:grant-type:device_code") || self.supports_grant_type("device_code") } } #[derive(Debug, Deserialize)] -#[allow(dead_code)] pub struct TokenResponse { pub id_token: Option, pub refresh_token: Option, pub access_token: Option, - pub token_type: Option, - pub expires_in: Option, } /// Fetch OIDC discovery document and deserialize into OidcDiscovery. From 99ebe5560061614726b2236d681388a1eb191e31 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jun 2026 15:52:47 -0700 Subject: [PATCH 19/22] fix: correct staging OAuth client ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed staging CLIENT_ID (c445cc61…) does not exist in the staging Ory project; auth.staging.source.coop returns invalid_client for it. The real source-coop-cli client is a79c9537-be78-454a-9ea1-b96a1be811cc. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 0774617..781ef4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use clap::{Parser, Subcommand, ValueEnum}; #[cfg(feature = "staging")] mod defaults { pub const ISSUER: &str = "https://auth.staging.source.coop"; - pub const CLIENT_ID: &str = "c445cc61-9884-44a8-b051-8d8f7273ffc1"; + pub const CLIENT_ID: &str = "a79c9537-be78-454a-9ea1-b96a1be811cc"; pub const PROXY_URL: &str = "https://staging.data.source.coop/.sts"; pub const ROLE_ARN: &str = "default"; } From 49efc76348345a898a5081049f0376cf4512a1ab Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jun 2026 15:56:02 -0700 Subject: [PATCH 20/22] chore: add .env.staging for pointing the CLI at staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source it (`source .env.staging`) to override issuer/client/proxy via the existing SOURCE_* env vars — no rebuild needed. Values are public (OIDC public client + public URLs). Ignore other .env files to avoid committing secrets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.staging | 13 +++++++++++++ .gitignore | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .env.staging diff --git a/.env.staging b/.env.staging new file mode 100644 index 0000000..8a04900 --- /dev/null +++ b/.env.staging @@ -0,0 +1,13 @@ +# Staging overrides for source-coop. These are public values (OIDC public +# client + public URLs), not secrets. Load into your shell with: +# +# source .env.staging +# source-coop login +# +# To avoid leaving these set in your shell afterward, scope them to a subshell: +# +# ( source .env.staging && source-coop login ) + +export SOURCE_OIDC_ISSUER=https://auth.staging.source.coop +export SOURCE_OIDC_CLIENT_ID=a79c9537-be78-454a-9ea1-b96a1be811cc +export SOURCE_PROXY_URL=https://staging.data.source.coop/.sts diff --git a/.gitignore b/.gitignore index ea8c4bf..2b56efe 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ /target + +# Local env files may hold secrets; .env.staging is public and intentionally tracked. +.env +.env.local +!.env.staging From 3fe684980dc2a0b4f2ab9aacca05c3934d3383e0 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jun 2026 19:33:25 -0700 Subject: [PATCH 21/22] docs: document Ory Network device-flow limitation Device-code login is blocked: Ory Network silently drops the urls.device.verification/success project-config keys, so Hydra can't be pointed at a custom verification UI. Captures what works, how it fails, root cause, and the ask. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ory-network-device-flow-limitation.md | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/ory-network-device-flow-limitation.md diff --git a/docs/ory-network-device-flow-limitation.md b/docs/ory-network-device-flow-limitation.md new file mode 100644 index 0000000..6141e4a --- /dev/null +++ b/docs/ory-network-device-flow-limitation.md @@ -0,0 +1,92 @@ +# Ory Network: `urls.device.verification` / `urls.device.success` silently dropped — device flow unusable + +**Environment:** Ory Network (managed). OAuth2 device authorization grant (RFC 8628). + +## What works + +1. **The grant + code issuance.** Adding the device grant to an OAuth2 client + succeeds, and the device-authorization endpoint issues codes: + + ```bash + ory update oauth2-client \ + --grant-type authorization_code --grant-type refresh_token \ + --grant-type urn:ietf:params:oauth:grant-type:device_code \ + --token-endpoint-auth-method none … + + curl -X POST https://.projects.oryapis.com/oauth2/device/auth \ + -d client_id= -d 'scope=openid offline_access' + # → returns user_code, device_code, verification_uri, verification_uri_complete ✓ + ``` + +2. **The verification mechanism itself.** A self-hosted verification page would + work fully. Taking the `device_challenge` that Hydra puts in the fallback URL + and accepting the code via the admin API returns `200` with a `redirect_to` + that continues into the normal login/consent flow: + + ```bash + curl -X PUT "https://.projects.oryapis.com/admin/oauth2/auth/requests/device/accept?device_challenge=" \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"user_code":""}' + # → 200 { "redirect_to": ".../oauth2/device/verify?client_id=…&device_verifier=…&user_code=…" } + ``` + + So every step is functional — issuance, code entry, accept, and hand-off to + login/consent. **The only missing link is getting Hydra to send the browser + to a custom verification page in the first place.** + +## What fails + +Setting the device verification/success UI URLs is **silently discarded**: + +```bash +ory patch project \ + --add '/services/oauth2/config/urls/device={"verification":"https://app.example.com/device","success":"https://app.example.com/device/success"}' +# → "Project updated successfully!" (no error, device NOT in the ignored-keys warning) +``` + +Two ways to confirm it didn't persist: + +```bash +# 1. Read-back: device key is absent +ory get project --format json | jq '.services.oauth2.config.urls' +# → { consent, error, login, logout, post_logout_redirect, registration, self } ← no "device" + +# 2. Live flow still hits the built-in fallback +# following verification_uri_complete 302-redirects to: +# https://.projects.oryapis.com/oauth2/fallbacks/device?device_challenge=… +``` + +## Root cause + +Ory Network persists Hydra config as a flat `normalizedProjectRevision`, which +has **no field** for `urls.device.verification` / `urls.device.success` (only +login/consent/logout/error/registration/post_logout_redirect/self exist). +Self-hosted Hydra's config schema defines these keys; the managed Network schema +does not expose them, so the API accepts the patch and drops the key. Confirmed +across `ory patch project`, `ory patch oauth2-config`, and `ory update` — all +normalize into the same schema. + +## Impact + +With `urls.device.verification` unset, Hydra routes the user to +`/oauth2/fallbacks/device`, a **hardcoded static error page** ("configuration +key `urls.device.verification` is not set") — no code-entry form. Since the +verification mechanism itself works (see above), this is purely a routing gap: +there is no way to point the browser at a working verification UI. The user can +never approve the device, so the client's token poll never completes and device +login hangs until the code expires. + +## Ask + +Expose `urls.device.verification` / `urls.device.success` in the Ory Network +project config schema (the Hydra device grant shipped in v25.4.0, but these URL +keys aren't settable on Network), or document the supported way to point the +device flow at a custom verification UI. + +## References + +- [Ory — Device Authorization docs](https://www.ory.com/docs/oauth2-oidc/device-authorization) +- [NormalizedProjectRevision schema (ory/client-go)](https://github.com/ory/client-go/blob/master/docs/NormalizedProjectRevision.md) +- [Ory v25.4.0 changelog](https://changelog.ory.com/announcements/ory-v25-4-0-released) +- [ory/hydra #2416](https://github.com/ory/hydra/issues/2416) / [PR #3912](https://github.com/ory/hydra/pull/3912) +- [Ory community thread — device URL config not persisting](https://archive.ory.sh/t/30289954/) From a2b768e2daf7b5d81b4e0e421e76d59cfed3b9a2 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 25 Jun 2026 21:28:13 -0700 Subject: [PATCH 22/22] chore: rm docs --- docs/ory-network-device-flow-limitation.md | 92 -- .../2026-03-12-oidc-discovery-auth-design.md | 129 -- .../2026-03-12-oidc-discovery-auth-plan.md | 1434 ----------------- 3 files changed, 1655 deletions(-) delete mode 100644 docs/ory-network-device-flow-limitation.md delete mode 100644 docs/plans/2026-03-12-oidc-discovery-auth-design.md delete mode 100644 docs/plans/2026-03-12-oidc-discovery-auth-plan.md diff --git a/docs/ory-network-device-flow-limitation.md b/docs/ory-network-device-flow-limitation.md deleted file mode 100644 index 6141e4a..0000000 --- a/docs/ory-network-device-flow-limitation.md +++ /dev/null @@ -1,92 +0,0 @@ -# Ory Network: `urls.device.verification` / `urls.device.success` silently dropped — device flow unusable - -**Environment:** Ory Network (managed). OAuth2 device authorization grant (RFC 8628). - -## What works - -1. **The grant + code issuance.** Adding the device grant to an OAuth2 client - succeeds, and the device-authorization endpoint issues codes: - - ```bash - ory update oauth2-client \ - --grant-type authorization_code --grant-type refresh_token \ - --grant-type urn:ietf:params:oauth:grant-type:device_code \ - --token-endpoint-auth-method none … - - curl -X POST https://.projects.oryapis.com/oauth2/device/auth \ - -d client_id= -d 'scope=openid offline_access' - # → returns user_code, device_code, verification_uri, verification_uri_complete ✓ - ``` - -2. **The verification mechanism itself.** A self-hosted verification page would - work fully. Taking the `device_challenge` that Hydra puts in the fallback URL - and accepting the code via the admin API returns `200` with a `redirect_to` - that continues into the normal login/consent flow: - - ```bash - curl -X PUT "https://.projects.oryapis.com/admin/oauth2/auth/requests/device/accept?device_challenge=" \ - -H "Authorization: Bearer " -H "Content-Type: application/json" \ - -d '{"user_code":""}' - # → 200 { "redirect_to": ".../oauth2/device/verify?client_id=…&device_verifier=…&user_code=…" } - ``` - - So every step is functional — issuance, code entry, accept, and hand-off to - login/consent. **The only missing link is getting Hydra to send the browser - to a custom verification page in the first place.** - -## What fails - -Setting the device verification/success UI URLs is **silently discarded**: - -```bash -ory patch project \ - --add '/services/oauth2/config/urls/device={"verification":"https://app.example.com/device","success":"https://app.example.com/device/success"}' -# → "Project updated successfully!" (no error, device NOT in the ignored-keys warning) -``` - -Two ways to confirm it didn't persist: - -```bash -# 1. Read-back: device key is absent -ory get project --format json | jq '.services.oauth2.config.urls' -# → { consent, error, login, logout, post_logout_redirect, registration, self } ← no "device" - -# 2. Live flow still hits the built-in fallback -# following verification_uri_complete 302-redirects to: -# https://.projects.oryapis.com/oauth2/fallbacks/device?device_challenge=… -``` - -## Root cause - -Ory Network persists Hydra config as a flat `normalizedProjectRevision`, which -has **no field** for `urls.device.verification` / `urls.device.success` (only -login/consent/logout/error/registration/post_logout_redirect/self exist). -Self-hosted Hydra's config schema defines these keys; the managed Network schema -does not expose them, so the API accepts the patch and drops the key. Confirmed -across `ory patch project`, `ory patch oauth2-config`, and `ory update` — all -normalize into the same schema. - -## Impact - -With `urls.device.verification` unset, Hydra routes the user to -`/oauth2/fallbacks/device`, a **hardcoded static error page** ("configuration -key `urls.device.verification` is not set") — no code-entry form. Since the -verification mechanism itself works (see above), this is purely a routing gap: -there is no way to point the browser at a working verification UI. The user can -never approve the device, so the client's token poll never completes and device -login hangs until the code expires. - -## Ask - -Expose `urls.device.verification` / `urls.device.success` in the Ory Network -project config schema (the Hydra device grant shipped in v25.4.0, but these URL -keys aren't settable on Network), or document the supported way to point the -device flow at a custom verification UI. - -## References - -- [Ory — Device Authorization docs](https://www.ory.com/docs/oauth2-oidc/device-authorization) -- [NormalizedProjectRevision schema (ory/client-go)](https://github.com/ory/client-go/blob/master/docs/NormalizedProjectRevision.md) -- [Ory v25.4.0 changelog](https://changelog.ory.com/announcements/ory-v25-4-0-released) -- [ory/hydra #2416](https://github.com/ory/hydra/issues/2416) / [PR #3912](https://github.com/ory/hydra/pull/3912) -- [Ory community thread — device URL config not persisting](https://archive.ory.sh/t/30289954/) diff --git a/docs/plans/2026-03-12-oidc-discovery-auth-design.md b/docs/plans/2026-03-12-oidc-discovery-auth-design.md deleted file mode 100644 index 142043d..0000000 --- a/docs/plans/2026-03-12-oidc-discovery-auth-design.md +++ /dev/null @@ -1,129 +0,0 @@ -# OIDC Discovery-Driven Auth Rework - -## Summary - -Replace the current single-flow (Authorization Code + PKCE) auth with a discovery-driven system that reads the provider's OIDC discovery document and selects the best available flow. Adds Device Code and Refresh Token flows. - -## Decisions - -- **Scope**: Source Coop focused. STS exchange always required. Source Coop defaults retained. -- **Flows**: Authorization Code + PKCE, Device Code (RFC 8628), Refresh Token. -- **Flow priority**: device code (if provider supports it) > auth code. Override with `--flow`. -- **Refresh token storage**: Separate keyring entry (with file fallback), keyed by issuer. -- **Auto-refresh**: `source-coop creds` silently refreshes expired AWS credentials using cached refresh token. -- **Approach**: Modular flow architecture. Each flow is a separate module returning an ID token. - -## Architecture - -``` -source-coop login - | - v - Discovery Fetch & parse .well-known/openid-configuration - | - v - Flow Select --flow flag > device_code > auth_code - | - v - Flow Execution auth_code | device_code | refresh - | Each returns id_token + optional refresh_token - v - STS Exchange Unchanged: id_token -> AssumeRoleWithWebIdentity - | - v - Cache + Output Store AWS creds + refresh token separately -``` - -## Components - -### Discovery - -Parse the full OIDC discovery document: - -```rust -pub struct OidcDiscovery { - pub issuer: String, - pub authorization_endpoint: String, - pub token_endpoint: String, - pub device_authorization_endpoint: Option, - pub revocation_endpoint: Option, - pub userinfo_endpoint: Option, - pub grant_types_supported: Vec, - pub scopes_supported: Vec, - pub code_challenge_methods_supported: Vec, -} -``` - -`device_authorization_endpoint` is optional since not all providers expose it. `grant_types_supported` drives flow selection. - -### Flow Selection - -Priority: - -1. `--flow device-code` -> device code (error if unsupported) -2. `--flow auth-code` -> auth code (error if unsupported) -3. `--flow auto` (default): device code if `device_code` in `grant_types_supported` AND `device_authorization_endpoint` present, else auth code + PKCE - -### Device Code Flow (RFC 8628) - -1. POST `device_authorization_endpoint` with `client_id`, `scope` -2. Receive `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `interval`, `expires_in` -3. Display: "Visit {verification_uri} and enter code: {user_code}". Open `verification_uri_complete` in browser. -4. Poll `token_endpoint` every `interval` seconds with `grant_type=urn:ietf:params:oauth:grant-type:device_code` -5. Handle: `authorization_pending` (continue), `slow_down` (increase interval), `expired_token` (error), success (extract `id_token`) - -### Auth Code Flow (existing, reorganized) - -Existing PKCE flow moves into its own module. No functional changes. - -### Refresh Token - -**Storage**: Separate keyring entry keyed as `source-coop-cli:refresh:{issuer_hash}`. File fallback at `~/.cache/source-coop/refresh/{issuer_hash}.json` (0600 permissions). - -**During login**: Cache `refresh_token` from token response if present. - -**During `creds`**: If AWS creds expired and refresh token exists: -1. POST `token_endpoint` with `grant_type=refresh_token`, `refresh_token`, `client_id` -2. Get new `id_token` (and possibly rotated `refresh_token`) -3. STS exchange -> new AWS creds -> cache - -**Scope**: Default scope becomes `openid offline_access` to request refresh tokens. - -### CLI Changes - -**`login` additions:** -- `--flow ` (default: `auto`) -- Default scope: `openid offline_access` - -**`creds` additions:** -- Auto-refresh on expired creds (silent, using cached refresh token) -- `--no-refresh` flag to skip auto-refresh - -**New `logout` command:** -- Revoke refresh token via revocation endpoint -- Clear cached AWS credentials -- Clear cached refresh token - -### File Structure - -``` -src/ - main.rs CLI args, command dispatch - oidc/ - mod.rs Discovery, flow selection, OidcDiscovery struct - auth_code.rs Authorization Code + PKCE flow (moved from oidc.rs) - device_code.rs Device Code flow (new) - refresh.rs Refresh token handling (new) - sts.rs Unchanged - cache.rs Extended with refresh token storage - output.rs Unchanged -``` - -## Provider Reference - -Source Coop's OIDC discovery (`https://auth.source.coop/.well-known/openid-configuration`) advertises: -- `grant_types_supported`: authorization_code, implicit, client_credentials, refresh_token, device_code -- `device_authorization_endpoint`: present -- `revocation_endpoint`: present -- `scopes_supported`: openid, offline_access, offline -- `code_challenge_methods_supported`: plain, S256 diff --git a/docs/plans/2026-03-12-oidc-discovery-auth-plan.md b/docs/plans/2026-03-12-oidc-discovery-auth-plan.md deleted file mode 100644 index ca036cc..0000000 --- a/docs/plans/2026-03-12-oidc-discovery-auth-plan.md +++ /dev/null @@ -1,1434 +0,0 @@ -# OIDC Discovery-Driven Auth Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Replace single-flow auth with a discovery-driven system supporting Authorization Code + PKCE, Device Code, and Refresh Token flows. - -**Architecture:** Parse the full OIDC discovery document, select the best flow (device code preferred, auth code fallback, user override via `--flow`), execute that flow to get an id_token + optional refresh_token, then STS exchange for AWS credentials. Refresh tokens cached separately in keyring enable silent credential renewal. - -**Tech Stack:** Rust, reqwest, serde, clap, keyring, tokio, sha2, base64, rand - ---- - -### Task 1: Restructure oidc.rs into oidc/ module with expanded discovery - -**Files:** -- Delete: `src/oidc.rs` -- Create: `src/oidc/mod.rs` -- Create: `src/oidc/auth_code.rs` - -**Step 1: Create the oidc directory and mod.rs with expanded OidcDiscovery struct** - -Create `src/oidc/mod.rs`: - -```rust -pub mod auth_code; - -use serde::Deserialize; - -/// Parsed OIDC discovery document. -#[derive(Debug, Clone, Deserialize)] -pub struct OidcDiscovery { - pub issuer: String, - pub authorization_endpoint: String, - pub token_endpoint: String, - pub device_authorization_endpoint: Option, - pub revocation_endpoint: Option, - pub grant_types_supported: Option>, - pub scopes_supported: Option>, - pub code_challenge_methods_supported: Option>, -} - -impl OidcDiscovery { - pub fn supports_grant_type(&self, grant_type: &str) -> bool { - self.grant_types_supported - .as_ref() - .map(|types| types.iter().any(|t| t == grant_type)) - .unwrap_or(false) - } - - pub fn supports_device_code(&self) -> bool { - self.supports_grant_type("urn:ietf:params:oauth:grant-type:device_code") - || self.supports_grant_type("device_code") - } -} - -/// Token response from the OIDC provider. -#[derive(Debug, Deserialize)] -pub struct TokenResponse { - pub id_token: Option, - pub refresh_token: Option, - pub access_token: Option, - pub token_type: Option, - pub expires_in: Option, -} - -/// Fetch and parse the OIDC discovery document. -pub async fn discover(issuer: &str, verbose: bool) -> Result { - let discovery_url = format!( - "{}/.well-known/openid-configuration", - issuer.trim_end_matches('/') - ); - - if verbose { - eprintln!("[verbose] GET {discovery_url}"); - } - - let resp = reqwest::get(&discovery_url) - .await - .map_err(|e| format!("Failed to fetch OIDC discovery document: {e}"))?; - - if verbose { - eprintln!("[verbose] Response: {}", resp.status()); - } - - if !resp.status().is_success() { - return Err(format!("OIDC discovery returned status {}", resp.status())); - } - - let discovery: OidcDiscovery = resp - .json() - .await - .map_err(|e| format!("Failed to parse OIDC discovery document: {e}"))?; - - if verbose { - eprintln!("[verbose] Authorization endpoint: {}", discovery.authorization_endpoint); - eprintln!("[verbose] Token endpoint: {}", discovery.token_endpoint); - if let Some(ref dae) = discovery.device_authorization_endpoint { - eprintln!("[verbose] Device authorization endpoint: {dae}"); - } - if let Some(ref grants) = discovery.grant_types_supported { - eprintln!("[verbose] Supported grant types: {}", grants.join(", ")); - } - } - - Ok(discovery) -} -``` - -**Step 2: Move existing auth code flow into auth_code.rs** - -Create `src/oidc/auth_code.rs` with the existing flow logic, adapted to use `OidcDiscovery` and return `TokenResponse`: - -```rust -use super::{OidcDiscovery, TokenResponse}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use rand::Rng; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; -use tokio::net::TcpListener; -use url::Url; - -struct Pkce { - verifier: String, - challenge: String, -} - -fn generate_pkce() -> Pkce { - let mut rng = rand::thread_rng(); - let bytes: Vec = (0..32).map(|_| rng.gen()).collect(); - let verifier = URL_SAFE_NO_PAD.encode(&bytes); - - let mut hasher = Sha256::new(); - hasher.update(verifier.as_bytes()); - let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); - - Pkce { - verifier, - challenge, - } -} - -/// Run the Authorization Code + PKCE flow. Returns a TokenResponse. -pub async fn login( - discovery: &OidcDiscovery, - client_id: &str, - scope: &str, - port: u16, - verbose: bool, -) -> Result { - let pkce = generate_pkce(); - let state: String = URL_SAFE_NO_PAD.encode(rand::thread_rng().gen::<[u8; 16]>()); - - // Bind local callback server - let listener = TcpListener::bind(format!("127.0.0.1:{port}")) - .await - .map_err(|e| format!("Failed to bind local server: {e}"))?; - - let local_addr = listener - .local_addr() - .map_err(|e| format!("Failed to get local address: {e}"))?; - let redirect_uri = format!("http://127.0.0.1:{}/callback", local_addr.port()); - - if verbose { - eprintln!("[verbose] Callback server listening on {local_addr}"); - eprintln!("[verbose] Redirect URI: {redirect_uri}"); - } - - // Build authorization URL - let mut auth_url = Url::parse(&discovery.authorization_endpoint) - .map_err(|e| format!("Invalid authorization endpoint URL: {e}"))?; - auth_url - .query_pairs_mut() - .append_pair("response_type", "code") - .append_pair("client_id", client_id) - .append_pair("redirect_uri", &redirect_uri) - .append_pair("scope", scope) - .append_pair("code_challenge", &pkce.challenge) - .append_pair("code_challenge_method", "S256") - .append_pair("state", &state); - - if verbose { - eprintln!("[verbose] Authorization URL: {auth_url}"); - } - - eprintln!("Opening browser for authentication..."); - if open::that(auth_url.as_str()).is_err() { - eprintln!( - "Could not open browser automatically. Please open this URL:\n{}", - auth_url - ); - } - - // Wait for callback - let (code, received_state) = wait_for_callback(&listener).await?; - - if verbose { - eprintln!("[verbose] Received authorization code callback"); - } - - if received_state != state { - return Err("State mismatch — possible CSRF attack".to_string()); - } - - // Exchange code for tokens - exchange_code( - &discovery.token_endpoint, - &code, - &redirect_uri, - client_id, - &pkce.verifier, - verbose, - ) - .await -} - -async fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> { - let (stream, _) = listener - .accept() - .await - .map_err(|e| format!("Failed to accept callback connection: {e}"))?; - - let std_stream = stream - .into_std() - .map_err(|e| format!("Failed to convert stream: {e}"))?; - std_stream - .set_nonblocking(false) - .map_err(|e| format!("Failed to set blocking: {e}"))?; - - let mut reader = BufReader::new(&std_stream); - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .map_err(|e| format!("Failed to read request: {e}"))?; - - let path = request_line - .split_whitespace() - .nth(1) - .ok_or("Invalid HTTP request")?; - - let url = Url::parse(&format!("http://localhost{path}")) - .map_err(|e| format!("Failed to parse callback URL: {e}"))?; - - let params: HashMap = url.query_pairs().into_owned().collect(); - - if let Some(error) = params.get("error") { - let desc = params - .get("error_description") - .map(|d| format!(": {d}")) - .unwrap_or_default(); - let html = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ -

Authentication Failed

{error}{desc}

\ -

You can close this tab.

" - ); - let _ = (&std_stream).write_all(html.as_bytes()); - return Err(format!("Authentication error: {error}{desc}")); - } - - let code = params - .get("code") - .ok_or("No authorization code in callback")? - .clone(); - let received_state = params.get("state").ok_or("No state in callback")?.clone(); - - let html = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ -

Authentication Successful

\ -

You can close this tab and return to your terminal.

"; - (&std_stream) - .write_all(html.as_bytes()) - .map_err(|e| format!("Failed to send response: {e}"))?; - - Ok((code, received_state)) -} - -async fn exchange_code( - token_endpoint: &str, - code: &str, - redirect_uri: &str, - client_id: &str, - code_verifier: &str, - verbose: bool, -) -> Result { - if verbose { - eprintln!("[verbose] POST {token_endpoint}"); - eprintln!("[verbose] grant_type=authorization_code"); - eprintln!("[verbose] redirect_uri={redirect_uri}"); - eprintln!("[verbose] client_id={client_id}"); - } - - let client = reqwest::Client::new(); - let resp = client - .post(token_endpoint) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", redirect_uri), - ("client_id", client_id), - ("code_verifier", code_verifier), - ]) - .send() - .await - .map_err(|e| format!("Token exchange request failed: {e}"))?; - - if verbose { - eprintln!("[verbose] Response: {}", resp.status()); - } - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Token exchange failed (HTTP {status}): {body}")); - } - - let token_response: TokenResponse = resp - .json() - .await - .map_err(|e| format!("Failed to parse token response: {e}"))?; - - if token_response.id_token.is_none() { - return Err("No id_token in token response".to_string()); - } - - if verbose { - eprintln!("[verbose] Received id_token"); - if token_response.refresh_token.is_some() { - eprintln!("[verbose] Received refresh_token"); - } - } - - Ok(token_response) -} -``` - -**Step 3: Delete old oidc.rs** - -```bash -rm src/oidc.rs -``` - -**Step 4: Update main.rs module declaration** - -In `src/main.rs`, the `mod oidc;` declaration already works for both a file and a directory module. Update the `run_login` function to use the new types: - -Replace lines 123-161 of `src/main.rs` with: - -```rust -async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { - // 1. OIDC Discovery - eprintln!("Discovering OIDC endpoints..."); - let discovery = oidc::discover(&args.issuer, verbose).await?; - - // 2. Browser-based OIDC login - let token_response = - oidc::auth_code::login(&discovery, &args.client_id, &args.scope, args.port, verbose).await?; - let id_token = token_response - .id_token - .ok_or("No id_token in token response")?; - eprintln!("Authentication successful."); - - // 3. STS credential exchange - if verbose { - eprintln!("[verbose] Assuming role: {}", args.role_arn); - } - eprintln!("Exchanging token for credentials..."); - let creds = sts::assume_role( - &args.proxy_url, - &args.role_arn, - &id_token, - args.duration, - verbose, - ) - .await?; - - // 4. Cache credentials - if args.no_cache { - eprintln!("Skipping credential cache (--no-cache)"); - } else { - let location = cache::write_credentials(&args.role_arn, &creds)?; - eprintln!("Credentials cached to {location}"); - } - - // 5. Output - match args.format { - OutputFormat::CredentialProcess => output::print_credential_process(&creds), - OutputFormat::Env => output::print_env(&creds), - } - - Ok(()) -} -``` - -**Step 5: Verify it compiles and existing behavior is preserved** - -Run: `cargo build` -Expected: Compiles successfully. No functional changes yet — still uses auth code flow only. - -**Step 6: Commit** - -```bash -git add -A && git commit -m "refactor: restructure oidc.rs into oidc/ module with expanded discovery" -``` - ---- - -### Task 2: Add device code flow - -**Files:** -- Create: `src/oidc/device_code.rs` -- Modify: `src/oidc/mod.rs` (add `pub mod device_code;`) - -**Step 1: Add device_code module declaration** - -In `src/oidc/mod.rs`, add after `pub mod auth_code;`: - -```rust -pub mod device_code; -``` - -**Step 2: Create device_code.rs** - -Create `src/oidc/device_code.rs`: - -```rust -use super::{OidcDiscovery, TokenResponse}; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct DeviceAuthResponse { - device_code: String, - user_code: String, - verification_uri: String, - verification_uri_complete: Option, - #[serde(default = "default_interval")] - interval: u64, - expires_in: u64, -} - -fn default_interval() -> u64 { - 5 -} - -#[derive(Debug, Deserialize)] -struct DeviceTokenErrorResponse { - error: String, - error_description: Option, -} - -/// Run the Device Code flow (RFC 8628). Returns a TokenResponse. -pub async fn login( - discovery: &OidcDiscovery, - client_id: &str, - scope: &str, - verbose: bool, -) -> Result { - let device_endpoint = discovery - .device_authorization_endpoint - .as_ref() - .ok_or("Provider does not support device authorization")?; - - if verbose { - eprintln!("[verbose] POST {device_endpoint}"); - eprintln!("[verbose] client_id={client_id}"); - eprintln!("[verbose] scope={scope}"); - } - - // Step 1: Request device code - let client = reqwest::Client::new(); - let resp = client - .post(device_endpoint) - .form(&[("client_id", client_id), ("scope", scope)]) - .send() - .await - .map_err(|e| format!("Device authorization request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!( - "Device authorization failed (HTTP {status}): {body}" - )); - } - - let device_resp: DeviceAuthResponse = resp - .json() - .await - .map_err(|e| format!("Failed to parse device authorization response: {e}"))?; - - if verbose { - eprintln!("[verbose] Device code received"); - eprintln!("[verbose] User code: {}", device_resp.user_code); - eprintln!("[verbose] Verification URI: {}", device_resp.verification_uri); - eprintln!("[verbose] Poll interval: {}s", device_resp.interval); - eprintln!("[verbose] Expires in: {}s", device_resp.expires_in); - } - - // Step 2: Display instructions to user - eprintln!(); - eprintln!("To authenticate, visit:"); - eprintln!(" {}", device_resp.verification_uri); - eprintln!(); - eprintln!("And enter code: {}", device_resp.user_code); - eprintln!(); - - // Try to open browser with the complete URI - if let Some(ref complete_uri) = device_resp.verification_uri_complete { - eprintln!("Opening browser..."); - if open::that(complete_uri).is_err() { - eprintln!("Could not open browser automatically."); - } - } - - // Step 3: Poll for token - let mut interval = device_resp.interval; - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_secs(device_resp.expires_in); - - loop { - tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; - - if tokio::time::Instant::now() > deadline { - return Err("Device code expired. Please try again.".to_string()); - } - - if verbose { - eprintln!("[verbose] Polling token endpoint..."); - } - - let resp = client - .post(&discovery.token_endpoint) - .form(&[ - ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ("device_code", &device_resp.device_code), - ("client_id", client_id), - ]) - .send() - .await - .map_err(|e| format!("Token poll request failed: {e}"))?; - - if resp.status().is_success() { - let token_response: TokenResponse = resp - .json() - .await - .map_err(|e| format!("Failed to parse token response: {e}"))?; - - if token_response.id_token.is_none() { - return Err("No id_token in token response".to_string()); - } - - if verbose { - eprintln!("[verbose] Received id_token"); - if token_response.refresh_token.is_some() { - eprintln!("[verbose] Received refresh_token"); - } - } - - return Ok(token_response); - } - - // Parse error response - let body = resp - .text() - .await - .unwrap_or_default(); - - let error_resp: DeviceTokenErrorResponse = serde_json::from_str(&body) - .unwrap_or(DeviceTokenErrorResponse { - error: "unknown".to_string(), - error_description: Some(body.clone()), - }); - - match error_resp.error.as_str() { - "authorization_pending" => { - if verbose { - eprintln!("[verbose] Authorization pending, waiting..."); - } - continue; - } - "slow_down" => { - interval += 5; - if verbose { - eprintln!("[verbose] Slowing down, new interval: {interval}s"); - } - continue; - } - "expired_token" => { - return Err("Device code expired. Please try again.".to_string()); - } - "access_denied" => { - return Err("Access denied by user.".to_string()); - } - other => { - let desc = error_resp - .error_description - .map(|d| format!(": {d}")) - .unwrap_or_default(); - return Err(format!("Device code error ({other}){desc}")); - } - } - } -} -``` - -**Step 3: Verify it compiles** - -Run: `cargo build` -Expected: Compiles. device_code module exists but isn't called yet. - -**Step 4: Commit** - -```bash -git add -A && git commit -m "feat: add device code flow (RFC 8628)" -``` - ---- - -### Task 3: Add flow selection and --flow flag to CLI - -**Files:** -- Modify: `src/main.rs` (add `--flow` flag, flow selection logic) -- Modify: `src/oidc/mod.rs` (add FlowType enum) - -**Step 1: Add FlowType to oidc/mod.rs** - -Add at the top of `src/oidc/mod.rs` (after imports): - -```rust -use clap::ValueEnum; - -#[derive(Debug, Clone, ValueEnum)] -pub enum FlowType { - /// Automatically select the best flow - Auto, - /// Device code flow (works everywhere including headless) - DeviceCode, - /// Authorization code + PKCE flow (requires browser on same machine) - AuthCode, -} -``` - -**Step 2: Update LoginArgs in main.rs** - -Add the `--flow` flag to `LoginArgs`: - -```rust - /// Authentication flow to use - #[arg(long, default_value = "auto")] - flow: oidc::FlowType, -``` - -Change the default scope: - -```rust - /// OAuth2 scopes - #[arg(long, default_value = "openid offline_access")] - scope: String, -``` - -**Step 3: Update run_login with flow selection** - -Replace the OIDC login section (step 2) in `run_login`: - -```rust -async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { - // 1. OIDC Discovery - eprintln!("Discovering OIDC endpoints..."); - let discovery = oidc::discover(&args.issuer, verbose).await?; - - // 2. Select and execute auth flow - let flow = match args.flow { - oidc::FlowType::Auto => { - if discovery.supports_device_code() - && discovery.device_authorization_endpoint.is_some() - { - if verbose { - eprintln!("[verbose] Auto-selected device code flow"); - } - oidc::FlowType::DeviceCode - } else { - if verbose { - eprintln!("[verbose] Auto-selected authorization code flow"); - } - oidc::FlowType::AuthCode - } - } - explicit => explicit, - }; - - let token_response = match flow { - oidc::FlowType::DeviceCode => { - if !discovery.supports_device_code() - || discovery.device_authorization_endpoint.is_none() - { - return Err( - "Provider does not support device code flow. Use --flow auth-code.".to_string(), - ); - } - oidc::device_code::login(&discovery, &args.client_id, &args.scope, verbose).await? - } - oidc::FlowType::AuthCode => { - oidc::auth_code::login( - &discovery, - &args.client_id, - &args.scope, - args.port, - verbose, - ) - .await? - } - oidc::FlowType::Auto => unreachable!(), - }; - - let id_token = token_response - .id_token - .ok_or("No id_token in token response")?; - eprintln!("Authentication successful."); - - // 3. STS credential exchange - if verbose { - eprintln!("[verbose] Assuming role: {}", args.role_arn); - } - eprintln!("Exchanging token for credentials..."); - let creds = sts::assume_role( - &args.proxy_url, - &args.role_arn, - &id_token, - args.duration, - verbose, - ) - .await?; - - // 4. Cache credentials - if args.no_cache { - eprintln!("Skipping credential cache (--no-cache)"); - } else { - let location = cache::write_credentials(&args.role_arn, &creds)?; - eprintln!("Credentials cached to {location}"); - } - - // 5. Output - match args.format { - OutputFormat::CredentialProcess => output::print_credential_process(&creds), - OutputFormat::Env => output::print_env(&creds), - } - - Ok(()) -} -``` - -**Step 4: Verify it compiles** - -Run: `cargo build` -Expected: Compiles. `source-coop login --help` shows the new `--flow` flag. - -**Step 5: Verify help output** - -Run: `cargo run -- login --help` -Expected: Shows `--flow ` with auto, device-code, auth-code options. - -**Step 6: Commit** - -```bash -git add -A && git commit -m "feat: add --flow flag with auto-selection (device code preferred)" -``` - ---- - -### Task 4: Add refresh token storage to cache.rs - -**Files:** -- Modify: `src/cache.rs` - -**Step 1: Add refresh token cache functions** - -Add these functions to `src/cache.rs`, after the existing `is_expired` function (before `#[cfg(test)]`): - -```rust -/// Compute a short, filesystem-safe key for an issuer URL. -fn issuer_key(issuer: &str) -> String { - sanitize_role_arn(issuer) -} - -/// Full path to the refresh token cache file for a given issuer. -fn refresh_cache_path(issuer: &str) -> Result { - let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; - let key = issuer_key(issuer); - Ok(cache_dir - .join("source-coop") - .join("refresh") - .join(format!("{key}.json"))) -} - -/// Refresh token data stored in cache. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RefreshTokenData { - pub refresh_token: String, - pub issuer: String, - pub client_id: String, -} - -fn write_refresh_token_file(data: &RefreshTokenData) -> Result { - let path = refresh_cache_path(&data.issuer)?; - let dir = path.parent().unwrap(); - - fs::create_dir_all(dir) - .map_err(|e| format!("Failed to create refresh cache directory {}: {e}", dir.display()))?; - - let json = serde_json::to_string_pretty(data) - .map_err(|e| format!("Failed to serialize refresh token: {e}"))?; - - fs::write(&path, &json) - .map_err(|e| format!("Failed to write refresh token cache {}: {e}", path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) - .map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?; - } - - Ok(path.display().to_string()) -} - -fn read_refresh_token_file(issuer: &str) -> Result, String> { - let path = refresh_cache_path(issuer)?; - match fs::read_to_string(&path) { - Ok(contents) => { - let data: RefreshTokenData = serde_json::from_str(&contents) - .map_err(|e| format!("Failed to parse refresh token cache: {e}"))?; - Ok(Some(data)) - } - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(format!( - "Failed to read refresh token cache {}: {e}", - path.display() - )), - } -} - -const REFRESH_KEYRING_PREFIX: &str = "source-coop-cli:refresh"; - -/// Write refresh token, trying OS keyring first with file fallback. -pub fn write_refresh_token(data: &RefreshTokenData) -> Result { - let json = serde_json::to_string(data) - .map_err(|e| format!("Failed to serialize refresh token: {e}"))?; - - let key = issuer_key(&data.issuer); - let entry = keyring::Entry::new(REFRESH_KEYRING_PREFIX, &key) - .map_err(|e| format!("Failed to create keyring entry: {e}")); - - if let Ok(entry) = entry { - match entry.set_password(&json) { - Ok(()) => { - return Ok(format!("OS keyring (service: {REFRESH_KEYRING_PREFIX})")); - } - Err(ref e) if is_keyring_unavailable(e) => {} - Err(e) => { - return Err(format!("Failed to write refresh token to keyring: {e}")); - } - } - } - - write_refresh_token_file(data) -} - -/// Read refresh token, trying OS keyring first with file fallback. -pub fn read_refresh_token(issuer: &str) -> Result, String> { - let key = issuer_key(issuer); - let entry = keyring::Entry::new(REFRESH_KEYRING_PREFIX, &key) - .map_err(|e| format!("Failed to create keyring entry: {e}")); - - if let Ok(entry) = entry { - match entry.get_password() { - Ok(json) => { - let data: RefreshTokenData = serde_json::from_str(&json) - .map_err(|e| format!("Failed to parse refresh token from keyring: {e}"))?; - return Ok(Some(data)); - } - Err(keyring::Error::NoEntry) => {} - Err(ref e) if is_keyring_unavailable(e) => {} - Err(e) => { - return Err(format!("Failed to read refresh token from keyring: {e}")); - } - } - } - - read_refresh_token_file(issuer) -} - -/// Delete refresh token from both keyring and file cache. -pub fn delete_refresh_token(issuer: &str) -> Result<(), String> { - let key = issuer_key(issuer); - - // Try keyring - if let Ok(entry) = keyring::Entry::new(REFRESH_KEYRING_PREFIX, &key) { - let _ = entry.delete_credential(); - } - - // Try file - if let Ok(path) = refresh_cache_path(issuer) { - let _ = fs::remove_file(path); - } - - Ok(()) -} - -/// Delete AWS credentials from both keyring and file cache. -pub fn delete_credentials(role_arn: &str) -> Result<(), String> { - // Try keyring - if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, role_arn) { - let _ = entry.delete_credential(); - } - - // Try file - if let Ok(path) = cache_path(role_arn) { - let _ = fs::remove_file(path); - } - - Ok(()) -} -``` - -Also add `use serde::Serialize;` to the imports at the top (Credentials already derives Serialize via sts.rs, but RefreshTokenData needs it for the module). - -Add to the top of cache.rs: - -```rust -use serde::{Deserialize, Serialize}; -``` - -Wait — `cache.rs` doesn't import serde directly. It uses `serde_json` for serialization but the `Credentials` struct derives `Serialize`/`Deserialize` in `sts.rs`. For `RefreshTokenData`, we need the derives in cache.rs itself. Add `use serde::{Serialize, Deserialize};` at the top of `cache.rs`. - -**Step 2: Verify it compiles** - -Run: `cargo build` -Expected: Compiles. New functions exist but aren't called yet. - -**Step 3: Commit** - -```bash -git add -A && git commit -m "feat: add refresh token cache with keyring and file fallback" -``` - ---- - -### Task 5: Add refresh token flow module - -**Files:** -- Create: `src/oidc/refresh.rs` -- Modify: `src/oidc/mod.rs` (add `pub mod refresh;`) - -**Step 1: Add refresh module declaration** - -In `src/oidc/mod.rs`, add: - -```rust -pub mod refresh; -``` - -**Step 2: Create refresh.rs** - -Create `src/oidc/refresh.rs`: - -```rust -use super::{OidcDiscovery, TokenResponse}; - -/// Exchange a refresh token for new tokens. -pub async fn refresh( - discovery: &OidcDiscovery, - client_id: &str, - refresh_token: &str, - verbose: bool, -) -> Result { - if verbose { - eprintln!("[verbose] POST {}", discovery.token_endpoint); - eprintln!("[verbose] grant_type=refresh_token"); - eprintln!("[verbose] client_id={client_id}"); - } - - let client = reqwest::Client::new(); - let resp = client - .post(&discovery.token_endpoint) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), - ("client_id", client_id), - ]) - .send() - .await - .map_err(|e| format!("Refresh token request failed: {e}"))?; - - if verbose { - eprintln!("[verbose] Response: {}", resp.status()); - } - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Token refresh failed (HTTP {status}): {body}")); - } - - let token_response: TokenResponse = resp - .json() - .await - .map_err(|e| format!("Failed to parse token response: {e}"))?; - - if token_response.id_token.is_none() { - return Err("No id_token in refresh response".to_string()); - } - - if verbose { - eprintln!("[verbose] Received refreshed id_token"); - if token_response.refresh_token.is_some() { - eprintln!("[verbose] Received rotated refresh_token"); - } - } - - Ok(token_response) -} - -/// Revoke a refresh token at the provider's revocation endpoint. -pub async fn revoke( - discovery: &OidcDiscovery, - client_id: &str, - refresh_token: &str, - verbose: bool, -) -> Result<(), String> { - let revocation_endpoint = match &discovery.revocation_endpoint { - Some(ep) => ep, - None => { - if verbose { - eprintln!("[verbose] No revocation endpoint; skipping token revocation"); - } - return Ok(()); - } - }; - - if verbose { - eprintln!("[verbose] POST {revocation_endpoint}"); - eprintln!("[verbose] token_type_hint=refresh_token"); - eprintln!("[verbose] client_id={client_id}"); - } - - let client = reqwest::Client::new(); - let resp = client - .post(revocation_endpoint) - .form(&[ - ("token", refresh_token), - ("token_type_hint", "refresh_token"), - ("client_id", client_id), - ]) - .send() - .await - .map_err(|e| format!("Token revocation request failed: {e}"))?; - - if verbose { - eprintln!("[verbose] Revocation response: {}", resp.status()); - } - - // RFC 7009: revocation endpoint returns 200 even if token was already invalid - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - eprintln!("Warning: token revocation returned HTTP {status}: {body}"); - } - - Ok(()) -} -``` - -**Step 3: Verify it compiles** - -Run: `cargo build` -Expected: Compiles. - -**Step 4: Commit** - -```bash -git add -A && git commit -m "feat: add refresh token exchange and revocation" -``` - ---- - -### Task 6: Wire refresh token caching into login flow - -**Files:** -- Modify: `src/main.rs` (cache refresh token after login) - -**Step 1: Update run_login to cache refresh token** - -After the "Authentication successful" line in `run_login`, add refresh token caching: - -```rust - let id_token = token_response - .id_token - .ok_or("No id_token in token response")?; - eprintln!("Authentication successful."); - - // Cache refresh token if present - if let Some(ref refresh_token) = token_response.refresh_token { - let data = cache::RefreshTokenData { - refresh_token: refresh_token.clone(), - issuer: args.issuer.clone(), - client_id: args.client_id.clone(), - }; - match cache::write_refresh_token(&data) { - Ok(location) => { - if verbose { - eprintln!("[verbose] Refresh token cached to {location}"); - } - } - Err(e) => { - eprintln!("Warning: could not cache refresh token: {e}"); - } - } - } -``` - -**Step 2: Verify it compiles** - -Run: `cargo build` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add -A && git commit -m "feat: cache refresh token during login" -``` - ---- - -### Task 7: Add auto-refresh to creds command - -**Files:** -- Modify: `src/main.rs` (update `CredsArgs` and `run_creds`) - -**Step 1: Update CredsArgs with refresh-related fields** - -```rust -#[derive(Parser)] -struct CredsArgs { - /// Role ARN to read cached credentials for - #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] - role_arn: String, - - /// Output format - #[arg(long, default_value = "credential-process")] - format: OutputFormat, - - /// Skip automatic refresh of expired credentials - #[arg(long)] - no_refresh: bool, - - /// OIDC issuer URL (needed for auto-refresh) - #[arg(long, env = "SOURCE_OIDC_ISSUER", default_value = defaults::ISSUER)] - issuer: String, - - /// S3 proxy URL for STS (needed for auto-refresh) - #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] - proxy_url: String, -} -``` - -**Step 2: Make run_creds async and add auto-refresh logic** - -Change `run_creds` to async: - -```rust -async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { - let creds = cache::read_credentials(&args.role_arn)? - .ok_or("No cached credentials found. Run 'source-coop login' first.")?; - - if !cache::is_expired(&creds)? { - // Credentials still valid, output them - match args.format { - OutputFormat::CredentialProcess => output::print_credential_process(&creds), - OutputFormat::Env => output::print_env(&creds), - } - return Ok(()); - } - - // Credentials expired — try auto-refresh - if args.no_refresh { - return Err( - "Cached credentials have expired. Run 'source-coop login' to refresh.".to_string(), - ); - } - - let refresh_data = cache::read_refresh_token(&args.issuer)?; - let refresh_data = match refresh_data { - Some(data) => data, - None => { - return Err( - "Cached credentials have expired and no refresh token found. Run 'source-coop login'.".to_string(), - ); - } - }; - - if verbose { - eprintln!("[verbose] Credentials expired, attempting auto-refresh..."); - } - eprintln!("Credentials expired. Refreshing..."); - - // Discover endpoints for refresh - let discovery = oidc::discover(&refresh_data.issuer, verbose).await?; - - // Refresh tokens - let token_response = oidc::refresh::refresh( - &discovery, - &refresh_data.client_id, - &refresh_data.refresh_token, - verbose, - ) - .await?; - - let id_token = token_response - .id_token - .ok_or("No id_token in refresh response")?; - - // Update cached refresh token if rotated - if let Some(ref new_refresh_token) = token_response.refresh_token { - let new_data = cache::RefreshTokenData { - refresh_token: new_refresh_token.clone(), - issuer: refresh_data.issuer.clone(), - client_id: refresh_data.client_id.clone(), - }; - let _ = cache::write_refresh_token(&new_data); - } - - // STS exchange - let creds = sts::assume_role( - &args.proxy_url, - &args.role_arn, - &id_token, - None, - verbose, - ) - .await?; - - // Cache new credentials - let location = cache::write_credentials(&args.role_arn, &creds)?; - if verbose { - eprintln!("[verbose] Refreshed credentials cached to {location}"); - } - eprintln!("Credentials refreshed."); - - match args.format { - OutputFormat::CredentialProcess => output::print_credential_process(&creds), - OutputFormat::Env => output::print_env(&creds), - } - Ok(()) -} -``` - -**Step 3: Update the main() match arm for Creds** - -Update the `Commands::Creds` arm in `main()` to pass verbose and use `.await`: - -```rust - Commands::Creds(args) => { - if let Err(e) = run_creds(args, verbose).await { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } -``` - -**Step 4: Verify it compiles** - -Run: `cargo build` -Expected: Compiles. - -**Step 5: Verify help output** - -Run: `cargo run -- creds --help` -Expected: Shows `--no-refresh`, `--issuer`, `--proxy-url` flags. - -**Step 6: Commit** - -```bash -git add -A && git commit -m "feat: add auto-refresh of expired credentials in creds command" -``` - ---- - -### Task 8: Add logout command - -**Files:** -- Modify: `src/main.rs` (add Logout command) - -**Step 1: Add LogoutArgs and Logout command variant** - -Add to the `Commands` enum: - -```rust - /// Clear cached credentials and revoke refresh token - Logout(LogoutArgs), -``` - -Add the struct: - -```rust -#[derive(Parser)] -struct LogoutArgs { - /// OIDC issuer URL - #[arg(long, env = "SOURCE_OIDC_ISSUER", default_value = defaults::ISSUER)] - issuer: String, - - /// OAuth2 client ID - #[arg(long, env = "SOURCE_OIDC_CLIENT_ID", default_value = defaults::CLIENT_ID)] - client_id: String, - - /// Role ARN to clear cached credentials for - #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] - role_arn: String, -} -``` - -**Step 2: Add run_logout function** - -```rust -async fn run_logout(args: LogoutArgs, verbose: bool) -> Result<(), String> { - // Revoke refresh token if we have one - if let Some(refresh_data) = cache::read_refresh_token(&args.issuer)? { - if verbose { - eprintln!("[verbose] Found cached refresh token, attempting revocation..."); - } - - // Best-effort discovery + revocation - match oidc::discover(&args.issuer, verbose).await { - Ok(discovery) => { - if let Err(e) = oidc::refresh::revoke( - &discovery, - &args.client_id, - &refresh_data.refresh_token, - verbose, - ) - .await - { - eprintln!("Warning: could not revoke refresh token: {e}"); - } - } - Err(e) => { - eprintln!("Warning: could not discover endpoints for revocation: {e}"); - } - } - } - - // Delete cached refresh token - cache::delete_refresh_token(&args.issuer)?; - eprintln!("Refresh token cleared."); - - // Delete cached AWS credentials - cache::delete_credentials(&args.role_arn)?; - eprintln!("Cached credentials cleared."); - - Ok(()) -} -``` - -**Step 3: Add the match arm in main()** - -```rust - Commands::Logout(args) => { - if let Err(e) = run_logout(args, verbose).await { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } -``` - -**Step 4: Verify it compiles** - -Run: `cargo build` -Expected: Compiles. `source-coop logout --help` shows expected flags. - -**Step 5: Commit** - -```bash -git add -A && git commit -m "feat: add logout command with token revocation" -``` - ---- - -### Task 9: Final integration verification - -**Files:** None (testing only) - -**Step 1: Verify full build** - -Run: `cargo build` -Expected: Clean compile. - -**Step 2: Verify all help output** - -Run: `cargo run -- --help` -Expected: Shows login, creds, logout commands. - -Run: `cargo run -- login --help` -Expected: Shows --flow, --issuer, --client-id, --scope (default "openid offline_access"), etc. - -Run: `cargo run -- creds --help` -Expected: Shows --no-refresh, --issuer, --proxy-url. - -Run: `cargo run -- logout --help` -Expected: Shows --issuer, --client-id, --role-arn. - -**Step 3: Run existing tests** - -Run: `cargo test` -Expected: All existing tests pass. - -**Step 4: Commit any final adjustments** - -```bash -git add -A && git commit -m "chore: final integration verification" -``` - ---- - -### Summary of all tasks - -| Task | Description | Key files | -|------|-------------|-----------| -| 1 | Restructure oidc.rs → oidc/ module | oidc/mod.rs, oidc/auth_code.rs | -| 2 | Add device code flow | oidc/device_code.rs | -| 3 | Add --flow flag and flow selection | main.rs, oidc/mod.rs | -| 4 | Add refresh token cache | cache.rs | -| 5 | Add refresh token flow module | oidc/refresh.rs | -| 6 | Wire refresh caching into login | main.rs | -| 7 | Add auto-refresh to creds | main.rs | -| 8 | Add logout command | main.rs | -| 9 | Final integration verification | — |