From a8d7345d5512c568a7f59092c23fcc9e06585c00 Mon Sep 17 00:00:00 2001
From: Michiel de Jong
Date: Wed, 16 Sep 2026 16:26:46 +0200
Subject: [PATCH 1/2] Add generic apiKey security-scheme support alongside
OAuth
Clockify (and other static-key APIs) have no OAuth2 login, only a
personal secret sent as a declared header/query parameter. The proxy
previously assumed every catalog platform was OAuth2, from the
consent-page provider gate through credential storage to how a
proxied request's Authorization header was built.
Add ApiKeyScheme/SecurityScheme (providers.rs) resolved generically
from a platform's composed OpenAPI securitySchemes, a Catalog::security_scheme
accessor, a browser-submitted-key branch in connect.rs's consent/authorize
flow that completes the existing PKCE handoff without any third-party
redirect, and a StoredCredential enum (unifying the previously duplicated
OAuth credential struct in oauth.rs/proxy.rs) so proxy.rs::forward can
inject a static key via its declared header/query name instead of always
assuming Authorization: Bearer.
No platform-specific behavior was added here; Clockify's own catalog
entry belongs in localthought/overlays.
---
src/catalog.rs | 22 ++++
src/connect.rs | 307 +++++++++++++++++++++++++++++++++++++++++++----
src/oauth.rs | 26 +---
src/providers.rs | 172 ++++++++++++++++++++++++++
src/proxy.rs | 279 +++++++++++++++++++++++++++++++++++++-----
src/templates.rs | 59 ++++++++-
6 files changed, 792 insertions(+), 73 deletions(-)
diff --git a/src/catalog.rs b/src/catalog.rs
index 1fdc4b3..1e2cdcb 100644
--- a/src/catalog.rs
+++ b/src/catalog.rs
@@ -217,6 +217,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(
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 288662b..42664fa 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;
@@ -1141,6 +1237,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 800fabd..d05a4f5 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,22 +273,30 @@ 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 response = provider
+ let refresh_token_value = refresh_token.as_deref().ok_or(())?;
+ let configured =
+ crate::providers::Provider::configured(&state.catalog, provider).map_err(|_| ())?;
+ let response = configured
.token_request(
&state.http_client,
&[
("grant_type", "refresh_token"),
- ("refresh_token", refresh_token),
+ ("refresh_token", refresh_token_value),
],
)
.send()
@@ -265,14 +305,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,
@@ -315,11 +364,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();
}
@@ -350,11 +399,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,
@@ -426,12 +504,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);
}
@@ -546,7 +629,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"{}"),
@@ -572,6 +655,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();
diff --git a/src/templates.rs b/src/templates.rs
index d8a421f..116e94d 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 your Atomic Data Hub",
+ escape(&platform_label(platform))
+ )
+ };
format!(
r#"
@@ -101,7 +116,8 @@ pub fn render_platform_connect(
{tenant_consent}
"#,
@@ -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 your Atomic Data Hub"));
@@ -377,6 +395,7 @@ mod tests {
"OIDC",
true,
&[],
+ false,
);
assert!(html.contains("Target Atomic Data Hub"));
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
Date: Wed, 16 Sep 2026 16:50:39 +0200
Subject: [PATCH 2/2] Add the composed-Clockify-fixture regression test
Missed from the previous commit: proves security_scheme()/allows()/
required_headers() resolve a real composed apiKey document correctly,
mirroring this file's existing pinned-fixture test pattern.
---
src/catalog.rs | 39 +++++
tests/fixtures/clockify-composed.yaml | 221 ++++++++++++++++++++++++++
2 files changed, 260 insertions(+)
create mode 100644 tests/fixtures/clockify-composed.yaml
diff --git a/src/catalog.rs b/src/catalog.rs
index 1e2cdcb..60d1a68 100644
--- a/src/catalog.rs
+++ b/src/catalog.rs
@@ -551,6 +551,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 composed_notion_preserves_base_path_and_resolves_version_header() {
let catalog = super::Catalog {
diff --git a/tests/fixtures/clockify-composed.yaml b/tests/fixtures/clockify-composed.yaml
new file mode 100644
index 0000000..44476db
--- /dev/null
+++ b/tests/fixtures/clockify-composed.yaml
@@ -0,0 +1,221 @@
+components:
+ crudResources:
+ timeEntry:
+ collections:
+ timeEntries:
+ urlTemplate: /v1/workspaces/{workspaceId}/user/{userId}/time-entries
+ description: A time entry on a Clockify workspace.
+ identity:
+ bindings:
+ id:
+ field: id
+ workspaceId:
+ field: workspaceId
+ urlTemplate: /v1/workspaces/{workspaceId}/time-entries/{id}
+ schema:
+ $ref: '#/components/schemas/TimeEntry'
+ paginationSchemes:
+ pageNumber:
+ description: 'Clockify''s list endpoints use 1-indexed page/page-size query parameters and return a plain JSON array with no pagination metadata in the body. Clockify also reports completion via a custom Last-Page response header, but paging stops the same way as other pageNumber platforms in this catalog: a page shorter than the requested page-size is the last one.'
+ request:
+ queryParameters:
+ page:
+ role: page
+ page-size:
+ role: pageSize
+ type: pageNumber
+ parameters:
+ End:
+ description: Represents an end date in the yyyy-MM-ddThh:mm:ssZ format.
+ in: query
+ name: end
+ required: false
+ schema:
+ format: date-time
+ type: string
+ Page:
+ description: Page number.
+ in: query
+ name: page
+ required: false
+ schema:
+ default: 1
+ minimum: 1
+ type: integer
+ PageSize:
+ description: Page size.
+ in: query
+ name: page-size
+ required: false
+ schema:
+ default: 50
+ minimum: 1
+ type: integer
+ Start:
+ description: Represents a start date in the yyyy-MM-ddThh:mm:ssZ format.
+ in: query
+ name: start
+ required: false
+ schema:
+ format: date-time
+ type: string
+ UserId:
+ description: Represents a user identifier across the system.
+ in: path
+ name: userId
+ required: true
+ schema:
+ type: string
+ WorkspaceId:
+ description: Represents a workspace identifier across the system.
+ in: path
+ name: workspaceId
+ required: true
+ schema:
+ type: string
+ schemas:
+ TimeEntry:
+ properties:
+ billable:
+ type: boolean
+ description:
+ nullable: true
+ type: string
+ id:
+ type: string
+ isLocked:
+ type: boolean
+ kioskId:
+ nullable: true
+ type: string
+ projectId:
+ nullable: true
+ type: string
+ tagIds:
+ items:
+ type: string
+ nullable: true
+ type: array
+ taskId:
+ nullable: true
+ type: string
+ timeInterval:
+ $ref: '#/components/schemas/TimeInterval'
+ type:
+ enum:
+ - REGULAR
+ - BREAK
+ type: string
+ userId:
+ type: string
+ workspaceId:
+ type: string
+ required:
+ - id
+ - workspaceId
+ - userId
+ type: object
+ TimeInterval:
+ nullable: true
+ properties:
+ duration:
+ nullable: true
+ type: string
+ end:
+ format: date-time
+ nullable: true
+ type: string
+ start:
+ format: date-time
+ nullable: true
+ type: string
+ type: object
+ securitySchemes:
+ clockifyApiKey:
+ description: A personal API key generated in Clockify's Profile Settings. Clockify has no OAuth2 login; this is the only supported authentication mode.
+ in: header
+ name: X-Api-Key
+ type: apiKey
+info:
+ contact:
+ name: Clockify Developer Connection
+ url: https://docs.clockify.me/
+ description: Compatible read-only subset of Clockify's public REST API for synchronizing a user's time entries on one workspace. Clockify has no OAuth2 login; every request authenticates with a personal API key sent as the X-Api-Key header (declared by this platform's auth-overlay). workspaceId and userId are supplied as constants, the same way other catalog platforms without an auto-discovery step (e.g. GitHub Issues' owner/repo) require them. This document preserves only the list and single-item read operations needed by Local Thought, scoped to Clockify's global API origin; regional/private subdomain origins are not covered.
+ title: Clockify API (read-only time entries subset)
+ version: 1.0.0-readonly
+ x-apisguru-categories:
+ - productivity
+ x-origin:
+ - format: openapi
+ url: https://docs.clockify.me/
+ version: '3.0'
+ x-providerName: clockify.me
+ x-serviceName: api-readonly
+openapi: 3.0.3
+paths:
+ /v1/workspaces/{workspaceId}/time-entries/{id}:
+ get:
+ operationId: get-time-entry
+ parameters:
+ - $ref: '#/components/parameters/WorkspaceId'
+ - description: Represents a time entry identifier across the system.
+ in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TimeEntry'
+ description: A single time entry.
+ '401':
+ description: The API key is missing or invalid.
+ '403':
+ description: The request is not allowed.
+ '404':
+ description: The time entry does not exist.
+ security:
+ - clockifyApiKey: []
+ summary: Get a specific time entry on a workspace
+ x-crud:
+ action: read
+ resource: timeEntry
+ /v1/workspaces/{workspaceId}/user/{userId}/time-entries:
+ get:
+ operationId: get-time-entries-for-user
+ parameters:
+ - $ref: '#/components/parameters/WorkspaceId'
+ - $ref: '#/components/parameters/UserId'
+ - $ref: '#/components/parameters/Start'
+ - $ref: '#/components/parameters/End'
+ - $ref: '#/components/parameters/Page'
+ - $ref: '#/components/parameters/PageSize'
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ items:
+ $ref: '#/components/schemas/TimeEntry'
+ type: array
+ description: A page of the user's time entries on this workspace.
+ '401':
+ description: The API key is missing or invalid.
+ '403':
+ description: The request is not allowed.
+ security:
+ - clockifyApiKey: []
+ summary: Get time entries for a user on a workspace
+ x-crud:
+ action: list
+ collection: timeEntries
+ resource: timeEntry
+ x-pagination:
+ - scheme: pageNumber
+security:
+- clockifyApiKey: []
+servers:
+- url: https://api.clockify.me/api