diff --git a/src/catalog.rs b/src/catalog.rs index 1d03734..7d7df1d 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -347,6 +347,28 @@ impl Catalog { }; crate::providers::Provider::from_document(&document, scheme) } + /// Resolves whichever kind of security scheme (OAuth or static apiKey) + /// the platform's composed document declares, generically. Callers that + /// only work with one kind (e.g. tenant identity, which is OAuth-only) + /// keep using `oauth_provider` directly. + pub fn security_scheme( + &self, + platform: &str, + ) -> Result { + let source = self.get(platform).ok_or("unknown catalog platform")?; + let document = serde_yaml::from_str(source).map_err(|_| "invalid catalog document")?; + let selection = self.selections.get(platform); + let read_selected = |key: &str| -> Result, String> { + match selection.and_then(|selection| selection.get(key)) { + Some(Value::String(scheme)) => Ok(Some(scheme.as_str())), + Some(_) => Err(format!("{key} selection must be a string")), + None => Ok(None), + } + }; + let oauth_scheme = read_selected("oauthSecurityScheme")?; + let api_key_scheme = read_selected("apiKeySecurityScheme")?; + crate::providers::SecurityScheme::from_document(&document, oauth_scheme, api_key_scheme) + } /// Returns an explicitly catalog-trusted identity operation. The OpenAPI /// extension alone is descriptive and is never sufficient for tenancy. pub fn tenant_identity( @@ -659,6 +681,45 @@ mod tests { assert_eq!(headers(&doc).unwrap().len(), 2); } + /// Composed the same way `Catalog::load` composes any platform: the base + /// OAD plus each overlay's actions applied in order. Captured as a + /// checked-in fixture rather than fetched live, matching this file's + /// existing pinned-fixture tests; move to the network-backed + /// `identity_catalog_tests.rs` pattern (a real `Catalog::load` against + /// the now-published `localthought/overlays` clockify entry) if that + /// coverage is wanted later. + #[test] + fn composed_clockify_fixture_declares_an_api_key_scheme_and_pagination() { + let document: Value = + serde_yaml::from_str(include_str!("../tests/fixtures/clockify-composed.yaml")).unwrap(); + let catalog = Catalog::from_test_document("clockify", document, serde_json::json!({})); + let scheme = catalog.security_scheme("clockify").unwrap(); + match scheme { + crate::providers::SecurityScheme::ApiKey(scheme) => { + assert_eq!(scheme.name, "X-Api-Key"); + assert_eq!(scheme.location, crate::providers::ApiKeyLocation::Header); + } + crate::providers::SecurityScheme::OAuth(_) => panic!("expected an apiKey scheme"), + } + assert!(catalog + .allows( + "clockify", + "GET", + "/api/v1/workspaces/ws1/user/u1/time-entries" + ) + .is_some()); + assert_eq!( + catalog + .required_headers( + "clockify", + "GET", + "/api/v1/workspaces/ws1/user/u1/time-entries" + ) + .unwrap(), + Vec::<(String, String)>::new() + ); + } + #[test] fn validate_request_checks_required_query_parameters_enums_and_body() { use serde_json::json; diff --git a/src/connect.rs b/src/connect.rs index 420f075..d112e53 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -218,8 +218,15 @@ pub async fn page( Ok(target) => target, Err(message) => return error(message), }; - if !state.catalog.names().contains(&request.platform) - || Provider::configured(&state.catalog, &request.platform).is_err() + if !state.catalog.names().contains(&request.platform) { + return error("This platform is not available for connection"); + } + let scheme = match state.catalog.security_scheme(&request.platform) { + Ok(scheme) => scheme, + Err(_) => return error("This platform is not available for connection"), + }; + if matches!(scheme, crate::providers::SecurityScheme::OAuth(_)) + && Provider::configured(&state.catalog, &request.platform).is_err() { return error("This platform is not available for connection"); } @@ -260,6 +267,7 @@ pub async fn page( .unwrap_or(&state.app_auth_label), bootstrap_identity, &api_login_platforms, + matches!(scheme, crate::providers::SecurityScheme::ApiKey(_)), )), )); // Keep the consent form's same-origin POST attributable while sending no @@ -268,20 +276,32 @@ pub async fn page( .headers_mut() .insert(header::REFERRER_POLICY, "same-origin".parse().unwrap()); // Chrome applies form-action to redirects too, including an already-authorized - // provider returning straight through its callback to the hub. - let provider = state.catalog.oauth_provider(&request.platform).unwrap(); - let provider_origin = Url::parse(&provider.authorization_url) - .unwrap() - .origin() - .ascii_serialization(); - let policy = format!( - "{}; form-action 'self' {} {}", - response.headers()["content-security-policy"] - .to_str() - .unwrap(), - provider_origin, - target.origin().ascii_serialization() - ); + // provider returning straight through its callback to the hub. An apiKey + // platform never redirects to a third party, so only the caller's own + // redirect_uri origin needs allowing. + let policy = match &scheme { + crate::providers::SecurityScheme::OAuth(provider) => { + let provider_origin = Url::parse(&provider.authorization_url) + .unwrap() + .origin() + .ascii_serialization(); + format!( + "{}; form-action 'self' {} {}", + response.headers()["content-security-policy"] + .to_str() + .unwrap(), + provider_origin, + target.origin().ascii_serialization() + ) + } + crate::providers::SecurityScheme::ApiKey(_) => format!( + "{}; form-action 'self' {}", + response.headers()["content-security-policy"] + .to_str() + .unwrap(), + target.origin().ascii_serialization() + ), + }; response .headers_mut() .insert("content-security-policy", policy.parse().unwrap()); @@ -291,6 +311,8 @@ pub async fn page( #[derive(Deserialize)] pub struct Approval { csrf: String, + #[serde(default)] + api_key: Option, } pub async fn authorize( @@ -342,6 +364,54 @@ pub async fn authorize( { return error("Connection approval expired or already used"); } + // Only an OAuth platform redirects to a third party from here; an apiKey + // platform already has everything it needs (the submitted key) and + // completes the handoff directly, generically, without ever involving + // `oauth::begin`/`oauth::callback`. + match state.catalog.security_scheme(&consent.request.platform) { + Ok(crate::providers::SecurityScheme::ApiKey(_)) => { + let Some(tenant_id) = user.as_ref().map(|user| user.subject.clone()) else { + return error( + "This platform cannot establish a tenant identity; log in before connecting", + ); + }; + let Some(key) = approval + .api_key + .as_deref() + .map(str::trim) + .filter(|key| (4..=512).contains(&key.len())) + else { + return error("Enter a valid API key"); + }; + let credential = crate::proxy::StoredCredential::ApiKey { + provider: consent.request.platform.clone(), + tenant_id: tenant_id.clone(), + user_id: consent.request.user_id.clone(), + key: key.to_owned(), + }; + let Ok(envelope) = security.seal( + &serde_json::to_vec(&credential).unwrap(), + b"connection-credential-v1", + ) else { + return error("Could not complete connection"); + }; + let context = OAuthContext { + request: consent.request.clone(), + binding: random(), + mode: BootstrapMode::ExistingTenant { + tenant_id: tenant_id.clone(), + }, + }; + let redirect_uri = context.request.redirect_uri.clone(); + let code = match handoff(security, &context, &tenant_id, &envelope).await { + Ok(code) => code, + Err(()) => return error("Could not complete connection"), + }; + return finish_with_connection_code(clear_consent(jar), &redirect_uri, &code); + } + Ok(crate::providers::SecurityScheme::OAuth(_)) => {} + Err(_) => return error("This platform is not available for connection"), + } let context = OAuthContext { request: consent.request.clone(), binding: random(), @@ -398,6 +468,24 @@ pub fn clear_provider_cookie(jar: PrivateCookieJar) -> PrivateCookieJar { jar.remove(Cookie::build(PROVIDER_COOKIE).path("/").build()) } +/// Redirects the browser back to `redirect_uri` with a rotating handoff +/// `connection_code` appended. Shared by the OAuth callback and the apiKey +/// `authorize` branch below; clearing the OAuth provider-binding cookie is a +/// no-op for a flow (like apiKey) that never set it. +pub(crate) fn finish_with_connection_code( + jar: PrivateCookieJar, + redirect_uri: &str, + code: &str, +) -> Response { + let Ok(mut redirect) = Url::parse(redirect_uri) else { + return error("Could not complete connection"); + }; + redirect + .query_pairs_mut() + .append_pair("connection_code", code); + (clear_provider_cookie(jar), Redirect::to(redirect.as_str())).into_response() +} + pub async fn handoff( security: &Security, context: &OAuthContext, @@ -621,6 +709,7 @@ mod tests { HeaderMap::new(), Form(Approval { csrf: consent.csrf.clone(), + api_key: None, }), ) .await; @@ -1089,6 +1178,7 @@ mod tests { HeaderMap::new(), Form(Approval { csrf: "wrong".into(), + api_key: None, }), ) .await; @@ -1108,6 +1198,7 @@ mod tests { HeaderMap::new(), Form(Approval { csrf: "valid".into(), + api_key: None, }), ) .await; @@ -1117,6 +1208,172 @@ mod tests { assert!(String::from_utf8_lossy(&body).contains("cannot establish a tenant identity")); } + fn api_key_catalog() -> crate::catalog::Catalog { + crate::catalog::Catalog::from_test_document( + "clockify", + serde_json::json!({ + "servers": [{"url": "https://api.clockify.me/v1"}], + "components": {"securitySchemes": {"clockifyApiKey": { + "type": "apiKey", "in": "header", "name": "X-Api-Key" + }}}, + "security": [{"clockifyApiKey": []}], + "paths": {"/workspaces": {"get": {}}} + }), + serde_json::json!({}), + ) + } + + fn api_key_request() -> Request { + Request { + platform: "clockify".into(), + redirect_uri: + "https://hub.example/app/integrations?integration_state=state&platform=clockify" + .into(), + user_id: "did:ad:agent:test".into(), + code_challenge: pkce_challenge(&"a".repeat(43)).unwrap(), + code_challenge_method: "S256".into(), + credentials: Credentials::Connection, + } + } + + #[tokio::test] + #[ignore = "requires TEST_DATABASE_URL; CI runs with --include-ignored"] + async fn postgres_api_key_authorize_seals_the_submitted_key_without_a_provider_redirect() { + let db = std::env::var("TEST_DATABASE_URL") + .expect("set TEST_DATABASE_URL to an isolated test database"); + let security = + Security::connect(&db, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", vec![]) + .await + .unwrap(); + let mut s = state(Some(security.clone())); + s.catalog = api_key_catalog(); + let user = session::SessionUser::new( + "fixture-clockify-tenant".into(), + "fixture@example.com".into(), + "Fixture".into(), + None, + ); + let session_jar = session::set_session(PrivateCookieJar::new(s.key.clone()), &user); + + // A wrong CSRF is rejected before the nonce (and thus the key) is ever consulted. + let consent_a = Consent { + request: api_key_request(), + csrf: random(), + expires: crate::proxy::now_unix() + 600, + }; + let jar_a = session_jar.clone().add(private_cookie( + CONSENT_COOKIE, + serde_json::to_string(&consent_a).unwrap(), + )); + assert_eq!( + authorize( + State(s.clone()), + jar_a.clone(), + HeaderMap::new(), + Form(Approval { + csrf: "wrong".into(), + api_key: Some("clockify-secret".into()), + }) + ) + .await + .status(), + StatusCode::BAD_REQUEST + ); + + // A correct CSRF with a blank key is rejected (and burns that consent's nonce). + let blank_key_result = authorize( + State(s.clone()), + jar_a.clone(), + HeaderMap::new(), + Form(Approval { + csrf: consent_a.csrf.clone(), + api_key: Some(" ".into()), + }), + ) + .await; + assert_eq!(blank_key_result.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(blank_key_result.into_body(), 16384) + .await + .unwrap(); + assert!(String::from_utf8_lossy(&body).contains("valid API key")); + + // A fresh consent, correct CSRF and a real key completes without ever + // touching `oauth::begin`/`oauth::callback` or a provider redirect. + let consent_b = Consent { + request: api_key_request(), + csrf: random(), + expires: crate::proxy::now_unix() + 600, + }; + let jar_b = session_jar.add(private_cookie( + CONSENT_COOKIE, + serde_json::to_string(&consent_b).unwrap(), + )); + let response = authorize( + State(s.clone()), + jar_b, + HeaderMap::new(), + Form(Approval { + csrf: consent_b.csrf.clone(), + api_key: Some("clockify-secret".into()), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response.headers()[header::LOCATION].to_str().unwrap(); + assert!(location.starts_with( + "https://hub.example/app/integrations?integration_state=state&platform=clockify" + )); + let redirect = Url::parse(location).unwrap(); + let handoff_code = redirect + .query_pairs() + .find(|(name, _)| name == "connection_code") + .map(|(_, value)| value.into_owned()) + .unwrap(); + + let redeemed = redeem( + State(s.clone()), + Json(Redemption { + code: handoff_code, + code_verifier: "a".repeat(43), + }), + ) + .await; + assert_eq!(redeemed.status(), StatusCode::OK); + let body = axum::body::to_bytes(redeemed.into_body(), 16384) + .await + .unwrap(); + assert!(!String::from_utf8_lossy(&body).contains("clockify-secret")); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["platform"], "clockify"); + let connection_code = body["connection_code"].as_str().unwrap(); + let envelope = security + .take_connection_code(connection_code) + .await + .unwrap() + .unwrap(); + let plaintext = security + .open(&envelope, b"connection-credential-v1") + .unwrap(); + let credential: crate::proxy::StoredCredential = + serde_json::from_slice(&plaintext).unwrap(); + match credential { + crate::proxy::StoredCredential::ApiKey { + provider, + tenant_id, + user_id, + key, + } => { + assert_eq!(provider, "clockify"); + assert_eq!(tenant_id, "fixture-clockify-tenant"); + assert_eq!(user_id, "did:ad:agent:test"); + assert_eq!(key, "clockify-secret"); + } + crate::proxy::StoredCredential::OAuth { .. } => { + panic!("expected an apiKey credential") + } + } + } + #[tokio::test] #[ignore = "requires TEST_DATABASE_URL; CI runs with --include-ignored"] async fn postgres_handoff_is_pkce_bound_single_use_expiring_and_grant_scoped() { @@ -1335,7 +1592,8 @@ mod tests { jar.clone(), HeaderMap::new(), Form(Approval { - csrf: "wrong".into() + csrf: "wrong".into(), + api_key: None, }) ) .await @@ -1350,7 +1608,8 @@ mod tests { jar.clone(), foreign, Form(Approval { - csrf: consent.csrf.clone() + csrf: consent.csrf.clone(), + api_key: None, }) ) .await @@ -1365,7 +1624,8 @@ mod tests { jar.clone(), opaque, Form(Approval { - csrf: consent.csrf.clone() + csrf: consent.csrf.clone(), + api_key: None, }) ) .await @@ -1378,6 +1638,7 @@ mod tests { HeaderMap::new(), Form(Approval { csrf: consent.csrf.clone(), + api_key: None, }), ) .await; @@ -1472,7 +1733,8 @@ mod tests { jar, HeaderMap::new(), Form(Approval { - csrf: consent.csrf.clone() + csrf: consent.csrf.clone(), + api_key: None, }) ) .await @@ -1489,7 +1751,10 @@ mod tests { State(s), jar, HeaderMap::new(), - Form(Approval { csrf: consent.csrf }) + Form(Approval { + csrf: consent.csrf, + api_key: None + }) ) .await .status(), diff --git a/src/oauth.rs b/src/oauth.rs index 687fcf9..1bad592 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -11,7 +11,7 @@ use axum::{ use axum_extra::extract::PrivateCookieJar; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use rand::RngCore; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use sha2::{Digest, Sha256}; use url::Url; @@ -40,16 +40,6 @@ struct Token { #[serde(default)] expires_in: Option, } -#[derive(Serialize)] -struct Credential { - provider: String, - tenant_id: String, - user_id: String, - access_token: String, - refresh_token: Option, - expires_at: Option, -} - fn random() -> String { let mut bytes = [0u8; 32]; rand::thread_rng().fill_bytes(&mut bytes); @@ -410,7 +400,7 @@ async fn callback_response( } None => (tenant_id_from_state.clone(), jar), }; - let credential = Credential { + let credential = crate::proxy::StoredCredential::OAuth { provider: name.clone(), tenant_id: tenant_id.clone(), user_id: user_id.clone(), @@ -444,17 +434,7 @@ async fn callback_response( code } }; - let Ok(mut redirect) = Url::parse(&redirect_uri) else { - return error(); - }; - redirect - .query_pairs_mut() - .append_pair("connection_code", &code); - ( - crate::connect::clear_provider_cookie(jar), - Redirect::to(redirect.as_str()), - ) - .into_response() + crate::connect::finish_with_connection_code(jar, &redirect_uri, &code) } #[cfg(test)] diff --git a/src/providers.rs b/src/providers.rs index f6db384..cc281ee 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -21,6 +21,102 @@ struct TokenOperation { requires_basic: bool, } +/// A static-secret OpenAPI `apiKey` security scheme: a declared parameter +/// name/location, filled in at proxy time from a browser-submitted secret. +/// There is no scopes concept and nothing to exchange or refresh. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApiKeyScheme { + pub name: String, + pub location: ApiKeyLocation, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApiKeyLocation { + Header, + Query, + Cookie, +} + +impl ApiKeyScheme { + /// Read API-key capabilities from the composed document, never from platform names. + pub fn from_document(document: &Value, selected_scheme: Option<&str>) -> Result { + let schemes = document + .pointer("/components/securitySchemes") + .and_then(Value::as_object) + .ok_or("missing security schemes")?; + let candidates: Vec<_> = schemes + .iter() + .filter(|(_, scheme)| scheme.get("type").and_then(Value::as_str) == Some("apiKey")) + .collect(); + let (_, scheme) = match selected_scheme { + Some(selected) => candidates + .iter() + .find(|(name, _)| name.as_str() == selected) + .copied() + .ok_or("selected API key security scheme is not an apiKey scheme")?, + None => match candidates.as_slice() { + [candidate] => *candidate, + _ => return Err( + "apiKeySecurityScheme selection is required when multiple apiKey schemes exist" + .into(), + ), + }, + }; + let name = scheme + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + .ok_or("apiKey scheme must declare a non-empty name")?; + let location = match scheme.get("in").and_then(Value::as_str) { + Some("header") => ApiKeyLocation::Header, + Some("query") => ApiKeyLocation::Query, + Some("cookie") => ApiKeyLocation::Cookie, + _ => return Err("apiKey scheme must declare a supported 'in' location".into()), + }; + Ok(Self { + name: name.to_owned(), + location, + }) + } +} + +/// Which kind of credential a catalog platform's composed document declares. +/// Resolved generically from the document's `securitySchemes`, never from the +/// platform's name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SecurityScheme { + OAuth(Provider), + ApiKey(ApiKeyScheme), +} + +impl SecurityScheme { + pub fn from_document( + document: &Value, + oauth_selected: Option<&str>, + api_key_selected: Option<&str>, + ) -> Result { + let schemes = document + .pointer("/components/securitySchemes") + .and_then(Value::as_object) + .ok_or("missing security schemes")?; + let types: BTreeSet<&str> = schemes + .values() + .filter_map(|scheme| scheme.get("type").and_then(Value::as_str)) + .collect(); + match (types.contains("oauth2"), types.contains("apiKey")) { + (true, false) => Provider::from_document(document, oauth_selected).map(Self::OAuth), + (false, true) => { + ApiKeyScheme::from_document(document, api_key_selected).map(Self::ApiKey) + } + (true, true) => Err( + "platform declares both oauth2 and apiKey security schemes; mixed-kind catalogs are not supported" + .into(), + ), + (false, false) => Err("no supported security scheme found".into()), + } + } +} + impl Provider { pub fn with_scopes(mut self, scopes: Vec) -> Self { self.scopes = scopes; @@ -1131,6 +1227,82 @@ mod tests { assert!(ClientAuth::parse("unknown").is_err()); } + fn api_key_document() -> Value { + serde_json::json!({"components":{"securitySchemes":{"clockifyApiKey":{ + "type":"apiKey","in":"header","name":"X-Api-Key"}}}, + "security":[{"clockifyApiKey":[]}], + "paths":{"/workspaces":{"get":{}}}}) + } + + #[test] + fn api_key_scheme_reads_declared_name_and_location() { + let scheme = ApiKeyScheme::from_document(&api_key_document(), None).unwrap(); + assert_eq!(scheme.name, "X-Api-Key"); + assert_eq!(scheme.location, ApiKeyLocation::Header); + } + + #[test] + fn api_key_scheme_supports_query_and_cookie_locations() { + for (location, expected) in [ + ("query", ApiKeyLocation::Query), + ("cookie", ApiKeyLocation::Cookie), + ] { + let mut doc = api_key_document(); + doc["components"]["securitySchemes"]["clockifyApiKey"]["in"] = location.into(); + assert_eq!( + ApiKeyScheme::from_document(&doc, None).unwrap().location, + expected + ); + } + } + + #[test] + fn api_key_scheme_rejects_missing_name_unsupported_location_and_ambiguous_selection() { + let mut doc = api_key_document(); + doc["components"]["securitySchemes"]["clockifyApiKey"]["name"] = "".into(); + assert!(ApiKeyScheme::from_document(&doc, None).is_err()); + + let mut doc = api_key_document(); + doc["components"]["securitySchemes"]["clockifyApiKey"]["in"] = "body".into(); + assert!(ApiKeyScheme::from_document(&doc, None).is_err()); + + let mut doc = api_key_document(); + doc["components"]["securitySchemes"]["second"] = + doc["components"]["securitySchemes"]["clockifyApiKey"].clone(); + assert!(ApiKeyScheme::from_document(&doc, None).is_err()); + assert!(ApiKeyScheme::from_document(&doc, Some("missing")).is_err()); + assert_eq!( + ApiKeyScheme::from_document(&doc, Some("second")) + .unwrap() + .name, + "X-Api-Key" + ); + } + + #[test] + fn security_scheme_dispatches_generically_on_declared_type() { + assert!(matches!( + SecurityScheme::from_document(&document(), None, None).unwrap(), + SecurityScheme::OAuth(_) + )); + assert!(matches!( + SecurityScheme::from_document(&api_key_document(), None, None).unwrap(), + SecurityScheme::ApiKey(_) + )); + } + + #[test] + fn security_scheme_rejects_mixed_or_absent_scheme_types() { + let mut mixed = document(); + mixed["components"]["securitySchemes"]["clockifyApiKey"] = + api_key_document()["components"]["securitySchemes"]["clockifyApiKey"].clone(); + assert!(SecurityScheme::from_document(&mixed, None, None).is_err()); + + let neither = serde_json::json!({"components":{"securitySchemes":{"basic":{ + "type":"http","scheme":"basic"}}}}); + assert!(SecurityScheme::from_document(&neither, None, None).is_err()); + } + #[test] fn composed_notion_fixture_builds_json_token_request() { let document: Value = diff --git a/src/proxy.rs b/src/proxy.rs index fe1f28a..8b8a191 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -222,14 +222,46 @@ pub async fn proxy(State(state): State, headers: HeaderMap) -> Respons } } +/// The credential sealed into a connection code, opaque to the +/// PKCE/handoff/rotation machinery and only interpreted here and where it's +/// minted (`oauth.rs`'s callback, `connect.rs`'s apiKey `authorize` branch). #[derive(Deserialize, Serialize)] -struct Credential { - provider: String, - tenant_id: String, - user_id: String, - access_token: String, - refresh_token: Option, - expires_at: Option, +#[serde(tag = "kind")] +pub(crate) enum StoredCredential { + #[serde(rename = "oauth")] + OAuth { + provider: String, + tenant_id: String, + user_id: String, + access_token: String, + refresh_token: Option, + expires_at: Option, + }, + #[serde(rename = "api_key")] + ApiKey { + provider: String, + tenant_id: String, + user_id: String, + key: String, + }, +} + +impl StoredCredential { + fn provider(&self) -> &str { + match self { + Self::OAuth { provider, .. } | Self::ApiKey { provider, .. } => provider, + } + } + fn tenant_id(&self) -> &str { + match self { + Self::OAuth { tenant_id, .. } | Self::ApiKey { tenant_id, .. } => tenant_id, + } + } + fn user_id(&self) -> &str { + match self { + Self::OAuth { user_id, .. } | Self::ApiKey { user_id, .. } => user_id, + } + } } #[derive(Deserialize)] @@ -241,30 +273,38 @@ struct RefreshToken { expires_in: Option, } -async fn refresh_if_needed(state: &AppState, credential: &mut Credential) -> Result<(), ()> { - if credential - .expires_at - .is_none_or(|expires| expires > now() + 30) - { +async fn refresh_if_needed(state: &AppState, credential: &mut StoredCredential) -> Result<(), ()> { + let StoredCredential::OAuth { + provider, + access_token, + refresh_token, + expires_at, + .. + } = credential + else { + // A static API key has nothing to refresh. + return Ok(()); + }; + if expires_at.is_none_or(|expires| expires > now() + 30) { return Ok(()); } - let refresh_token = credential.refresh_token.as_deref().ok_or(())?; - let provider = crate::providers::Provider::configured(&state.catalog, &credential.provider) - .map_err(|_| ())?; + let refresh_token_value = refresh_token.as_deref().ok_or(())?; + let configured = + crate::providers::Provider::configured(&state.catalog, provider).map_err(|_| ())?; #[cfg(test)] - let provider = { - let mut provider = provider; + let configured = { + let mut configured = configured; if let Some(upstream) = &state.test_upstream { - provider.provider.token_url = format!("{}/token", upstream.trim_end_matches('/')); + configured.provider.token_url = format!("{}/token", upstream.trim_end_matches('/')); } - provider + configured }; - let response = provider + let response = configured .token_request( &state.http_client, &[ ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), + ("refresh_token", refresh_token_value), ], ) .send() @@ -273,14 +313,23 @@ async fn refresh_if_needed(state: &AppState, credential: &mut Credential) -> Res .error_for_status() .map_err(|_| ())?; let token = response.json::().await.map_err(|_| ())?; - credential.access_token = token.access_token; + *access_token = token.access_token; if token.refresh_token.is_some() { - credential.refresh_token = token.refresh_token; + *refresh_token = token.refresh_token; } - credential.expires_at = token.expires_in.map(|seconds| now() + seconds); + *expires_at = token.expires_in.map(|seconds| now() + seconds); Ok(()) } +/// How to attach a resolved credential to the outbound upstream request. +/// `None` covers query-located API keys, already appended to the target URL +/// before the request is built. +enum CredentialInjection { + Bearer(String), + Header { name: String, value: String }, + None, +} + pub async fn forward( Path(path): Path, RawQuery(query): RawQuery, @@ -331,11 +380,11 @@ pub async fn forward( let Some(plaintext) = security.open(&envelope, b"connection-credential-v1") else { return (StatusCode::UNAUTHORIZED, "invalid connection code").into_response(); }; - let Ok(mut credential) = serde_json::from_slice::(&plaintext) else { + let Ok(mut credential) = serde_json::from_slice::(&plaintext) else { return (StatusCode::UNAUTHORIZED, "invalid connection code").into_response(); }; - if credential.provider != platform - || security.is_revoked(&credential.tenant_id, &credential.user_id) + if credential.provider() != platform + || security.is_revoked(credential.tenant_id(), credential.user_id()) { return (StatusCode::FORBIDDEN, "credential is not permitted").into_response(); } @@ -377,11 +426,40 @@ pub async fn forward( } target.set_path(&request_path); target.set_query(query.as_deref()); + let injection = match &credential { + StoredCredential::OAuth { access_token, .. } => { + CredentialInjection::Bearer(access_token.clone()) + } + StoredCredential::ApiKey { key, .. } => { + let Ok(crate::providers::SecurityScheme::ApiKey(scheme)) = + state.catalog.security_scheme(platform) + else { + return (StatusCode::UNAUTHORIZED, "invalid connection code").into_response(); + }; + match scheme.location { + crate::providers::ApiKeyLocation::Header => CredentialInjection::Header { + name: scheme.name, + value: key.clone(), + }, + crate::providers::ApiKeyLocation::Query => { + target.query_pairs_mut().append_pair(&scheme.name, key); + CredentialInjection::None + } + crate::providers::ApiKeyLocation::Cookie => { + return ( + StatusCode::NOT_IMPLEMENTED, + "cookie-located API keys are not supported", + ) + .into_response(); + } + } + } + }; let upstream = match upstream_request( &state.http_client, method.clone(), target.clone(), - &credential.access_token, + injection, &headers, &required_headers, body, @@ -490,12 +568,17 @@ fn upstream_request( client: &reqwest::Client, method: axum::http::Method, target: Url, - access_token: &str, + injection: CredentialInjection, headers: &HeaderMap, required_headers: &[(String, String)], body: Bytes, ) -> reqwest::RequestBuilder { - let mut request = client.request(method, target).bearer_auth(access_token); + let mut request = client.request(method, target); + request = match injection { + CredentialInjection::Bearer(token) => request.bearer_auth(token), + CredentialInjection::Header { name, value } => request.header(name, value), + CredentialInjection::None => request, + }; if let Some(content_type) = headers.get(header::CONTENT_TYPE) { request = request.header(header::CONTENT_TYPE, content_type); } @@ -611,7 +694,7 @@ mod tests { &client, axum::http::Method::POST, Url::parse(&format!("http://{address}/repos/owner/repo/issues?state=all&page=2&per_page=1&labels=a%2Cb")).unwrap(), - "test-provider-token", + CredentialInjection::Bearer("test-provider-token".to_string()), &headers, &[], Bytes::from_static(b"{}"), @@ -637,6 +720,148 @@ mod tests { server.abort(); } + #[tokio::test] + async fn api_key_credentials_are_sent_as_the_declared_header_not_bearer() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = axum::Router::new().route( + "/workspaces", + axum::routing::get( + |headers: HeaderMap| async move { + Json(json!({ + "authorization": headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()), + "x_api_key": headers.get("x-api-key").and_then(|v| v.to_str().ok()), + })) + }, + ), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = crate::build_http_client(); + let response = upstream_request( + &client, + axum::http::Method::GET, + Url::parse(&format!("http://{address}/workspaces")).unwrap(), + CredentialInjection::Header { + name: "X-Api-Key".to_string(), + value: "clockify-secret".to_string(), + }, + &HeaderMap::new(), + &[], + Bytes::new(), + ) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(response["authorization"], serde_json::Value::Null); + assert_eq!(response["x_api_key"], "clockify-secret"); + server.abort(); + } + + #[tokio::test] + #[ignore = "requires TEST_DATABASE_URL; CI runs with --include-ignored"] + async fn postgres_forward_injects_the_declared_api_key_header_and_rotates_the_code() { + let db = std::env::var("TEST_DATABASE_URL") + .expect("set TEST_DATABASE_URL to an isolated test database"); + let security = crate::security::Security::connect( + &db, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + vec![], + ) + .await + .unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = axum::Router::new().route( + "/workspaces", + axum::routing::get(|headers: HeaderMap| async move { + Json(json!({ + "authorization": headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()), + "x_api_key": headers.get("x-api-key").and_then(|v| v.to_str().ok()), + })) + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut s = test_state("fixture-server-secret"); + s.security = Some(security.clone()); + s.catalog = crate::catalog::Catalog::from_test_document( + "clockify", + serde_json::json!({ + "servers": [{"url": format!("http://{address}")}], + "components": {"securitySchemes": {"clockifyApiKey": { + "type": "apiKey", "in": "header", "name": "X-Api-Key" + }}}, + "security": [{"clockifyApiKey": []}], + "paths": {"/workspaces": {"get": {}}} + }), + serde_json::json!({}), + ); + + let credential = StoredCredential::ApiKey { + provider: "clockify".into(), + tenant_id: "tenant".into(), + user_id: "did:ad:agent:test".into(), + key: "clockify-secret".into(), + }; + let envelope = security + .seal( + &serde_json::to_vec(&credential).unwrap(), + b"connection-credential-v1", + ) + .unwrap(); + let code = "test-connection-code".to_string(); + security + .store_connection_code(&code, &envelope) + .await + .unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {code}")).unwrap(), + ); + let response = forward( + Path("clockify/workspaces".to_string()), + RawQuery(None), + State(s), + axum::http::Method::GET, + headers, + Bytes::new(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let new_code = response.headers()["x-connection-code"] + .to_str() + .unwrap() + .to_string(); + assert_ne!(new_code, code); + let body = axum::body::to_bytes(response.into_body(), 16384) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["x_api_key"], "clockify-secret"); + assert_eq!(body["authorization"], serde_json::Value::Null); + + // The redeemed code is single-use; only the rotated code now works. + assert!(security + .take_connection_code(&code) + .await + .unwrap() + .is_none()); + assert!(security + .take_connection_code(&new_code) + .await + .unwrap() + .is_some()); + server.abort(); + } + #[test] fn pagination_headers_survive_without_forwarding_provider_credentials() { let mut headers = HeaderMap::new(); @@ -813,7 +1038,7 @@ mod tests { state.security = Some(security.clone()); state.test_upstream = Some(upstream_url); - let credential = Credential { + let credential = StoredCredential::OAuth { provider: "github-issues".into(), tenant_id: "tenant".into(), user_id: "user".into(), @@ -880,8 +1105,13 @@ mod tests { let plaintext = security .open(&rotated_envelope, b"connection-credential-v1") .unwrap(); - let rotated: Credential = serde_json::from_slice(&plaintext).unwrap(); - assert_eq!(rotated.access_token, "refreshed-token"); + let rotated: StoredCredential = serde_json::from_slice(&plaintext).unwrap(); + match rotated { + StoredCredential::OAuth { access_token, .. } => { + assert_eq!(access_token, "refreshed-token"); + } + StoredCredential::ApiKey { .. } => panic!("expected an OAuth credential"), + } server.abort(); } diff --git a/src/templates.rs b/src/templates.rs index e189b3e..aa94ace 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -47,6 +47,7 @@ pub fn render_platform_connect( auth_label: &str, bootstrap_identity: bool, api_login_platforms: &[String], + requires_api_key: bool, ) -> String { let tenant_consent = if include_tenant_secret { "

This also gives this hub your LocalThought account credential, allowing it to authorize future connections on your behalf.

" @@ -92,6 +93,20 @@ pub fn render_platform_connect( .collect::(), ), Some(user) => { + let api_key_field = if requires_api_key { + r#"

Find this in your account settings on the platform's own site.

+ "# + } else { + "" + }; + let button_label = if requires_api_key { + format!("Connect {}", escape(&platform_label(platform))) + } else { + format!( + "Use LocalThought to sync {} with this destination", + escape(&platform_label(platform)) + ) + }; format!( r#"
@@ -101,7 +116,8 @@ pub fn render_platform_connect( {tenant_consent}
- + {api_key_field} +
"#, @@ -114,7 +130,8 @@ pub fn render_platform_connect( target_origin = escape(target_origin), csrf = escape(csrf), tenant_consent = tenant_consent, - auth_label = escape(auth_label), + api_key_field = api_key_field, + button_label = button_label, ) } }; @@ -356,6 +373,7 @@ mod tests { "Example Login", false, &[], + false, ); assert!(html.contains("Google Calendar")); assert!(html.contains("Use LocalThought to sync Google Calendar with this destination")); @@ -377,6 +395,7 @@ mod tests { "OIDC", true, &[], + false, ); assert!(html.contains("Destination:")); assert!(html.contains("account credential")); @@ -396,11 +415,47 @@ mod tests { "Github", false, &[], + false, ); assert!(html.contains("logged in with Github

")); assert!(!html.contains("as