diff --git a/crates/openshell-providers/src/discovery.rs b/crates/openshell-providers/src/discovery.rs index 79d6fb091..96ed76466 100644 --- a/crates/openshell-providers/src/discovery.rs +++ b/crates/openshell-providers/src/discovery.rs @@ -96,6 +96,7 @@ mod tests { header_name: String::new(), query_param: String::new(), refresh: None, + path_template: String::new(), }, CredentialProfile { name: "secondary".to_string(), @@ -106,6 +107,7 @@ mod tests { header_name: String::new(), query_param: String::new(), refresh: None, + path_template: String::new(), }, ], endpoints: Vec::new(), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 25c750e63..b24ed977e 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -81,6 +81,8 @@ pub struct CredentialProfile { pub query_param: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub refresh: Option, + #[serde(default)] + pub path_template: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -280,6 +282,7 @@ impl ProviderTypeProfile { .refresh .as_ref() .map(credential_refresh_from_proto), + path_template: credential.path_template.clone(), }) .collect(), endpoints: profile.endpoints.iter().map(endpoint_from_proto).collect(), @@ -325,6 +328,7 @@ impl ProviderTypeProfile { header_name: credential.header_name.clone(), query_param: credential.query_param.clone(), refresh: credential.refresh.as_ref().map(credential_refresh_to_proto), + path_template: credential.path_template.clone(), }) .collect(), endpoints: self.endpoints.iter().map(endpoint_to_proto).collect(), @@ -966,6 +970,16 @@ pub fn validate_profile_set( )); } } + "path" => { + if credential.path_template.trim().is_empty() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.path_template", + "path_template is required for path auth", + )); + } + } "query" => { if credential.query_param.trim().is_empty() { diagnostics.push(ProfileValidationDiagnostic::error( @@ -1231,6 +1245,57 @@ credentials: assert!(exported.contains("client_secret")); } + #[test] + fn credential_fields_round_trip_through_proto_and_yaml() { + let profile = parse_profile_yaml( + r" +id: multi-auth +display_name: Multi Auth +credentials: + - name: basic_cred + env_vars: [BASIC_TOKEN] + auth_style: basic + - name: bearer_cred + env_vars: [BEARER_TOKEN] + auth_style: bearer + header_name: authorization + - name: query_cred + env_vars: [QUERY_TOKEN] + auth_style: query + query_param: api_key + - name: path_cred + env_vars: [PATH_TOKEN] + auth_style: path + path_template: /v1/{key}/resources +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("multi-auth.yaml".to_string(), profile.clone())]); + assert!( + diagnostics.is_empty(), + "unexpected diagnostics: {diagnostics:?}" + ); + + assert_eq!(profile.credentials[1].header_name, "authorization"); + assert_eq!(profile.credentials[2].query_param, "api_key"); + assert_eq!(profile.credentials[3].path_template, "/v1/{key}/resources"); + + let from_proto = ProviderTypeProfile::from_proto(&profile.to_proto()); + assert_eq!(from_proto.credentials[1].header_name, "authorization"); + assert_eq!(from_proto.credentials[2].query_param, "api_key"); + assert_eq!( + from_proto.credentials[3].path_template, + "/v1/{key}/resources" + ); + + let exported = profile_to_yaml(&from_proto).expect("yaml"); + let reparsed = parse_profile_yaml(&exported).expect("re-parse"); + assert_eq!(reparsed.credentials[1].header_name, "authorization"); + assert_eq!(reparsed.credentials[2].query_param, "api_key"); + assert_eq!(reparsed.credentials[3].path_template, "/v1/{key}/resources"); + } + #[test] fn profile_json_round_trip_preserves_compact_dto_shape() { let profile = get_default_profile("github").expect("github profile"); @@ -1338,6 +1403,9 @@ credentials: - name: api_key env_vars: [BROKEN_TOKEN, ""] auth_style: unknown + - name: path_key + env_vars: [PATH_TOKEN] + auth_style: path discovery: credentials: [api_key, missing_key] endpoints: @@ -1358,6 +1426,7 @@ binaries: ["", /usr/bin/broken] assert!(messages.contains(&"duplicate credential env var 'BROKEN_TOKEN'")); assert!(messages.contains(&"credential env var must not be empty")); assert!(messages.contains(&"query_param is required for query auth")); + assert!(messages.contains(&"path_template is required for path auth")); assert!(messages.contains(&"unsupported auth_style: unknown")); assert!(messages.contains(&"unknown discovery credential: missing_key")); assert!( diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 3ddaae037..465626d59 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1538,6 +1538,7 @@ mod tests { auth_style: "bearer".to_string(), header_name: "authorization".to_string(), query_param: String::new(), + path_template: String::new(), refresh: Some(ProviderCredentialRefresh { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, token_url: "https://auth.example.com/token".to_string(), @@ -1595,6 +1596,7 @@ mod tests { header_name: "authorization".to_string(), query_param: String::new(), refresh: None, + path_template: String::new(), } } @@ -2927,6 +2929,7 @@ mod tests { auth_style: "bearer".to_string(), header_name: "authorization".to_string(), query_param: String::new(), + path_template: String::new(), refresh: Some(ProviderCredentialRefresh { strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, diff --git a/proto/openshell.proto b/proto/openshell.proto index 90d1594f7..76c1462d5 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -887,6 +887,7 @@ message ProviderProfileCredential { string header_name = 6; string query_param = 7; ProviderCredentialRefresh refresh = 8; + string path_template = 9; } enum ProviderCredentialRefreshStrategy {