Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ enum OutputFormat {
enum CliProviderRefreshStrategy {
Oauth2RefreshToken,
Oauth2ClientCredentials,
Oauth2TokenExchange,
GoogleServiceAccountJwt,
}

Expand All @@ -668,6 +669,7 @@ impl CliProviderRefreshStrategy {
match self {
Self::Oauth2RefreshToken => "oauth2_refresh_token",
Self::Oauth2ClientCredentials => "oauth2_client_credentials",
Self::Oauth2TokenExchange => "oauth2_token_exchange",
Self::GoogleServiceAccountJwt => "google_service_account_jwt",
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5004,6 +5004,7 @@ fn provider_refresh_strategy(strategy: &str) -> Result<ProviderCredentialRefresh
"oauth2_client_credentials" => {
Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials)
}
"oauth2_token_exchange" => Ok(ProviderCredentialRefreshStrategy::Oauth2TokenExchange),
"google_service_account_jwt" => {
Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
}
Expand Down Expand Up @@ -5058,6 +5059,7 @@ 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::GoogleServiceAccountJwt => "google_service_account_jwt",
ProviderCredentialRefreshStrategy::Unspecified => "unspecified",
}
Expand Down
140 changes: 140 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1708,6 +1797,57 @@ 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 provider_profile_lint_from_directory_reports_parse_errors_without_importing() {
let ts = run_server().await;
Expand Down
82 changes: 72 additions & 10 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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"),
];

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -372,6 +373,7 @@ impl CredentialRefreshProfile {
self.strategy,
ProviderCredentialRefreshStrategy::Oauth2RefreshToken
| ProviderCredentialRefreshStrategy::Oauth2ClientCredentials
| ProviderCredentialRefreshStrategy::Oauth2TokenExchange
| ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt
)
}
Expand Down Expand Up @@ -530,6 +532,7 @@ pub fn provider_refresh_strategy_from_yaml(raw: &str) -> Option<ProviderCredenti
"oauth2_client_credentials" => {
Some(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials)
}
"oauth2_token_exchange" => Some(ProviderCredentialRefreshStrategy::Oauth2TokenExchange),
"google_service_account_jwt" => {
Some(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
}
Expand All @@ -546,6 +549,7 @@ 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::GoogleServiceAccountJwt => "google_service_account_jwt",
ProviderCredentialRefreshStrategy::Unspecified => "unspecified",
}
Expand Down Expand Up @@ -1172,6 +1176,59 @@ mod tests {
assert_eq!(proto.binaries.len(), 4);
}

#[test]
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!(
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::<Vec<_>>();
assert_eq!(
material_names,
vec![
"client_id",
"audience",
"client_secret",
"subject_token",
"scope"
]
);
assert!(
refresh
.material
.iter()
.find(|material| material.name == "subject_token")
.is_some_and(|material| !material.required && material.secret)
);
assert!(
refresh
.material
.iter()
.find(|material| material.name == "audience")
.is_some_and(|material| material.required)
);
}

#[test]
fn credential_env_vars_are_deduplicated_in_profile_order() {
let profile = get_default_profile("claude-code").expect("claude-code profile");
Expand Down Expand Up @@ -1221,19 +1278,24 @@ mod tests {

#[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
for (required, strategy) in [
(false, "oauth2_refresh_token"),
(true, "oauth2_token_exchange"),
] {
let profile = parse_profile_yaml(&format!(
r"
id: refresh-profile
display_name: Refresh Profile
credentials:
- name: access_token
required: false
required: {required}
refresh:
strategy: oauth2_refresh_token
",
)
.expect("profile");
assert!(optional_refresh_profile.allows_gateway_refresh_bootstrap());
strategy: {strategy}
"
))
.expect("profile");
assert!(profile.allows_gateway_refresh_bootstrap());
}

let mixed_required_profile = parse_profile_yaml(
r"
Expand Down
22 changes: 17 additions & 5 deletions crates/openshell-server/src/auth/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ 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);

/// 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 "))
}

impl OidcClaims {
/// Extract roles from the JWT claims using a dot-separated path.
///
Expand Down Expand Up @@ -372,11 +388,7 @@ impl Authenticator for OidcAuthenticator {
headers: &http::HeaderMap,
_path: &str,
) -> Result<Option<Principal>, 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);
};

Expand Down
Loading
Loading