diff --git a/crates/openshell-bootstrap/src/oidc_token.rs b/crates/openshell-bootstrap/src/oidc_token.rs index 19c6cabaa..1dcf49450 100644 --- a/crates/openshell-bootstrap/src/oidc_token.rs +++ b/crates/openshell-bootstrap/src/oidc_token.rs @@ -19,6 +19,10 @@ pub struct OidcTokenBundle { /// `OAuth2` access token (JWT). pub access_token: String, + /// Optional OIDC ID token returned by the provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id_token: Option, + /// `OAuth2` refresh token. `None` for `client_credentials` grants. #[serde(default, skip_serializing_if = "Option::is_none")] pub refresh_token: Option, diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index a421b418a..ff7d75632 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -98,17 +98,19 @@ async fn completion_grpc_client( Some("oidc") => { if let Some(bundle) = load_oidc_token(gateway_name) { if is_token_expired(&bundle) { - match oidc_refresh_token(&bundle, tls_opts.gateway_insecure).await { - Ok(refreshed) => { - let _ = store_oidc_token(gateway_name, &refreshed); - tls_opts.oidc_token = Some(refreshed.access_token); - } - Err(_) => { - tls_opts.oidc_token = Some(bundle.access_token); - } + if let Ok(refreshed) = + oidc_refresh_token(&bundle, tls_opts.gateway_insecure).await + { + let _ = store_oidc_token(gateway_name, &refreshed); + tls_opts.oidc_token = Some(refreshed.access_token); + tls_opts.oidc_id_token = refreshed.id_token; + } else { + tls_opts.oidc_token = Some(bundle.access_token); + tls_opts.oidc_id_token = bundle.id_token; } } else { tls_opts.oidc_token = Some(bundle.access_token); + tls_opts.oidc_id_token = bundle.id_token; } } } @@ -124,6 +126,7 @@ async fn completion_grpc_client( let channel = build_channel(server, &tls_opts).await.ok()?; let interceptor = EdgeAuthInterceptor::new( tls_opts.oidc_token.as_deref(), + tls_opts.oidc_id_token.as_deref(), tls_opts.edge_token.as_deref(), ) .ok()?; diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 22af412b5..47c7903b1 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -140,6 +140,15 @@ fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) { else { return; }; + let bearer_for_gateway = |access_token: &str, id_token: Option<&String>| { + if access_token.matches('.').count() == 2 { + access_token.to_string() + } else { + id_token + .cloned() + .unwrap_or_else(|| access_token.to_string()) + } + }; if openshell_bootstrap::oidc_token::is_token_expired(&bundle) { let insecure = std::env::var("OPENSHELL_GATEWAY_INSECURE") .is_ok_and(|v| !v.is_empty() && v != "0" && v != "false"); @@ -155,17 +164,29 @@ fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) { gateway_name, &refreshed, ); - tls.oidc_token = Some(refreshed.access_token); + tls.oidc_token = Some(bearer_for_gateway( + &refreshed.access_token, + refreshed.id_token.as_ref(), + )); + tls.oidc_id_token = refreshed.id_token; } Err(e) => { tracing::warn!("OIDC token refresh failed: {e}"); // Use the expired token anyway — server will reject it // with a clear error prompting re-login. - tls.oidc_token = Some(bundle.access_token); + tls.oidc_token = Some(bearer_for_gateway( + &bundle.access_token, + bundle.id_token.as_ref(), + )); + tls.oidc_id_token = bundle.id_token; } } } else { - tls.oidc_token = Some(bundle.access_token); + tls.oidc_token = Some(bearer_for_gateway( + &bundle.access_token, + bundle.id_token.as_ref(), + )); + tls.oidc_id_token = bundle.id_token; } } _ => {} @@ -660,6 +681,8 @@ enum OutputFormat { enum CliProviderRefreshStrategy { Oauth2RefreshToken, Oauth2ClientCredentials, + Oauth2TokenExchange, + OktaXaa, GoogleServiceAccountJwt, } @@ -668,6 +691,8 @@ impl CliProviderRefreshStrategy { match self { Self::Oauth2RefreshToken => "oauth2_refresh_token", Self::Oauth2ClientCredentials => "oauth2_client_credentials", + Self::Oauth2TokenExchange => "oauth2_token_exchange", + Self::OktaXaa => "okta_xaa", Self::GoogleServiceAccountJwt => "google_service_account_jwt", } } @@ -2925,6 +2950,7 @@ async fn main() -> Result<()> { let channel = openshell_cli::tls::build_channel(&ctx.endpoint, &tls).await?; let interceptor = openshell_core::auth::EdgeAuthInterceptor::new( tls.oidc_token.as_deref(), + tls.oidc_id_token.as_deref(), tls.edge_token.as_deref(), )?; openshell_tui::run(channel, interceptor, &ctx.name, &ctx.endpoint, theme).await?; diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 379a53112..b508434c7 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -14,13 +14,17 @@ use hyper::{Method, Response, StatusCode}; use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::conn::auto::Builder; use miette::{IntoDiagnostic, Result}; -use oauth2::basic::BasicClient; use oauth2::{ - AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, - RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl, + AuthType, AuthUrl, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, + EndpointNotSet, ExtraTokenFields, PkceCodeChallenge, RedirectUrl, RefreshToken, Scope, + StandardRevocableToken, StandardTokenResponse, TokenResponse, TokenUrl, + basic::{ + BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, + BasicTokenType, + }, }; use openshell_bootstrap::oidc_token::OidcTokenBundle; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::convert::Infallible; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -29,6 +33,37 @@ use tokio::sync::oneshot; use tracing::debug; const AUTH_TIMEOUT: Duration = Duration::from_secs(120); +const DEFAULT_OIDC_CALLBACK_BIND: &str = "127.0.0.1:0"; +const OIDC_CALLBACK_PORT_ENV: &str = "OPENSHELL_OIDC_CALLBACK_PORT"; +const OIDC_CLIENT_SECRET_ENV: &str = "OPENSHELL_OIDC_CLIENT_SECRET"; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +struct OidcExtraTokenFields { + #[serde(default, skip_serializing_if = "Option::is_none")] + id_token: Option, +} + +impl ExtraTokenFields for OidcExtraTokenFields {} + +type OidcTokenResponse = StandardTokenResponse; +type OidcClient< + HasAuthUrl = EndpointNotSet, + HasDeviceAuthUrl = EndpointNotSet, + HasIntrospectionUrl = EndpointNotSet, + HasRevocationUrl = EndpointNotSet, + HasTokenUrl = EndpointNotSet, +> = Client< + BasicErrorResponse, + OidcTokenResponse, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, + HasAuthUrl, + HasDeviceAuthUrl, + HasIntrospectionUrl, + HasRevocationUrl, + HasTokenUrl, +>; /// OIDC discovery document (subset of fields we need). #[derive(Debug, Deserialize)] @@ -95,6 +130,25 @@ fn build_ci_scopes(scopes: Option<&str>) -> Vec { .collect() } +fn oidc_callback_bind_address() -> Result { + match std::env::var(OIDC_CALLBACK_PORT_ENV) { + Ok(raw) => { + let port = raw.parse::().map_err(|_| { + miette::miette!( + "{OIDC_CALLBACK_PORT_ENV} must be a valid TCP port number, got '{raw}'" + ) + })?; + if port == 0 { + return Err(miette::miette!( + "{OIDC_CALLBACK_PORT_ENV} must be greater than 0" + )); + } + Ok(format!("127.0.0.1:{port}")) + } + Err(_) => Ok(DEFAULT_OIDC_CALLBACK_BIND.to_string()), + } +} + /// Run the OIDC Authorization Code + PKCE browser flow. /// /// Opens the user's browser to the Keycloak login page and waits for @@ -108,14 +162,21 @@ pub async fn oidc_browser_auth_flow( ) -> Result { let discovery = discover(issuer, insecure).await?; - let listener = TcpListener::bind("127.0.0.1:0").await.into_diagnostic()?; + let listener = TcpListener::bind(oidc_callback_bind_address()?) + .await + .into_diagnostic()?; let port = listener.local_addr().into_diagnostic()?.port(); let redirect_uri = format!("http://127.0.0.1:{port}/callback"); - let client = BasicClient::new(ClientId::new(client_id.to_string())) + let mut client = OidcClient::new(ClientId::new(client_id.to_string())) .set_auth_uri(AuthUrl::new(discovery.authorization_endpoint).into_diagnostic()?) .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?) .set_redirect_uri(RedirectUrl::new(redirect_uri).into_diagnostic()?); + if let Ok(client_secret) = std::env::var(OIDC_CLIENT_SECRET_ENV) { + client = client + .set_client_secret(ClientSecret::new(client_secret)) + .set_auth_type(AuthType::RequestBody); + } let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); @@ -167,7 +228,7 @@ pub async fn oidc_browser_auth_flow( server_handle.abort(); let http = http_client(insecure); - let token_response = client + let token_response: OidcTokenResponse = client .exchange_code(AuthorizationCode::new(code)) .set_pkce_verifier(pkce_verifier) .request_async(&http) @@ -191,15 +252,15 @@ pub async fn oidc_client_credentials_flow( scopes: Option<&str>, insecure: bool, ) -> Result { - let client_secret = std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").map_err(|_| { + let client_secret = std::env::var(OIDC_CLIENT_SECRET_ENV).map_err(|_| { miette::miette!( - "OPENSHELL_OIDC_CLIENT_SECRET environment variable is required for client credentials flow" + "{OIDC_CLIENT_SECRET_ENV} environment variable is required for client credentials flow" ) })?; let discovery = discover(issuer, insecure).await?; - let client = BasicClient::new(ClientId::new(client_id.to_string())) + let client = OidcClient::new(ClientId::new(client_id.to_string())) .set_client_secret(ClientSecret::new(client_secret)) .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?) .set_auth_type(AuthType::RequestBody); @@ -213,7 +274,7 @@ pub async fn oidc_client_credentials_flow( } let http = http_client(insecure); - let token_response = request + let token_response: OidcTokenResponse = request .request_async(&http) .await .map_err(|e| miette::miette!("client credentials token exchange failed: {e}"))?; @@ -241,11 +302,11 @@ pub async fn oidc_refresh_token( let discovery = discover(&bundle.issuer, insecure).await?; - let client = BasicClient::new(ClientId::new(bundle.client_id.clone())) + let client = OidcClient::new(ClientId::new(bundle.client_id.clone())) .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?); let http = http_client(insecure); - let token_response = client + let token_response: OidcTokenResponse = client .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string())) .request_async(&http) .await @@ -287,7 +348,7 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu // ── Helpers ────────────────────────────────────────────────────────── fn bundle_from_oauth2_response( - resp: &oauth2::basic::BasicTokenResponse, + resp: &OidcTokenResponse, issuer: &str, client_id: &str, ) -> OidcTokenBundle { @@ -298,6 +359,7 @@ fn bundle_from_oauth2_response( OidcTokenBundle { access_token: resp.access_token().secret().clone(), + id_token: resp.extra_fields().id_token.clone(), refresh_token: resp.refresh_token().map(|rt| rt.secret().clone()), expires_at: resp.expires_in().map(|ei| now + ei.as_secs()), issuer: issuer.to_string(), @@ -518,14 +580,13 @@ mod tests { #[test] fn bundle_from_response_sets_fields() { - use oauth2::basic::BasicTokenResponse; - - let token_response: BasicTokenResponse = serde_json::from_str( - r#"{"access_token":"test-access","token_type":"bearer","expires_in":300,"refresh_token":"test-refresh"}"#, + let token_response: OidcTokenResponse = serde_json::from_str( + r#"{"access_token":"test-access","token_type":"bearer","expires_in":300,"refresh_token":"test-refresh","id_token":"test-id"}"#, ) .unwrap(); let bundle = bundle_from_oauth2_response(&token_response, "https://issuer", "my-client"); assert_eq!(bundle.access_token, "test-access"); + assert_eq!(bundle.id_token.as_deref(), Some("test-id")); assert_eq!(bundle.refresh_token.as_deref(), Some("test-refresh")); assert_eq!(bundle.issuer, "https://issuer"); assert_eq!(bundle.client_id, "my-client"); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 76f1214d3..fe813804d 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5004,6 +5004,8 @@ fn provider_refresh_strategy(strategy: &str) -> Result { Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) } + "oauth2_token_exchange" => Ok(ProviderCredentialRefreshStrategy::Oauth2TokenExchange), + "okta_xaa" => Ok(ProviderCredentialRefreshStrategy::OktaXaa), "google_service_account_jwt" => { Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) } @@ -5058,6 +5060,8 @@ fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) - ProviderCredentialRefreshStrategy::External => "external", ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => "oauth2_token_exchange", + ProviderCredentialRefreshStrategy::OktaXaa => "okta_xaa", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } diff --git a/crates/openshell-cli/src/tls.rs b/crates/openshell-cli/src/tls.rs index 10df401a5..ce1f24387 100644 --- a/crates/openshell-cli/src/tls.rs +++ b/crates/openshell-cli/src/tls.rs @@ -40,6 +40,9 @@ pub struct TlsOptions { /// OIDC bearer token — when set, injects `authorization: Bearer ` /// on every gRPC request. Takes precedence over `edge_token`. pub oidc_token: Option, + /// OIDC ID token — when set, injects a gateway-private metadata header + /// so delegated/XAA flows can bind the signed-in user session. + pub oidc_id_token: Option, /// Skip TLS certificate verification for gateway connections. pub gateway_insecure: bool, } @@ -53,6 +56,7 @@ impl TlsOptions { gateway_name: None, edge_token: None, oidc_token: None, + oidc_id_token: None, gateway_insecure: false, } } @@ -441,7 +445,11 @@ pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result { } fn interceptor_from_tls(tls: &TlsOptions) -> Result { - EdgeAuthInterceptor::new(tls.oidc_token.as_deref(), tls.edge_token.as_deref()) + EdgeAuthInterceptor::new( + tls.oidc_token.as_deref(), + tls.oidc_id_token.as_deref(), + tls.edge_token.as_deref(), + ) } pub async fn grpc_inference_client(server: &str, tls: &TlsOptions) -> Result { diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index ed78c6659..06220e251 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1098,6 +1098,95 @@ async fn provider_refresh_cli_run_functions_wire_requests() { ); } +#[tokio::test] +async fn provider_refresh_cli_supports_oauth2_token_exchange_strategy() { + let ts = run_server().await; + + ts.state.profiles.lock().await.insert( + "okta-obo".to_string(), + ProviderProfile { + id: "okta-obo".to_string(), + display_name: "Okta OBO".to_string(), + credentials: vec![ProviderProfileCredential { + name: "OKTA_OBO_ACCESS_TOKEN".to_string(), + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::Oauth2TokenExchange as i32, + token_url: "https://example.okta.com/oauth2/default/v1/token".to_string(), + material: vec![ + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "client_id".to_string(), + required: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "audience".to_string(), + required: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "client_secret".to_string(), + secret: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "subject_token".to_string(), + secret: true, + ..Default::default() + }, + ], + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }, + ); + + run::provider_create( + &ts.endpoint, + "okta-obo-runtime", + "okta-obo", + false, + &[], + false, + &[], + &ts.tls, + ) + .await + .expect("provider create"); + + run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "okta-obo-runtime", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + strategy: "oauth2_token_exchange", + material: &[ + "client_id=client-id".to_string(), + "audience=api://downstream".to_string(), + "subject_token=user-token".to_string(), + "scope=api:access:read".to_string(), + ], + secret_material_keys: &["client_secret".to_string()], + credential_expires_at_ms: None, + }, + &ts.tls, + ) + .await + .expect("provider refresh configure"); + + let requests = ts.state.refresh_requests.lock().await.clone(); + assert_eq!( + requests, + vec![ProviderRefreshRequestLog::Configure { + provider_name: "okta-obo-runtime".to_string(), + credential_key: "OKTA_OBO_ACCESS_TOKEN".to_string(), + expires_at_ms: None, + }] + ); +} + #[tokio::test] async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() { let ts = run_server().await; @@ -1708,6 +1797,169 @@ endpoints: .expect_err("valid profiles should not be partially imported after local parse errors"); } +#[tokio::test] +async fn built_in_okta_obo_profile_is_available_via_provider_profile_api() { + let ts = run_server().await; + + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client should connect"); + let profile = client + .get_provider_profile(openshell_core::proto::GetProviderProfileRequest { + id: "okta-obo".to_string(), + }) + .await + .expect("get provider profile") + .into_inner() + .profile + .expect("profile should exist"); + + assert_eq!(profile.id, "okta-obo"); + let credential = profile + .credentials + .iter() + .find(|credential| credential.name == "obo_access_token") + .expect("obo access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("obo credential should include refresh config"); + assert_eq!( + refresh.strategy, + ProviderCredentialRefreshStrategy::Oauth2TokenExchange as i32 + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "client_id" && material.required) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "audience" && material.required) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "subject_token" && !material.required) + ); +} + +#[tokio::test] +async fn built_in_okta_xaa_profile_is_available_via_provider_profile_api() { + let ts = run_server().await; + + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client should connect"); + let profile = client + .get_provider_profile(openshell_core::proto::GetProviderProfileRequest { + id: "okta-xaa".to_string(), + }) + .await + .expect("get provider profile") + .into_inner() + .profile + .expect("profile should exist"); + + assert_eq!(profile.id, "okta-xaa"); + let credential = profile + .credentials + .iter() + .find(|credential| credential.name == "xaa_access_token") + .expect("xaa access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("xaa credential should include refresh config"); + assert_eq!( + refresh.strategy, + ProviderCredentialRefreshStrategy::OktaXaa as i32 + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "subject_token" + && !material.required + && material.secret) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "requesting_client_id" && material.required) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "requesting_client_secret" + && material.required + && material.secret) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "resource_client_id" && material.required) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "resource_client_secret" + && material.required + && material.secret) + ); +} + +#[tokio::test] +async fn built_in_xaa_dev_profile_is_available_via_provider_profile_api() { + let ts = run_server().await; + + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client should connect"); + let profile = client + .get_provider_profile(openshell_core::proto::GetProviderProfileRequest { + id: "xaa-dev".to_string(), + }) + .await + .expect("get provider profile") + .into_inner() + .profile + .expect("profile should exist"); + + assert_eq!(profile.id, "xaa-dev"); + let credential = profile + .credentials + .iter() + .find(|credential| credential.name == "xaa_access_token") + .expect("xaa access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("xaa credential should include refresh config"); + assert_eq!( + refresh.strategy, + ProviderCredentialRefreshStrategy::OktaXaa as i32 + ); + assert_eq!(refresh.token_url, "https://idp.xaa.dev/token"); + assert_eq!(refresh.scopes, vec!["todos.read"]); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "resource_client_secret" + && material.required + && material.secret) + ); +} + #[tokio::test] async fn provider_profile_lint_from_directory_reports_parse_errors_without_importing() { let ts = run_server().await; diff --git a/crates/openshell-core/src/auth.rs b/crates/openshell-core/src/auth.rs index 16d513346..57fac8ebb 100644 --- a/crates/openshell-core/src/auth.rs +++ b/crates/openshell-core/src/auth.rs @@ -14,6 +14,7 @@ use miette::Result; #[allow(clippy::struct_field_names)] pub struct EdgeAuthInterceptor { bearer_value: Option>, + oidc_id_token_value: Option>, header_value: Option>, cookie_value: Option>, } @@ -23,14 +24,27 @@ impl EdgeAuthInterceptor { /// /// OIDC bearer tokens take precedence over edge tokens. Returns a no-op /// interceptor when no token is provided. - pub fn new(oidc_token: Option<&str>, edge_token: Option<&str>) -> Result { + pub fn new( + oidc_token: Option<&str>, + oidc_id_token: Option<&str>, + edge_token: Option<&str>, + ) -> Result { if let Some(token) = oidc_token { let bearer: tonic::metadata::MetadataValue = format!("Bearer {token}") .parse() .map_err(|_| miette::miette!("invalid bearer token value"))?; + let oidc_id_token_value = match oidc_id_token { + Some(token) => Some( + token + .parse() + .map_err(|_| miette::miette!("invalid OIDC ID token value"))?, + ), + None => None, + }; return Ok(Self { bearer_value: Some(bearer), + oidc_id_token_value, header_value: None, cookie_value: None, }); @@ -51,6 +65,7 @@ impl EdgeAuthInterceptor { }; Ok(Self { bearer_value: None, + oidc_id_token_value: None, header_value, cookie_value, }) @@ -60,6 +75,7 @@ impl EdgeAuthInterceptor { pub fn noop() -> Self { Self { bearer_value: None, + oidc_id_token_value: None, header_value: None, cookie_value: None, } @@ -74,6 +90,10 @@ impl tonic::service::Interceptor for EdgeAuthInterceptor { if let Some(ref val) = self.bearer_value { req.metadata_mut().insert("authorization", val.clone()); } + if let Some(ref val) = self.oidc_id_token_value { + req.metadata_mut() + .insert("x-openshell-oidc-id-token", val.clone()); + } if let Some(ref val) = self.header_value { req.metadata_mut() .insert("cf-access-jwt-assertion", val.clone()); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 316624287..912bad4eb 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -21,6 +21,9 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/github.yaml"), include_str!("../../../providers/google-vertex-ai.yaml"), include_str!("../../../providers/nvidia.yaml"), + include_str!("../../../providers/okta-obo.yaml"), + include_str!("../../../providers/okta-xaa.yaml"), + include_str!("../../../providers/xaa-dev.yaml"), ]; #[derive(Debug, thiserror::Error)] @@ -372,7 +375,9 @@ impl CredentialRefreshProfile { self.strategy, ProviderCredentialRefreshStrategy::Oauth2RefreshToken | ProviderCredentialRefreshStrategy::Oauth2ClientCredentials + | ProviderCredentialRefreshStrategy::Oauth2TokenExchange | ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt + | ProviderCredentialRefreshStrategy::OktaXaa ) } } @@ -530,6 +535,8 @@ pub fn provider_refresh_strategy_from_yaml(raw: &str) -> Option { Some(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) } + "oauth2_token_exchange" => Some(ProviderCredentialRefreshStrategy::Oauth2TokenExchange), + "okta_xaa" => Some(ProviderCredentialRefreshStrategy::OktaXaa), "google_service_account_jwt" => { Some(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) } @@ -546,6 +553,8 @@ pub fn provider_refresh_strategy_to_yaml( ProviderCredentialRefreshStrategy::External => "external", ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => "oauth2_token_exchange", + ProviderCredentialRefreshStrategy::OktaXaa => "okta_xaa", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } @@ -619,7 +628,6 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { allow_encoded_slash: endpoint.allow_encoded_slash, websocket_credential_rewrite: endpoint.websocket_credential_rewrite, request_body_credential_rewrite: endpoint.request_body_credential_rewrite, - advisor_proposed: false, persisted_queries: endpoint.persisted_queries.clone(), graphql_persisted_queries: endpoint .graphql_persisted_queries @@ -628,6 +636,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), + advisor_proposed: false, } } @@ -1173,95 +1182,181 @@ mod tests { } #[test] - fn credential_env_vars_are_deduplicated_in_profile_order() { - let profile = get_default_profile("claude-code").expect("claude-code profile"); + fn okta_obo_profile_exposes_token_exchange_shape() { + let profile = get_default_profile("okta-obo").expect("okta-obo profile"); + let credential = profile + .credentials + .iter() + .find(|credential| credential.name == "obo_access_token") + .expect("okta-obo access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("okta-obo credential should be refreshable"); + assert_eq!( - profile.credential_env_vars(), - vec!["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"] + refresh.strategy, + openshell_core::proto::ProviderCredentialRefreshStrategy::Oauth2TokenExchange + ); + assert_eq!( + refresh.token_url, + "https://example.okta.com/oauth2/default/v1/token" + ); + + let material_names = refresh + .material + .iter() + .map(|material| material.name.as_str()) + .collect::>(); + assert_eq!( + material_names, + vec![ + "client_id", + "audience", + "client_secret", + "subject_token", + "scope" + ] + ); + assert!( + refresh + .material + .iter() + .find(|material| material.name == "audience") + .is_some_and(|material| material.required) + ); + assert!( + refresh + .material + .iter() + .find(|material| material.name == "subject_token") + .is_some_and(|material| !material.required && material.secret) ); } #[test] - fn vertex_profile_declares_discovery_and_fallback_token_env_vars() { - let profile = get_default_profile("google-vertex-ai").expect("vertex profile"); - let service_account_token = profile - .credentials - .iter() - .find(|credential| credential.name == "service_account_token") - .expect("vertex service-account token credential"); - let adc_credential = profile + fn okta_xaa_profile_exposes_id_token_exchange_shape() { + let profile = get_default_profile("okta-xaa").expect("okta-xaa profile"); + let credential = profile .credentials .iter() - .find(|credential| credential.name == "gcloud_adc_token") - .expect("vertex ADC credential"); + .find(|credential| credential.name == "xaa_access_token") + .expect("okta-xaa access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("okta-xaa credential should be refreshable"); assert_eq!( - service_account_token.env_vars, - vec![ - "GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN".to_string(), - "VERTEX_AI_SERVICE_ACCOUNT_TOKEN".to_string() - ] + refresh.strategy, + openshell_core::proto::ProviderCredentialRefreshStrategy::OktaXaa ); assert_eq!( - adc_credential.env_vars, + refresh.token_url, + "https://example.okta.com/oauth2/v1/token" + ); + + let material_names = refresh + .material + .iter() + .map(|material| material.name.as_str()) + .collect::>(); + assert_eq!( + material_names, vec![ - "GOOGLE_VERTEX_AI_TOKEN".to_string(), - "VERTEX_AI_TOKEN".to_string() + "requesting_client_id", + "requesting_client_secret", + "subject_token", + "resource_client_id", + "resource_client_secret", + "audience", + "resource", + "resource_token_url", + "scope" ] ); - assert_eq!( - profile.discovery.credentials, - vec!["service_account_token", "gcloud_adc_token"] + assert!( + refresh + .material + .iter() + .find(|material| material.name == "requesting_client_secret") + .is_some_and(|material| material.required && material.secret) ); assert!( - profile.allows_gateway_refresh_bootstrap(), - "Vertex profile should allow empty-create bootstrap via gateway-mintable credentials" + refresh + .material + .iter() + .find(|material| material.name == "resource_client_secret") + .is_some_and(|material| material.required && material.secret) + ); + assert!( + refresh + .material + .iter() + .find(|material| material.name == "subject_token") + .is_some_and(|material| !material.required && material.secret) ); } #[test] - fn refresh_bootstrap_requires_a_gateway_mintable_path_and_no_required_static_credentials() { - let optional_refresh_profile = parse_profile_yaml( - r" -id: optional-refresh -display_name: Optional Refresh -credentials: - - name: access_token - required: false - refresh: - strategy: oauth2_refresh_token -", - ) - .expect("profile"); - assert!(optional_refresh_profile.allows_gateway_refresh_bootstrap()); + fn xaa_dev_profile_exposes_sample_two_step_shape() { + let profile = get_default_profile("xaa-dev").expect("xaa-dev profile"); + let credential = profile + .credentials + .iter() + .find(|credential| credential.name == "xaa_access_token") + .expect("xaa-dev access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("xaa-dev credential should be refreshable"); - let mixed_required_profile = parse_profile_yaml( - r" -id: mixed-required -display_name: Mixed Required -credentials: - - name: access_token - required: true - refresh: - strategy: oauth2_client_credentials - - name: static_key - required: true -", - ) - .expect("profile"); - assert!(!mixed_required_profile.allows_gateway_refresh_bootstrap()); + assert_eq!( + refresh.strategy, + openshell_core::proto::ProviderCredentialRefreshStrategy::OktaXaa + ); + assert_eq!(refresh.token_url, "https://idp.xaa.dev/token"); + assert_eq!(refresh.scopes, vec!["todos.read"]); - let static_only_profile = parse_profile_yaml( - r" -id: static-only -display_name: Static Only -credentials: - - name: api_key - required: false -", - ) - .expect("profile"); - assert!(!static_only_profile.allows_gateway_refresh_bootstrap()); + let material_names = refresh + .material + .iter() + .map(|material| material.name.as_str()) + .collect::>(); + assert_eq!( + material_names, + vec![ + "requesting_client_id", + "requesting_client_secret", + "subject_token", + "resource_client_id", + "resource_client_secret", + "audience", + "resource", + "resource_token_url", + ] + ); + assert!( + refresh + .material + .iter() + .find(|material| material.name == "subject_token") + .is_some_and(|material| !material.required && material.secret) + ); + assert_eq!(profile.endpoints.len(), 3); + assert_eq!(profile.endpoints[0].host, "idp.xaa.dev"); + assert_eq!(profile.endpoints[1].host, "auth.resource.xaa.dev"); + assert_eq!(profile.endpoints[2].host, "api.resource.xaa.dev"); + assert_eq!(profile.endpoints[2].access, "read-only"); + } + + #[test] + fn credential_env_vars_are_deduplicated_in_profile_order() { + let profile = get_default_profile("claude-code").expect("claude-code profile"); + assert_eq!( + profile.credential_env_vars(), + vec!["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"] + ); } #[test] diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index bf5490f2a..945f86d61 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -109,6 +109,38 @@ pub struct OidcClaims { const STANDARD_OIDC_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"]; +/// Raw OIDC bearer token captured from the inbound request. +/// +/// Stored in request extensions only after OIDC authentication succeeds so +/// later handlers can persist or exchange the user token without reparsing the +/// header. +#[derive(Debug, Clone)] +pub struct RawBearerToken(pub String); + +/// Raw OIDC ID token forwarded from the authenticated CLI/TUI request. +/// +/// This is a gateway-private metadata channel used for delegated/XAA flows. +/// The gateway only trusts it after the access token has already authenticated +/// the caller as an OIDC user. +#[derive(Debug, Clone)] +pub struct RawIdToken(pub String); + +/// Extract a bearer token from an `Authorization` header. +pub fn extract_bearer_token(headers: &http::HeaderMap) -> Option<&str> { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) +} + +/// Extract the forwarded OIDC ID token from a private gRPC metadata header. +pub fn extract_id_token(headers: &http::HeaderMap) -> Option<&str> { + headers + .get("x-openshell-oidc-id-token") + .and_then(|v| v.to_str().ok()) + .filter(|value| !value.trim().is_empty()) +} + impl OidcClaims { /// Extract roles from the JWT claims using a dot-separated path. /// @@ -372,11 +404,7 @@ impl Authenticator for OidcAuthenticator { headers: &http::HeaderMap, _path: &str, ) -> Result, Status> { - let Some(token) = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - else { + let Some(token) = extract_bearer_token(headers) else { return Ok(None); }; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e6f0c2780..c5a4ab20d 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5,6 +5,9 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> +use crate::auth::identity::IdentityProvider; +use crate::auth::oidc::{RawBearerToken, RawIdToken}; +use crate::auth::principal::Principal; use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; @@ -1238,6 +1241,9 @@ pub(super) async fn handle_configure_provider_refresh( state: &Arc, request: Request, ) -> Result, Status> { + let principal = request.extensions().get::().cloned(); + let raw_bearer_token = request.extensions().get::().cloned(); + let raw_id_token = request.extensions().get::().cloned(); let request = request.into_inner(); let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); @@ -1379,6 +1385,91 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, ) .await?; + let mut material = request.material; + let mut secret_material_keys = request.secret_material_keys; + match strategy { + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => { + match (principal.as_ref(), raw_bearer_token.as_ref()) { + (Some(Principal::User(user)), Some(raw)) + if user.identity.provider == IdentityProvider::Oidc => + { + material.insert("subject_token".to_string(), raw.0.clone()); + if !secret_material_keys + .iter() + .any(|key| key == "subject_token") + { + secret_material_keys.push("subject_token".to_string()); + } + } + _ => { + if let Some(existing) = existing_refresh_state + .as_ref() + .and_then(|state| state.material.get("subject_token")) + { + material + .entry("subject_token".to_string()) + .or_insert_with(|| existing.clone()); + } else { + return Err(Status::failed_precondition( + "oauth2_token_exchange refresh requires an authenticated OIDC user bearer token during configuration", + )); + } + if existing_refresh_state.as_ref().is_some_and(|state| { + state + .secret_material_keys + .iter() + .any(|key| key == "subject_token") + }) && !secret_material_keys + .iter() + .any(|key| key == "subject_token") + { + secret_material_keys.push("subject_token".to_string()); + } + } + } + } + ProviderCredentialRefreshStrategy::OktaXaa => { + match (principal.as_ref(), raw_id_token.as_ref()) { + (Some(Principal::User(user)), Some(raw)) + if user.identity.provider == IdentityProvider::Oidc => + { + material.insert("subject_token".to_string(), raw.0.clone()); + if !secret_material_keys + .iter() + .any(|key| key == "subject_token") + { + secret_material_keys.push("subject_token".to_string()); + } + } + _ => { + if let Some(existing) = existing_refresh_state + .as_ref() + .and_then(|state| state.material.get("subject_token")) + { + material + .entry("subject_token".to_string()) + .or_insert_with(|| existing.clone()); + } else { + return Err(Status::failed_precondition( + "okta_xaa refresh requires an authenticated OIDC user id_token during configuration", + )); + } + if existing_refresh_state.as_ref().is_some_and(|state| { + state + .secret_material_keys + .iter() + .any(|key| key == "subject_token") + }) && !secret_material_keys + .iter() + .any(|key| key == "subject_token") + { + secret_material_keys.push("subject_token".to_string()); + } + } + } + } + _ => {} + } let expires_at_ms = request.expires_at_ms.unwrap_or_else(|| { existing_refresh_state .as_ref() @@ -1390,8 +1481,8 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, crate::provider_refresh::NewRefreshStateConfig { strategy, - material: request.material, - secret_material_keys: request.secret_material_keys, + material, + secret_material_keys, expires_at_ms, token_url, scopes, @@ -1818,7 +1909,15 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["claude-code", "github", "google-vertex-ai", "nvidia",] + vec![ + "claude-code", + "github", + "google-vertex-ai", + "nvidia", + "okta-obo", + "okta-xaa", + "xaa-dev", + ] ); let github = response diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 198d5f04c..06f67fb4c 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -211,7 +211,6 @@ async fn handle_create_sandbox_inner( Some(Err(status)) => return Err(status), None => None, }; - let sandbox = state.compute.create_sandbox(sandbox, sandbox_token).await?; info!( @@ -494,17 +493,7 @@ async fn handle_delete_sandbox_inner( return Err(Status::invalid_argument("name is required")); } - let sandbox_id = state - .store - .get_message_by_name::(&name) - .await - .ok() - .flatten() - .map(|sandbox| sandbox.object_id().to_string()); let deleted = state.compute.delete_sandbox(&name).await?; - if deleted && let Some(sandbox_id) = sandbox_id { - state.telemetry.end_sandbox_session(&sandbox_id); - } info!(sandbox_name = %name, "DeleteSandbox request completed successfully"); Ok(Response::new(DeleteSandboxResponse { deleted })) } diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index e94326f98..29dd5d0a7 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -467,6 +467,31 @@ where } } + let raw_oidc_bearer = if let Principal::User(ref user) = principal { + if user.identity.provider == crate::auth::identity::IdentityProvider::Oidc { + oidc::extract_bearer_token(req.headers()).map(str::to_owned) + } else { + None + } + } else { + None + }; + if let Some(token) = raw_oidc_bearer { + req.extensions_mut().insert(oidc::RawBearerToken(token)); + } + let raw_oidc_id_token = if let Principal::User(ref user) = principal { + if user.identity.provider == crate::auth::identity::IdentityProvider::Oidc { + oidc::extract_id_token(req.headers()).map(str::to_owned) + } else { + None + } + } else { + None + }; + if let Some(token) = raw_oidc_id_token { + req.extensions_mut().insert(oidc::RawIdToken(token)); + } + req.extensions_mut().insert(principal); inner.ready().await?.call(req).await }) diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 161daeb7f..a5acb86d1 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -246,6 +246,16 @@ struct GoogleServiceAccountClaims<'a> { sub: Option<&'a str>, } +#[derive(Debug, Serialize)] +struct OktaXaaClientAssertionClaims<'a> { + iss: &'a str, + sub: &'a str, + aud: &'a str, + iat: i64, + exp: i64, + jti: String, +} + pub fn next_refresh_at_ms( expires_at_ms: i64, refresh_before_seconds: i64, @@ -278,6 +288,8 @@ pub fn refresh_strategy_name(strategy: i32) -> &'static str { ProviderCredentialRefreshStrategy::External => "external", ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => "oauth2_token_exchange", + ProviderCredentialRefreshStrategy::OktaXaa => "okta_xaa", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } @@ -288,6 +300,8 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy) strategy, ProviderCredentialRefreshStrategy::Oauth2RefreshToken | ProviderCredentialRefreshStrategy::Oauth2ClientCredentials + | ProviderCredentialRefreshStrategy::Oauth2TokenExchange + | ProviderCredentialRefreshStrategy::OktaXaa | ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt ) } @@ -446,6 +460,10 @@ async fn mint_credential( ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => { mint_oauth2_client_credentials(state).await } + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => { + mint_oauth2_token_exchange(state).await + } + ProviderCredentialRefreshStrategy::OktaXaa => mint_okta_xaa_token_exchange(state).await, ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => { mint_google_service_account_jwt(state).await } @@ -465,18 +483,57 @@ async fn mint_oauth2_refresh_token( let refresh_token = required_material(&state.material, "refresh_token")?; let mut form = vec![ ("grant_type".to_string(), "refresh_token".to_string()), - ("client_id".to_string(), client_id), ("refresh_token".to_string(), refresh_token), ]; - if let Some(client_secret) = material_value(&state.material, &["client_secret"]) { - form.push(("client_secret".to_string(), client_secret)); + let basic_auth = material_value(&state.material, &["client_secret"]) + .map(|client_secret| (client_id.clone(), client_secret)); + if basic_auth.is_none() { + form.push(("client_id".to_string(), client_id)); } let scope = refresh_scopes(state).join(" "); if !scope.is_empty() { form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token(&token_url, &form, basic_auth, state.max_lifetime_seconds).await +} + +async fn mint_oauth2_token_exchange( + state: &StoredProviderCredentialRefreshState, +) -> Result { + let token_url = oauth2_token_url(state)?; + let client_id = required_material(&state.material, "client_id")?; + let subject_token = required_material(&state.material, "subject_token")?; + + let mut form = vec![ + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), + ), + ("subject_token".to_string(), subject_token), + ( + "subject_token_type".to_string(), + "urn:ietf:params:oauth:token-type:access_token".to_string(), + ), + ( + "requested_token_type".to_string(), + "urn:ietf:params:oauth:token-type:access_token".to_string(), + ), + ]; + let basic_auth = material_value(&state.material, &["client_secret"]) + .map(|client_secret| (client_id.clone(), client_secret)); + if basic_auth.is_none() { + form.push(("client_id".to_string(), client_id)); + } + if let Some(audience) = material_value(&state.material, &["audience", "resource"]) { + form.push(("audience".to_string(), audience)); + } + let scope = refresh_scopes(state).join(" "); + if !scope.is_empty() { + form.push(("scope".to_string(), scope)); + } + + request_token(&token_url, &form, basic_auth, state.max_lifetime_seconds).await } async fn mint_oauth2_client_credentials( @@ -485,17 +542,180 @@ async fn mint_oauth2_client_credentials( let token_url = oauth2_token_url(state)?; let client_id = required_material(&state.material, "client_id")?; let client_secret = required_material(&state.material, "client_secret")?; + let mut form = vec![("grant_type".to_string(), "client_credentials".to_string())]; + let basic_auth = Some((client_id, client_secret)); + let scope = refresh_scopes(state).join(" "); + if !scope.is_empty() { + form.push(("scope".to_string(), scope)); + } + + request_token(&token_url, &form, basic_auth, state.max_lifetime_seconds).await +} + +async fn mint_okta_xaa_token_exchange( + state: &StoredProviderCredentialRefreshState, +) -> Result { + if material_value( + &state.material, + &[ + "requesting_client_id", + "requesting_client_secret", + "resource_client_id", + "resource_client_secret", + ], + ) + .is_some() + { + return mint_okta_xaa_sample_token_exchange(state).await; + } + + let token_url = oauth2_token_url(state)?; + let subject_token = required_material(&state.material, "subject_token")?; + + let client_id = required_material(&state.material, "client_id")?; + let client_assertion = build_okta_xaa_client_assertion(state, &token_url, &client_id)?; + let client_assertion_type = material_value(&state.material, &["client_assertion_type"]) + .unwrap_or_else(|| "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".to_string()); + let audience = required_material(&state.material, "audience")?; + let mut form = vec![ - ("grant_type".to_string(), "client_credentials".to_string()), + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), + ), + ( + "requested_token_type".to_string(), + "urn:ietf:params:oauth:token-type:id-jag".to_string(), + ), ("client_id".to_string(), client_id), - ("client_secret".to_string(), client_secret), + ("client_assertion".to_string(), client_assertion), + ("client_assertion_type".to_string(), client_assertion_type), + ("subject_token".to_string(), subject_token), + ( + "subject_token_type".to_string(), + "urn:ietf:params:oauth:token-type:id_token".to_string(), + ), + ("audience".to_string(), audience), ]; let scope = refresh_scopes(state).join(" "); if !scope.is_empty() { form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token(&token_url, &form, None, state.max_lifetime_seconds).await +} + +async fn mint_okta_xaa_sample_token_exchange( + state: &StoredProviderCredentialRefreshState, +) -> Result { + let subject_token = required_material(&state.material, "subject_token")?; + let requesting_client_id = required_material(&state.material, "requesting_client_id")?; + let requesting_client_secret = required_material(&state.material, "requesting_client_secret")?; + let resource_client_id = required_material(&state.material, "resource_client_id")?; + let resource_client_secret = required_material(&state.material, "resource_client_secret")?; + let audience = required_material(&state.material, "audience")?; + let resource = + material_value(&state.material, &["resource"]).unwrap_or_else(|| audience.clone()); + let scope = refresh_scopes(state).join(" "); + + let idp_token_url = oauth2_token_url(state)?; + let mut jag_form = vec![ + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), + ), + ( + "requested_token_type".to_string(), + "urn:ietf:params:oauth:token-type:id-jag".to_string(), + ), + ("subject_token".to_string(), subject_token), + ( + "subject_token_type".to_string(), + "urn:ietf:params:oauth:token-type:id_token".to_string(), + ), + ("audience".to_string(), audience.clone()), + ("resource".to_string(), resource), + ("client_id".to_string(), requesting_client_id), + ("client_secret".to_string(), requesting_client_secret), + ]; + if !scope.is_empty() { + jag_form.push(("scope".to_string(), scope.clone())); + } + + let id_jag = request_token(&idp_token_url, &jag_form, None, state.max_lifetime_seconds) + .await? + .access_token; + + let resource_token_url = material_value(&state.material, &["resource_token_url"]) + .map_or_else(|| token_url_from_issuer(&audience), Ok)?; + let mut resource_form = vec![ + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:jwt-bearer".to_string(), + ), + ("assertion".to_string(), id_jag), + ("client_id".to_string(), resource_client_id), + ("client_secret".to_string(), resource_client_secret), + ]; + if !scope.is_empty() { + resource_form.push(("scope".to_string(), scope)); + } + + request_token( + &resource_token_url, + &resource_form, + None, + state.max_lifetime_seconds, + ) + .await +} + +fn token_url_from_issuer(issuer: &str) -> Result { + let mut url = reqwest::Url::parse(issuer) + .map_err(|_| Status::invalid_argument("issuer must be an absolute URL"))?; + let mut path = url.path().trim_end_matches('/').to_string(); + if path.is_empty() { + path = "/oauth2/v1/token".to_string(); + } else if path.ends_with("/oauth2") || path.contains("/oauth2/") { + path.push_str("/v1/token"); + } else { + path.push_str("/oauth2/v1/token"); + } + url.set_path(&path); + url.set_query(None); + url.set_fragment(None); + Ok(url.to_string()) +} + +fn build_okta_xaa_client_assertion( + state: &StoredProviderCredentialRefreshState, + token_url: &str, + client_id: &str, +) -> Result { + let private_key_pem = required_material(&state.material, "private_key_pem")?; + let lifetime_secs = if state.max_lifetime_seconds > 0 { + state.max_lifetime_seconds.min(DEFAULT_MAX_LIFETIME_SECONDS) + } else { + DEFAULT_MAX_LIFETIME_SECONDS + }; + let now_secs = current_time_ms() / 1000; + let claims = OktaXaaClientAssertionClaims { + iss: client_id, + sub: client_id, + aud: token_url, + iat: now_secs, + exp: now_secs.saturating_add(lifetime_secs), + jti: uuid::Uuid::new_v4().to_string(), + }; + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); + header.kid = material_value(&state.material, &["kid"]); + jsonwebtoken::encode( + &header, + &claims, + &jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem.as_bytes()) + .map_err(|_| Status::invalid_argument("okta_xaa private_key_pem must be RSA PEM"))?, + ) + .map_err(|_| Status::internal("sign okta xaa client assertion failed")) } async fn mint_google_service_account_jwt( @@ -541,12 +761,13 @@ async fn mint_google_service_account_jwt( ), ("assertion".to_string(), assertion), ]; - request_token(&token_url, &form, lifetime_secs).await + request_token(&token_url, &form, None, lifetime_secs).await } async fn request_token( token_url: &str, form: &[(String, String)], + basic_auth: Option<(String, String)>, max_lifetime_seconds: i64, ) -> Result { let parsed = reqwest::Url::parse(token_url) @@ -565,16 +786,24 @@ async fn request_token( .timeout(Duration::from_secs(30)) .build() .map_err(|e| Status::internal(format!("build refresh HTTP client failed: {e}")))?; - let response = client - .post(parsed) - .form(form) + let request = client.post(parsed).form(form); + let request = if let Some((client_id, client_secret)) = basic_auth { + request.basic_auth(client_id, Some(client_secret)) + } else { + request + }; + let response = request .send() .await .map_err(|e| Status::unavailable(format!("token endpoint request failed: {e}")))?; let status = response.status(); if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); return Err(Status::failed_precondition(format!( - "token endpoint returned HTTP {status}" + "token endpoint returned HTTP {status}: {body}" ))); } let token = response @@ -648,7 +877,7 @@ fn oauth2_token_url(state: &StoredProviderCredentialRefreshState) -> Result("my-obo") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored_provider.credentials.get("OKTA_ACCESS_TOKEN"), + Some(&"delegated-downstream-token".to_string()) + ); + } + + #[tokio::test] + async fn okta_xaa_refresh_uses_id_token_and_generated_client_assertion() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", + )) + .and(body_string_contains( + "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token", + )) + .and(body_string_contains( + "requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag", + )) + .and(body_string_contains( + "audience=https%3A%2F%2Fnvidia-partner.oktapreview.com", + )) + .and(body_string_contains("client_assertion=")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "xaa-delegated-token", + "expires_in": 1800, + "token_type": "Bearer" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("my-xaa", "okta-xaa"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "OKTA_XAA_ACCESS_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::OktaXaa, + material: HashMap::from([ + ("client_id".to_string(), "agent-client-id".to_string()), + ("subject_token".to_string(), "user-id-token".to_string()), + ( + "audience".to_string(), + "https://nvidia-partner.oktapreview.com".to_string(), + ), + ( + "private_key_pem".to_string(), + TEST_RSA_PRIVATE_KEY.to_string(), + ), + ("kid".to_string(), "test-key-id".to_string()), + ]), + secret_material_keys: vec![ + "private_key_pem".to_string(), + "subject_token".to_string(), + ], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: vec!["jira.read".to_string()], + refresh_before_seconds: 300, + max_lifetime_seconds: 3600, + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let refreshed = refresh_provider_credential(&store, "my-xaa", "OKTA_XAA_ACCESS_TOKEN") + .await + .unwrap(); + assert_eq!(refreshed.status, "refreshed"); + + let stored = store + .get_message_by_name::("my-xaa") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.credentials.get("OKTA_XAA_ACCESS_TOKEN"), + Some(&"xaa-delegated-token".to_string()) + ); + } + #[tokio::test] async fn google_service_account_refresh_mints_and_persists_access_token() { let mock_server = MockServer::start().await; diff --git a/crates/openshell-server/src/telemetry.rs b/crates/openshell-server/src/telemetry.rs index 7de21154e..04529bcc4 100644 --- a/crates/openshell-server/src/telemetry.rs +++ b/crates/openshell-server/src/telemetry.rs @@ -21,8 +21,6 @@ impl TelemetryState { pub fn sandbox_session_disconnected(&self, _sandbox_id: &str) {} - pub fn end_sandbox_session(&self, _sandbox_id: &str) {} - pub fn record_network_activity(&self, sandbox_id: &str, summary: &NetworkActivitySummary) { if sandbox_id.is_empty() || !openshell_core::telemetry::enabled() { return; @@ -99,6 +97,5 @@ mod tests { let telemetry = TelemetryState::new(); telemetry.sandbox_session_connected("sb-1"); telemetry.sandbox_session_disconnected("sb-1"); - telemetry.end_sandbox_session("sb-1"); } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 22201dbb6..18d452b24 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -528,7 +528,8 @@ async fn connect_to_gateway(name: &str, endpoint: &str) -> Result<(Channel, Edge Re-authenticate with: openshell gateway login" ); } - let interceptor = EdgeAuthInterceptor::new(Some(&bundle.access_token), None)?; + let interceptor = + EdgeAuthInterceptor::new(Some(&bundle.access_token), bundle.id_token.as_deref(), None)?; let channel = build_oidc_channel(name, endpoint).await?; Ok((channel, interceptor)) } else { diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index 0d82509ad..9ba887eed 100644 --- a/docs/get-started/tutorials/index.mdx +++ b/docs/get-started/tutorials/index.mdx @@ -27,6 +27,16 @@ Launch Claude Code in a sandbox, diagnose a policy denial, and iterate on a cust Configure a Providers v2 Microsoft Graph provider with gateway-managed OAuth2 refresh-token rotation. + + +Configure delegated Okta access on behalf of the logged-in OpenShell user with token exchange. + + + + +Run the sample 2-step Cross App Access flow through OpenShell with `xaa.dev` and the Todo0 resource API. + + Route inference through Ollama using cloud-hosted or local models, and verify it from a sandbox. diff --git a/docs/get-started/tutorials/okta-obo.mdx b/docs/get-started/tutorials/okta-obo.mdx new file mode 100644 index 000000000..cc8035774 --- /dev/null +++ b/docs/get-started/tutorials/okta-obo.mdx @@ -0,0 +1,138 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Delegate Okta Access On Behalf of the Logged-in User" +sidebar-title: "Okta OBO Token Exchange" +slug: "get-started/tutorials/okta-obo" +description: "Configure the built-in Okta OBO provider profile so OpenShell can exchange a logged-in user's token for a delegated downstream token." +keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Okta, OBO, RFC 8693, Token Exchange, Delegation" +--- + +Use the built-in `okta-obo` profile when a sandboxed workload must call a downstream API on behalf of the human who logged into OpenShell. During refresh configuration, the gateway captures the logged-in user's bearer token as provider refresh secret material and later exchanges it for a short-lived delegated token using Okta token exchange. + +After completing this tutorial, you have: + +- A token-exchange service app in Okta for delegated access. +- A customized `okta-obo` provider profile that points at your Okta tenant. +- An attached provider that can mint `OKTA_OBO_ACCESS_TOKEN` from the logged-in user's identity. + + +This tutorial covers the delegation lane. It assumes you already completed the gateway login lane and can log into the gateway with Okta before you create the sandbox. + + +## Prerequisites + +- A working OpenShell installation with an active gateway. +- Okta CLI login already working against your OpenShell gateway. +- An Okta custom authorization server. +- An Okta service app with the `Token Exchange` grant enabled. +- A downstream audience and scope that the delegated token should target. + +For the example commands below, set: + +| Variable | Value | +|---|---| +| `OKTA_OBO_CLIENT_ID` | Token-exchange service app client ID. | +| `OKTA_OBO_CLIENT_SECRET` | Token-exchange service app client secret. | +| `OKTA_OBO_AUDIENCE` | Downstream audience, such as `api://default`. | +| `OKTA_OBO_SCOPE` | Delegated scope, such as `api:access:read`. | + + +Treat the token-exchange service app secret like any other production credential. Do not commit it, paste it into source control, or leave it in shell history longer than needed. + + + + +## Create the Downstream Scope and Rule in Okta + +In your Okta custom authorization server: + +- create the downstream scope that the delegated token should carry +- create an access-policy rule for the service app with the `Token Exchange` grant enabled + +For a smoke test, a broad rule with `Any scopes` is fine. + +## Create a Tenant-Specific OBO Profile + +Export the built-in profile and update the token endpoint: + +```shell +openshell provider profile export okta-obo -o yaml > okta-obo.yaml +``` + +Replace: + +- `https://example.okta.com/oauth2/default/v1/token` + +with your real Okta token endpoint, then import the customized profile: + +```shell +openshell provider profile lint -f okta-obo.yaml +openshell provider profile import -f okta-obo.yaml +``` + +## Log In + +Log into the gateway as the human user whose identity should be delegated: + +```shell +openshell gateway login +``` + +## Create the OBO Provider + +Create the provider from the imported profile: + +```shell +openshell provider create \ + --name okta-obo-runtime \ + --type okta-obo +``` + +## Configure Token Exchange + +Configure the delegated credential refresh. When this command runs while you are logged in with an OIDC gateway session, OpenShell captures the current user bearer token as secret refresh material. + +```shell +openshell provider refresh configure okta-obo-runtime \ + --credential-key OKTA_OBO_ACCESS_TOKEN \ + --strategy oauth2-token-exchange \ + --material client_id="$OKTA_OBO_CLIENT_ID" \ + --material client_secret="$OKTA_OBO_CLIENT_SECRET" \ + --material audience="$OKTA_OBO_AUDIENCE" \ + --material scope="$OKTA_OBO_SCOPE" \ + --secret-material-key client_secret +``` + +Check refresh status: + +```shell +openshell provider refresh status okta-obo-runtime \ + --credential-key OKTA_OBO_ACCESS_TOKEN +``` + +## Create the Sandbox and Attach the Provider + +Create the sandbox after configuring the provider: + +```shell +openshell sandbox create --name okta-obo-smoke +``` + +Attach the OBO provider to the sandbox: + +```shell +openshell sandbox provider attach okta-obo-smoke okta-obo-runtime +``` + +## Verify the Delegated Token Path + +Exec into the sandbox and confirm the delegated credential placeholder exists: + +```shell +openshell sandbox exec --name okta-obo-smoke -- /bin/sh -lc 'printenv OKTA_OBO_ACCESS_TOKEN' +``` + +The value should be an OpenShell-managed placeholder rather than a raw token. OpenShell resolves it to the current delegated token when the workload uses the supported authorization path. + + diff --git a/docs/get-started/tutorials/xaa-dev.mdx b/docs/get-started/tutorials/xaa-dev.mdx new file mode 100644 index 000000000..b5c45fe6a --- /dev/null +++ b/docs/get-started/tutorials/xaa-dev.mdx @@ -0,0 +1,148 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Run the XAA.dev Sample XAA Flow" +sidebar-title: "XAA.dev Sample XAA" +slug: "get-started/tutorials/xaa-dev" +description: "Configure the built-in xaa-dev provider profile and prove the sample 2-step XAA flow from inside an OpenShell sandbox." +keywords: "Generative AI, Cybersecurity, Tutorial, XAA, Cross App Access, ID-JAG, Providers, Sandbox" +--- + +Use the built-in `xaa-dev` profile to exercise the sample 2-step Cross App Access flow backed by `xaa.dev`. OpenShell captures the logged-in user's OIDC `id_token` during refresh configuration, exchanges that token for an `ID-JAG`, exchanges the `ID-JAG` for a delegated resource token, and injects the delegated token into the sandbox for outbound API calls. + +After completing this tutorial, you have: + +- A registered `xaa.dev` requesting app connected to the `Todo0 Resource App`. +- A provider that mints `XAA_DEV_ACCESS_TOKEN` through the sample 2-step XAA flow. +- A sandbox that can call the sample `Todo0` resource API with the delegated token. + + +This tutorial documents the working `xaa.dev` sample path. It does not cover a tenant-integrated resource authorization server in your own Okta environment. + + +## Prerequisites + +- A working OpenShell installation with an active gateway. +- A local gateway login flow that can store an OIDC `id_token`. +- `providers_v2_enabled=true` on the target gateway. +- A `xaa.dev` requesting app registered at `https://xaa.dev/developer/register`. + +When you register the sample app, use: + +| Field | Value | +|---|---| +| Application Name | `OpenShell XAA Local` | +| Redirect URI | `http://127.0.0.1:8767/callback` | +| Post-Logout Redirect URI | `http://127.0.0.1:8767/` | +| Resource Connection | `Todo0 Resource App` | + +Record these values from the `xaa.dev` registration and integration guide: + +| Variable | Value | +|---|---| +| `XAA_DEV_REQUESTING_CLIENT_ID` | Main requesting app client ID, for example `client_f8d67a261fab113c`. | +| `XAA_DEV_REQUESTING_CLIENT_SECRET` | Main requesting app client secret. | +| `XAA_DEV_RESOURCE_CLIENT_ID` | Resource client ID, for example `client_-at-todo0`. | +| `XAA_DEV_RESOURCE_CLIENT_SECRET` | Resource client secret from the Step 3 guide. | + +The sample endpoints used by the built-in profile are: + +| Purpose | URL | +|---|---| +| Identity provider token endpoint | `https://idp.xaa.dev/token` | +| Resource authorization server token endpoint | `https://auth.resource.xaa.dev/token` | +| Protected API base | `https://api.resource.xaa.dev` | + + + +## Enable Providers v2 + +Enable profile-backed provider policy on the target gateway: + +```shell +openshell settings set --global --key providers_v2_enabled --value true +``` + +## Log In and Create a Sandbox + +Log in before configuring refresh so OpenShell can capture the authenticated user's `id_token`: + +```shell +openshell gateway login +``` + +Create a sandbox: + +```shell +openshell sandbox create --name xaa-dev-sample +openshell sandbox get xaa-dev-sample +``` + +## Create the Sample Provider + +Create the provider from the built-in profile: + +```shell +openshell provider create \ + --name xaa-dev-runtime \ + --type xaa-dev +``` + +## Configure the 2-Step XAA Refresh + +Configure the requesting-app and resource-app credentials. When this command runs while you are logged in, OpenShell stores the current OIDC `id_token` as secret refresh material for the provider: + +```shell +openshell provider refresh configure xaa-dev-runtime \ + --credential-key XAA_DEV_ACCESS_TOKEN \ + --strategy okta-xaa \ + --material requesting_client_id="$XAA_DEV_REQUESTING_CLIENT_ID" \ + --material requesting_client_secret="$XAA_DEV_REQUESTING_CLIENT_SECRET" \ + --material resource_client_id="$XAA_DEV_RESOURCE_CLIENT_ID" \ + --material resource_client_secret="$XAA_DEV_RESOURCE_CLIENT_SECRET" \ + --material audience="https://auth.resource.xaa.dev" \ + --material resource="https://api.resource.xaa.dev" \ + --material resource_token_url="https://auth.resource.xaa.dev/token" \ + --material scope="todos.read" \ + --secret-material-key requesting_client_secret \ + --secret-material-key resource_client_secret +``` + +Force a refresh and confirm the credential status: + +```shell +openshell provider refresh rotate xaa-dev-runtime \ + --credential-key XAA_DEV_ACCESS_TOKEN + +openshell provider refresh status xaa-dev-runtime \ + --credential-key XAA_DEV_ACCESS_TOKEN +``` + +The status should show `refreshed`. + +## Attach the Provider + +Attach the provider to the sandbox: + +```shell +openshell sandbox provider attach xaa-dev-sample xaa-dev-runtime +``` + +## Verify the Delegated Token Path + +Run the sample API call from inside the sandbox: + +```shell +openshell sandbox exec \ + --name xaa-dev-sample \ + -- /bin/sh -lc 'curl --http1.1 -sS -H "Authorization: Bearer $XAA_DEV_ACCESS_TOKEN" https://api.resource.xaa.dev/api/todos' +``` + +The command should return the sample `Todo0` JSON payload. + + + +## Next Steps + +- Use [Providers v2](/sandboxes/providers-v2) for more detail on profile-backed provider behavior. +- Use [Okta OBO Token Exchange](/get-started/tutorials/okta-obo) if you want the delegated Okta OBO path instead of the `xaa.dev` sample flow. diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 3ac248ff8..dd64a2aef 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -96,6 +96,9 @@ Built-in Providers v2 profiles currently include: | `github` | `source_control` | `GITHUB_TOKEN`, `GH_TOKEN` | | `google-vertex-ai` | `inference` | `GOOGLE_SERVICE_ACCOUNT_KEY`, `GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `GOOGLE_VERTEX_AI_TOKEN`, `VERTEX_AI_TOKEN` | | `nvidia` | `inference` | `NVIDIA_API_KEY` | +| `xaa-dev` | `other` | `XAA_DEV_ACCESS_TOKEN` | + +The built-in `xaa-dev` profile demonstrates the sample 2-step XAA flow and includes the sample identity provider, resource authorization server, and resource API endpoints. For the full setup, see [XAA.dev Sample XAA](/get-started/tutorials/xaa-dev). Export a built-in profile as YAML: diff --git a/proto/openshell.proto b/proto/openshell.proto index f9b64618b..7f4980d90 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -904,6 +904,8 @@ enum ProviderCredentialRefreshStrategy { PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN = 3; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS = 4; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT = 5; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_TOKEN_EXCHANGE = 6; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OKTA_XAA = 7; } message ProviderCredentialRefreshMaterial { diff --git a/providers/okta-obo.yaml b/providers/okta-obo.yaml new file mode 100644 index 000000000..776d053f8 --- /dev/null +++ b/providers/okta-obo.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: okta-obo +display_name: Okta OBO +description: Okta delegated access tokens minted on behalf of the authenticated OpenShell user +category: other +credentials: + - name: obo_access_token + description: Okta delegated access token for downstream APIs + env_vars: [OKTA_OBO_ACCESS_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_token_exchange + token_url: https://example.okta.com/oauth2/default/v1/token + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: client_id + description: Okta OIDC application client ID used for token exchange + required: true + - name: audience + description: Downstream Okta resource audience for the delegated token + required: true + - name: client_secret + description: Okta client secret for confidential token-exchange clients + required: false + secret: true + - name: subject_token + description: Authenticated user bearer token captured during refresh configuration + required: false + secret: true + - name: scope + description: Space-delimited scopes requested for the delegated token + required: false +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/providers/okta-xaa.yaml b/providers/okta-xaa.yaml new file mode 100644 index 000000000..422a9ae06 --- /dev/null +++ b/providers/okta-xaa.yaml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: okta-xaa +display_name: Okta XAA +description: Okta Cross App Access tokens minted from the authenticated OpenShell user's OIDC ID token +category: other +credentials: + - name: xaa_access_token + description: Okta XAA delegated access token for downstream resources + env_vars: [OKTA_XAA_ACCESS_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: okta_xaa + token_url: https://example.okta.com/oauth2/v1/token + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: requesting_client_id + description: XAA requesting app client ID used for the ID token to ID-JAG exchange + required: true + - name: requesting_client_secret + description: XAA requesting app client secret used for the ID token to ID-JAG exchange + required: true + secret: true + - name: subject_token + description: Authenticated user OIDC id_token captured during refresh configuration + required: false + secret: true + - name: resource_client_id + description: XAA resource app client ID used for the ID-JAG to access-token exchange + required: true + - name: resource_client_secret + description: XAA resource app client secret used for the ID-JAG to access-token exchange + required: true + secret: true + - name: audience + description: Okta XAA resource authorization server issuer used as the audience for the ID-JAG exchange + required: true + - name: resource + description: Optional resource URL sent during the ID token to ID-JAG exchange. Defaults to the audience when omitted. + required: false + - name: resource_token_url + description: Optional resource app token endpoint. Defaults to the audience issuer with /oauth2/v1/token. + required: false + - name: scope + description: Space-delimited scopes requested for the delegated token + required: false +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/providers/xaa-dev.yaml b/providers/xaa-dev.yaml new file mode 100644 index 000000000..ed22d887d --- /dev/null +++ b/providers/xaa-dev.yaml @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: xaa-dev +display_name: XAA.dev Sample +description: Sample 2-step XAA flow using idp.xaa.dev and the Todo0 Resource App +category: other +credentials: + - name: xaa_access_token + description: XAA.dev delegated access token for the Todo0 sample resource API + env_vars: [XAA_DEV_ACCESS_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: okta_xaa + token_url: https://idp.xaa.dev/token + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + scopes: [todos.read] + material: + - name: requesting_client_id + description: XAA.dev requesting app client ID used for the ID token to ID-JAG exchange + required: true + - name: requesting_client_secret + description: XAA.dev requesting app client secret used for the ID token to ID-JAG exchange + required: true + secret: true + - name: subject_token + description: OIDC id_token captured during refresh configuration for the sample XAA flow + required: false + secret: true + - name: resource_client_id + description: Resource client ID for the Todo0 sample resource authorization server + required: true + - name: resource_client_secret + description: Resource client secret for the Todo0 sample resource authorization server + required: true + secret: true + - name: audience + description: Resource authorization server issuer used when minting the ID-JAG + required: true + - name: resource + description: Optional protected resource URL requested during the ID-JAG exchange + required: false + - name: resource_token_url + description: Optional resource authorization server token endpoint + required: false +endpoints: + - host: idp.xaa.dev + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: auth.resource.xaa.dev + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: api.resource.xaa.dev + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: + - /usr/bin/curl + - /usr/local/bin/curl