From f91e6bfb2bcc7b55889768955402b970f7faca41 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 21 Sep 2026 17:36:26 -0700 Subject: [PATCH 1/2] feat(sts): exchange API keys at /.sts and sign them at /.keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An API key (ADR-013) is a JWT the proxy signs for a service account, handed out as sck_ + JWT. POST /.keys signs one for a manager presenting their own ID token, once the Source API confirms they manage the account; source.coop calls it after writing the key's record. A key presented at /.sts is verified against the proxy's own signing key in process — a Worker cannot fetch its own JWKS — and its jti is checked with the Source API, cached for 60s, before credentials are minted. Expired, revoked and unknown keys get one answer; the reason is logged under the request id. Keys carry aud = the proxy issuer, which ADR-013 now says, and may omit exp: the key record is what expires or is revoked. The _default role says subject_conditions ["*"] outright, which multistore#146 requires, and builds against the multistore branch carrying #146 and #147 through [patch.crates-io] until they are released. Closes #231 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- Cargo.lock | 18 ++-- Cargo.toml | 20 +++++ README.md | 20 +++++ adrs/013-api-keys.md | 2 + src/keys.rs | 195 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 188 ++++++++++++++++++++++++++++++++++++-- src/source_api/cache.rs | 79 +++++++++++++++- src/sts.rs | 30 +++++-- tests/keys.rs | 162 +++++++++++++++++++++++++++++++++ 9 files changed, 685 insertions(+), 29 deletions(-) create mode 100644 src/keys.rs create mode 100644 tests/keys.rs diff --git a/Cargo.lock b/Cargo.lock index b70dd727..611f2afb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1035,8 +1035,7 @@ dependencies = [ [[package]] name = "multistore" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d34cce7e97df06f76b1a711d6bd655ce079dc7b32bea049d2432cfa2780d" +source = "git+https://github.com/developmentseed/multistore?branch=oidc%2Fsign-claims#2a985be0c13f5a5098c8bbde16352abc7cc7a7bf" dependencies = [ "async-trait", "base64", @@ -1064,8 +1063,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d3c198c3e31ea7903e15f7af15c10b5d5b1c62f16b62a00e458872d5ae1d72" +source = "git+https://github.com/developmentseed/multistore?branch=oidc%2Fsign-claims#2a985be0c13f5a5098c8bbde16352abc7cc7a7bf" dependencies = [ "async-trait", "bytes", @@ -1091,8 +1089,7 @@ dependencies = [ [[package]] name = "multistore-oidc-provider" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ecb56666012a49cc8fdbaf60564337b04c1f89230bace11240604c4ddf56ef" +source = "git+https://github.com/developmentseed/multistore?branch=oidc%2Fsign-claims#2a985be0c13f5a5098c8bbde16352abc7cc7a7bf" dependencies = [ "base64", "chrono", @@ -1110,8 +1107,7 @@ dependencies = [ [[package]] name = "multistore-path-mapping" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e0738451d593e53eafdb2e33f7c2ceef622d2d8b21a42f59e389649a2c294d" +source = "git+https://github.com/developmentseed/multistore?branch=oidc%2Fsign-claims#2a985be0c13f5a5098c8bbde16352abc7cc7a7bf" dependencies = [ "multistore", "percent-encoding", @@ -1121,8 +1117,7 @@ dependencies = [ [[package]] name = "multistore-sts" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "449762c5aac6de3ca7f96430dc6af2e668b1f93c041e58aa181148bb5dd7bebb" +source = "git+https://github.com/developmentseed/multistore?branch=oidc%2Fsign-claims#2a985be0c13f5a5098c8bbde16352abc7cc7a7bf" dependencies = [ "aes-gcm", "base64", @@ -1891,6 +1886,7 @@ dependencies = [ name = "source-data-proxy" version = "2.3.4" dependencies = [ + "chrono", "console_error_panic_hook", "getrandom 0.4.3", "hmac", @@ -1902,8 +1898,10 @@ dependencies = [ "multistore-path-mapping", "multistore-sts", "percent-encoding", + "rand 0.8.7", "reqwest", "ring", + "rsa", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 472e6b39..721968ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,10 @@ path = "tests/object_path.rs" name = "fixtures" path = "tests/fixtures.rs" +[[test]] +name = "keys" +path = "tests/keys.rs" + [dependencies] # Multistore multistore = { version = "0.7.2", features = ["azure", "gcp"] } @@ -44,6 +48,7 @@ multistore-sts = "0.7.2" # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = "0.4" # HTTP http = "1" @@ -56,6 +61,11 @@ sha2 = "0.10" # Tracing tracing = "0.1" +# Native only: `tests/keys.rs` generates a throwaway signing key. +[dev-dependencies] +rand = "0.8" +rsa = "0.9" + # Wasm-only dependencies (Cloudflare Workers runtime) [target.'cfg(target_arch = "wasm32")'.dependencies] # Pulled in transitively via object_store -> rand. getrandom doesn't support @@ -86,3 +96,13 @@ web-sys = { version = "0.3", features = [ ] } worker = { version = "=0.7.5", features = ["http"] } worker-macros = { version = "=0.7.5", features = ["http"] } + +# Until the multistore release that carries developmentseed/multistore#146 +# (fail-closed trust fields, `allow_missing_exp_from`) and #147 +# (`JwtSigner::sign_claims`). Drop this and bump the versions above on release. +[patch.crates-io] +multistore = { git = "https://github.com/developmentseed/multistore", branch = "oidc/sign-claims" } +multistore-cf-workers = { git = "https://github.com/developmentseed/multistore", branch = "oidc/sign-claims" } +multistore-oidc-provider = { git = "https://github.com/developmentseed/multistore", branch = "oidc/sign-claims" } +multistore-path-mapping = { git = "https://github.com/developmentseed/multistore", branch = "oidc/sign-claims" } +multistore-sts = { git = "https://github.com/developmentseed/multistore", branch = "oidc/sign-claims" } diff --git a/README.md b/README.md index 2699e72e..bd31bb08 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ Client Request: GET /{account}/{product}/{key} | `OPTIONS *` | CORS preflight | | `GET /.well-known/openid-configuration` | OIDC discovery document | | `GET /.well-known/jwks.json` | JSON Web Key Set for JWT verification | +| `POST /.sts` | `AssumeRoleWithWebIdentity`: an ID token or API key for credentials | +| `POST /.keys` | Sign an API key for a service account (called by source.coop) | Write operations (`PUT`, `POST`, `DELETE`, `PATCH`) return `405 Method Not Allowed`. @@ -147,6 +149,24 @@ When configured with an RSA key, the proxy acts as its own OpenID Connect identi These endpoints are only active when `OIDC_PROVIDER_KEY` is configured. +### API Keys + +An API key ([ADR-013](adrs/013-api-keys.md)) is a JWT signed with the same key, +prefixed `sck_`, that a service account exchanges at `/.sts` exactly as a user +exchanges an ID token: `AssumeRoleWithWebIdentity` with the key as +`WebIdentityToken` and `RoleArn=_default`. The proxy verifies it against its +own signing key in process (a Worker cannot fetch its own JWKS), then asks the +Source API whether the key is still active before minting — cached for 60s, so +revoking a key stops new exchanges within a minute. Expired, revoked and +unknown keys all get the same `InvalidIdentityToken` answer; the reason is +logged under the response's `x-request-id`. + +`POST /.keys` signs a key. source.coop calls it with the manager's ID token +(`Authorization: Bearer`) and `{"account_id", "jti", "expires_at"}` once it +has written the key's record; the proxy checks with the Source API that the +caller manages the account before signing. Like `/.sts`, it answers 501 until +`AUTH_AUDIENCE` is set. + ### Setup Generate an RSA key pair, store it as a GitHub environment secret, and deploy: diff --git a/adrs/013-api-keys.md b/adrs/013-api-keys.md index 69718595..db75d420 100644 --- a/adrs/013-api-keys.md +++ b/adrs/013-api-keys.md @@ -36,6 +36,7 @@ An API key JWT contains: { "iss": "https://data.source.coop", "sub": "", + "aud": "https://data.source.coop", "jti": "", "iat": 1711929600, "exp": 1743465600, @@ -45,6 +46,7 @@ An API key JWT contains: - `iss` is the proxy's own issuer URL, not `auth.source.coop` (which is Ory Network and outside Source Cooperative's control for token minting) - `sub` identifies the Source Cooperative account that owns the key +- `aud` is the proxy's own issuer too: the proxy is the only party meant to accept a key, and an exchange requires every token to name its audience - `jti` is a unique key identifier used for revocation checks - `exp` is optional — keys without an expiry are valid until explicitly revoked - `type` distinguishes API key JWTs from other tokens the proxy may issue (e.g. outbound federation tokens) diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 00000000..b6b19f72 --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,195 @@ +//! API keys (ADR-013, amended by ADR-014): long-lived JWTs the proxy signs +//! for a service account, handed out as `sck_` + JWT and exchanged at `/.sts` +//! like any other identity token — except that the proxy verifies them +//! against its own signing key in process, and asks the Source API whether +//! the key's `jti` is still active before it mints anything. +//! +//! Kept wasm-free so the minting and verification rules are unit-tested +//! natively (see `tests/keys.rs`), despite the crate's `[lib] test = false`. +//! The network halves — the standing lookup and the manager check — live in +//! `source_api::cache`, and the wiring in `lib.rs`. + +use multistore::error::ProxyError; +use multistore::types::{RoleConfig, TemporaryCredentials}; +use multistore_oidc_provider::jwt::JwtSigner; +use multistore_sts::jwks::{verify_token, JwkKey, JwksResponse}; +use multistore_sts::sts::mint_temporary_credentials; +use multistore_sts::TokenKey; +use serde::Deserialize; + +/// Every key starts with this. A JWT always begins `eyJ`, so a leaked key +/// matches `sck_eyJ[\w-]+\.[\w-]+\.[\w-]+` — the pattern secret scanners are +/// given. Stripped before verification; a bare JWT from this issuer is not a +/// key, and the STS route rejects it as an untrusted issuer. +pub const API_KEY_PREFIX: &str = "sck_"; + +/// The `type` claim that marks a token as an API key, telling it apart from +/// the assertions the proxy signs for outbound federation under the same key. +pub const API_KEY_TYPE: &str = "api_key"; + +/// What source.coop sends to `POST /.keys` for a key it has recorded. +#[derive(Debug, Deserialize)] +pub struct KeyRequest { + pub account_id: String, + pub jti: String, + /// RFC 3339; `null` for a key that lasts until revoked. + pub expires_at: Option, +} + +/// The Source API's answer for a presented key +/// (`POST /api/v1/service-account-keys/{jti}/exchanges`). +#[derive(Debug, Deserialize)] +pub struct KeyStanding { + pub active: bool, +} + +/// A verified key: who it is for, which record it is, and its claims. +#[derive(Debug)] +pub struct ApiKey { + pub account_id: String, + pub jti: String, + pub claims: serde_json::Value, +} + +/// The role an API key assumes: the proxy trusts its own issuer, for tokens +/// minted for itself, from any service account — and lets them omit `exp`, +/// because validity is the key record's, checked on every exchange. +pub fn api_key_role(issuer: &str, max_session_duration_secs: u64) -> RoleConfig { + RoleConfig { + role_id: "_default".to_string(), + name: "API key".to_string(), + trusted_oidc_issuers: vec![issuer.to_string()], + required_audiences: vec![issuer.to_string()], + subject_conditions: vec!["*".to_string()], + allowed_scopes: vec![], + max_session_duration_secs, + allow_missing_exp_from: vec![issuer.to_string()], + } +} + +/// The claims of a key for `req`, dated `now` (unix seconds). +pub fn api_key_claims( + issuer: &str, + req: &KeyRequest, + now: i64, +) -> Result { + if req.account_id.is_empty() || req.jti.is_empty() { + return Err(ProxyError::InvalidRequest( + "account_id and jti are required".into(), + )); + } + let mut claims = serde_json::json!({ + "iss": issuer, + "sub": req.account_id, + "aud": issuer, + "jti": req.jti, + "iat": now, + "type": API_KEY_TYPE, + }); + if let Some(at) = &req.expires_at { + let exp = chrono::DateTime::parse_from_rfc3339(at) + .map_err(|e| ProxyError::InvalidRequest(format!("expires_at: {e}")))? + .timestamp(); + if exp <= now { + return Err(ProxyError::InvalidRequest( + "expires_at is in the past".into(), + )); + } + claims["exp"] = exp.into(); + } + Ok(claims) +} + +/// Sign a key for `req`: the prefix, then the JWT. +pub fn mint_api_key( + signer: &JwtSigner, + issuer: &str, + req: &KeyRequest, + now: i64, +) -> Result { + let claims = api_key_claims(issuer, req, now)?; + let jwt = signer + .sign_claims(&claims) + .map_err(|e| ProxyError::Internal(format!("sign API key: {e}")))?; + Ok(format!("{API_KEY_PREFIX}{jwt}")) +} + +/// The JWT inside a key, or `None` when `token` is not a key at all. +pub fn strip_api_key(token: &str) -> Option<&str> { + token.strip_prefix(API_KEY_PREFIX) +} + +/// The proxy's own signing keys as a JWK set — what a relying party would +/// fetch from `/.well-known/jwks.json`, built in process because a Worker +/// cannot fetch itself. Pass the previous key too during a rotation, so keys +/// signed before it still verify while that key is served. +pub fn own_jwks(signers: &[&JwtSigner]) -> JwksResponse { + let keys: Vec<_> = signers.iter().map(|s| (s.public_key(), s.kid())).collect(); + serde_json::from_str(&multistore_oidc_provider::jwks::jwks_json(&keys)) + .expect("jwks_json is a JWKS") +} + +/// Verify `token` — the JWT, prefix already stripped — with whichever of +/// `jwks` signed it, and check it is an API key: `type` says so, and `sub` +/// and `jti` are present. +pub fn verify_api_key( + token: &str, + jwks: &JwksResponse, + issuer: &str, + role: &RoleConfig, +) -> Result { + let claims = verify_with_any_key(token, &jwks.keys, issuer, role)?; + if claims.get("type").and_then(|v| v.as_str()) != Some(API_KEY_TYPE) { + return Err(ProxyError::InvalidOidcToken("not an API key".into())); + } + let field = |name: &str| { + claims + .get(name) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| ProxyError::InvalidOidcToken(format!("API key has no {name}"))) + }; + Ok(ApiKey { + account_id: field("sub")?, + jti: field("jti")?, + claims, + }) +} + +/// Verify `token` with whichever of `keys` signed it. A set holds a key or +/// two, so trying each costs less than decoding the header to pick one; the +/// last failure is the one reported. +pub fn verify_with_any_key( + token: &str, + keys: &[JwkKey], + issuer: &str, + role: &RoleConfig, +) -> Result { + let mut last = ProxyError::InvalidOidcToken("no signing keys".into()); + for key in keys { + match verify_token(token, key, issuer, role) { + Ok(claims) => return Ok(claims), + Err(e) => last = e, + } + } + Err(last) +} + +/// Credentials for a verified key, sealed the way the STS route seals every +/// session, for the duration the client asked for within the role's cap. +pub fn credentials_for( + role: &RoleConfig, + key: &ApiKey, + duration_seconds: Option, + token_key: &TokenKey, +) -> Result { + // The same floor and default as multistore's exchange (AWS's 900s minimum). + let duration = duration_seconds + .unwrap_or(3600) + .clamp(900, role.max_session_duration_secs); + let mut creds = + mint_temporary_credentials(role, &key.account_id, duration, "STSPRXY", &key.claims)?; + creds.session_token = token_key.seal(&creds)?; + Ok(creds) +} diff --git a/src/lib.rs b/src/lib.rs index e9f8d42e..22886638 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,29 +12,37 @@ mod authz; mod backend_auth; mod config; mod handlers; +mod keys; mod location; mod object_path; mod pagination; mod source_api; mod sts; +use crate::config::AppConfig; use crate::source_api::{ApiAuth, SourceCoopRegistry}; use analytics::log_analytics; use handlers::{AccountListHandler, IndexHandler}; +use keys::KeyRequest; use multistore::api::response::ErrorResponse; +use multistore::error::ProxyError; use multistore::proxy::{GatewayResponse, ProxyGateway}; use multistore::route_handler::{ProxyResult, RequestInfo}; use multistore::router::Router; +use multistore::types::TemporaryCredentials; use multistore_cf_workers::{ - collect_js_body, GatewayResponseExt, NoopCredentialRegistry, RequestParts, WorkerBackend, - WorkerSubscriber, + collect_js_body, GatewayResponseExt, JsBody, NoopCredentialRegistry, RequestParts, + WorkerBackend, WorkerSubscriber, }; use multistore_oidc_provider::backend_auth::{AwsBackendAuth, MaybeOidcAuth}; +use multistore_oidc_provider::jwt::JwtSigner; use multistore_oidc_provider::route_handler::OidcRouterExt; use multistore_oidc_provider::{HttpExchange, OidcCredentialProvider, OidcProviderError}; use multistore_path_mapping::{MappedRegistry, PathMapping}; use multistore_sts::jwks::JwksCache; +use multistore_sts::request::StsRequest; use multistore_sts::route_handler::StsRouterExt; +use multistore_sts::{build_sts_error_response, build_sts_response, try_parse_sts_request}; use object_path::{extract_path_segments, is_keyless_write, mapped_copy_source}; use std::sync::OnceLock; use sts::StsCredentialRegistry; @@ -147,9 +155,11 @@ async fn fetch(req: web_sys::Request, env: Env, ctx: Context) -> Result Result Result(headers: &'a http::HeaderMap, name: &str) -> &'a st .unwrap_or("") } +/// A response answered before the gateway, with the CORS headers and request +/// id every gateway response carries. +fn finish(result: ProxyResult, request_id: &str) -> web_sys::Response { + let response = add_cors(GatewayResponse::Response(result).into_web_sys()); + if !request_id.is_empty() { + let _ = response.headers().set("x-request-id", request_id); + } + response +} + +// ── API keys ──────────────────────────────────────────────────────── + +/// `POST /.keys`: sign an API key for a service account, on the say-so of a +/// manager presenting their own identity token. The proxy holds the signing +/// key; source.coop holds the record, and calls here once it has written it. +/// The manager is checked with the Source API, so a token alone mints nothing. +async fn mint_key( + config: &AppConfig, + parts: &RequestParts, + body: JsBody, + api_auth: &ApiAuth, + request_id: &str, +) -> (u16, String) { + match try_mint_key(config, parts, body, api_auth, request_id).await { + Ok(key) => (200, serde_json::json!({ "key": key }).to_string()), + Err(e) => { + tracing::warn!(error = %e, "API key minting refused"); + ( + e.status_code(), + serde_json::json!({ "error": e.to_string() }).to_string(), + ) + } + } +} + +async fn try_mint_key( + config: &AppConfig, + parts: &RequestParts, + body: JsBody, + api_auth: &ApiAuth, + request_id: &str, +) -> std::result::Result { + if parts.method != http::Method::POST { + return Err(ProxyError::InvalidRequest("POST /.keys".into())); + } + let bearer = header_str(&parts.headers, "authorization") + .strip_prefix("Bearer ") + .ok_or(ProxyError::MissingAuth)?; + // The trust `/.sts` extends: an ID token from the auth issuer, for one of + // the configured audiences. + let role = sts::default_role( + config.auth_issuer.clone(), + config.auth_audiences.clone(), + config.sts_max_session_duration_secs, + ); + let jwks = jwks_cache().get_or_fetch(&config.auth_issuer).await?; + let claims = keys::verify_with_any_key(bearer, &jwks.keys, &config.auth_issuer, &role)?; + let manager = claims + .get("sub") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ProxyError::InvalidOidcToken("missing sub claim".into()))?; + let bytes = collect_js_body(body) + .await + .map_err(ProxyError::InvalidRequest)?; + let req: KeyRequest = serde_json::from_slice(&bytes) + .map_err(|e| ProxyError::InvalidRequest(format!("body: {e}")))?; + source_api::cache::assert_caller_manages_account( + &config.api_base_url, + &req.account_id, + api_auth, + request_id, + manager, + ) + .await?; + keys::mint_api_key( + &config.oidc.signer, + &config.oidc.issuer, + &req, + chrono::Utc::now().timestamp(), + ) +} + +/// A key presented at `/.sts` — `sck_` + JWT — is exchanged here rather than +/// by the STS route, because a Worker cannot fetch its own JWKS (Cloudflare +/// refuses the self-request, error 1042). The token is verified against the +/// signing key in process, and the key's standing is checked with the Source +/// API — the step ADR-013 adds — before credentials are minted. `None` when +/// the request is not an exchange of an API key. +async fn api_key_exchange( + config: &AppConfig, + parts: &RequestParts, + api_auth: &ApiAuth, + request_id: &str, +) -> Option<(u16, String)> { + let sts = try_parse_sts_request(parts.query.as_deref()) + .or_else(|| try_parse_sts_request(parts.form_body.as_deref()))? + .ok()?; // a malformed request is the STS route's to report + let jwt = keys::strip_api_key(&sts.web_identity_token)?; + if !sts::is_default_role(&sts.role_arn) { + return Some(build_sts_error_response(&ProxyError::RoleNotFound( + sts.role_arn.clone(), + ))); + } + Some( + match exchange_api_key(config, &sts, jwt, api_auth, request_id).await { + Ok(creds) => { + tracing::info!(subject = %creds.source_identity, "API key exchange succeeded"); + build_sts_response(&creds) + } + // Bad signature, expired, revoked, unknown: one answer for all, so the + // response says nothing about the record. The reason is in the log, + // under the request id the response carries. + Err(e) => { + tracing::warn!(error = %e, "API key exchange refused"); + build_sts_error_response(&ProxyError::InvalidOidcToken( + "API key was not accepted".into(), + )) + } + }, + ) +} + +async fn exchange_api_key( + config: &AppConfig, + sts: &StsRequest, + jwt: &str, + api_auth: &ApiAuth, + request_id: &str, +) -> std::result::Result { + let signers: Vec<&JwtSigner> = std::iter::once(&config.oidc.signer) + .chain(config.oidc.previous_signer.as_ref()) + .collect(); + let role = keys::api_key_role(&config.oidc.issuer, config.sts_max_session_duration_secs); + let key = keys::verify_api_key(jwt, &keys::own_jwks(&signers), &config.oidc.issuer, &role)?; + let standing = source_api::cache::get_or_fetch_key_standing( + &config.api_base_url, + &key.jti, + api_auth, + request_id, + &key.account_id, + ) + .await?; + if !standing.active { + return Err(ProxyError::AccessDenied); + } + keys::credentials_for(&role, &key, sts.duration_seconds, &config.session_token_key) +} + // ── CORS ──────────────────────────────────────────────────────────── fn add_cors(resp: web_sys::Response) -> web_sys::Response { diff --git a/src/source_api/cache.rs b/src/source_api/cache.rs index a3499843..03fa0f4a 100644 --- a/src/source_api/cache.rs +++ b/src/source_api/cache.rs @@ -4,6 +4,7 @@ //! Adjust the `*_CACHE_SECS` constants to tune per-datatype expiry. use super::types::{DataConnection, SourceProduct, SourceProductList}; +use crate::keys::KeyStanding; use multistore::error::ProxyError; use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; @@ -44,6 +45,11 @@ const PRODUCT_LIST_CACHE_SECS: u32 = 60; // 1 minute /// so a revoked grant should stop taking effect quickly. const PERMISSIONS_CACHE_SECS: u32 = 60; // 1 minute +/// A presented API key's standing (`.../service-account-keys/{jti}/exchanges`). +/// The permissions TTL, for the same reason: it gates access, so a revoked +/// key should stop being exchangeable quickly (ADR-013). +const KEY_STANDING_CACHE_SECS: u32 = 60; // 1 minute + // ── Public cache functions ───────────────────────────────────────── /// Fetch a single product's metadata, cached for `PRODUCT_CACHE_SECS`. @@ -65,6 +71,7 @@ pub async fn get_or_fetch_product( cached_fetch( &cache_key, &api_url, + "GET", PRODUCT_CACHE_SECS, api_auth, request_id, @@ -94,6 +101,7 @@ pub async fn get_or_fetch_permissions( cached_fetch( &cache_key, &api_url, + "GET", PERMISSIONS_CACHE_SECS, api_auth, request_id, @@ -125,6 +133,7 @@ pub async fn get_or_fetch_data_connection( cached_fetch( &cache_key, &api_url, + "GET", DATA_CONNECTION_CACHE_SECS, api_auth, request_id, @@ -150,6 +159,7 @@ pub async fn get_or_fetch_product_list( cached_fetch( &cache_key, &api_url, + "GET", PRODUCT_LIST_CACHE_SECS, api_auth, request_id, @@ -158,6 +168,65 @@ pub async fn get_or_fetch_product_list( .await } +/// A presented API key's standing, cached for `KEY_STANDING_CACHE_SECS`. A +/// POST: the API records the use as it answers. Asked as the key's own +/// account — the route answers no one else. +pub async fn get_or_fetch_key_standing( + api_base_url: &str, + jti: &str, + api_auth: &crate::ApiAuth, + request_id: &str, + subject: &str, +) -> Result { + let api_url = format!( + "{}/api/v1/service-account-keys/{}/exchanges", + api_base_url, + utf8_percent_encode(jti, PATH_SEGMENT), + ); + let cache_key = cache_key_with_subject(&api_url, Some(subject)); + cached_fetch( + &cache_key, + &api_url, + "POST", + KEY_STANDING_CACHE_SECS, + api_auth, + request_id, + Some(subject), + ) + .await +} + +/// Whether `subject` manages `account_id`, asked of the API as `subject`. +/// `GET .../accounts/{id}/integrations` lists the account's identity bindings +/// and is gated on managing the account, so a 200 is the answer and the list +/// is discarded. Not cached: minting is rare, and a manager who has just lost +/// the account should lose this with it. +pub async fn assert_caller_manages_account( + api_base_url: &str, + account_id: &str, + api_auth: &crate::ApiAuth, + request_id: &str, + subject: &str, +) -> Result<(), ProxyError> { + let api_url = format!( + "{}/api/v1/accounts/{}/integrations", + api_base_url, + utf8_percent_encode(account_id, PATH_SEGMENT), + ); + let cache_key = cache_key_with_subject(&api_url, Some(subject)); + cached_fetch::( + &cache_key, + &api_url, + "GET", + 0, + api_auth, + request_id, + Some(subject), + ) + .await + .map(drop) +} + // ── Internal helpers ────────────────────────────────────────────── /// Build a cache key that includes the caller's identity so that @@ -181,11 +250,12 @@ fn cache_key_with_subject(api_url: &str, subject: Option<&str>) -> String { } /// Generic cache-or-fetch: check the Cache API, return cached JSON on hit, -/// otherwise fetch from `api_url`, store in cache with the given TTL, and -/// return the deserialized result. +/// otherwise fetch from `api_url` with `method`, store in cache with the given +/// TTL (a TTL of 0 stores nothing), and return the deserialized result. async fn cached_fetch( cache_key: &str, api_url: &str, + method: &str, ttl_secs: u32, api_auth: &crate::ApiAuth, request_id: &str, @@ -219,7 +289,7 @@ async fn cached_fetch( // ── Cache miss — fetch from API ──────────────────────────── span.record("cache_hit", false); let init = web_sys::RequestInit::new(); - init.set_method("GET"); + init.set_method(method); let req_headers = web_sys::Headers::new() .map_err(|e| ProxyError::Internal(format!("headers build failed: {:?}", e)))?; // Only authenticate to the API when we have an identified caller. @@ -270,6 +340,9 @@ async fn cached_fetch( .map_err(|e| ProxyError::Internal(format!("JSON parse failed: {} for {}", e, api_url)))?; // ── Store in cache ───────────────────────────────────────── + if ttl_secs == 0 { + return Ok(result); + } let headers = worker::Headers::new(); let _ = headers.set("content-type", "application/json"); let _ = headers.set("cache-control", &format!("max-age={}", ttl_secs)); diff --git a/src/sts.rs b/src/sts.rs index da49bc1e..85f6e323 100644 --- a/src/sts.rs +++ b/src/sts.rs @@ -36,19 +36,31 @@ impl StsCredentialRegistry { max_session_duration_secs: u64, ) -> Self { Self { - default_role: RoleConfig { - role_id: "_default".to_string(), - name: "Default".to_string(), - trusted_oidc_issuers: vec![oidc_issuer], - required_audiences, - subject_conditions: vec![], - allowed_scopes: vec![], // unlimited - max_session_duration_secs, - }, + default_role: default_role(oidc_issuer, required_audiences, max_session_duration_secs), } } } +/// The `_default` role: any subject the auth issuer vouches for, unscoped, +/// every token dated by that issuer. Only the proxy's own API keys +/// (`keys::api_key_role`) may leave `exp` out. +pub(crate) fn default_role( + oidc_issuer: String, + required_audiences: Vec, + max_session_duration_secs: u64, +) -> RoleConfig { + RoleConfig { + role_id: "_default".to_string(), + name: "Default".to_string(), + trusted_oidc_issuers: vec![oidc_issuer], + required_audiences, + subject_conditions: vec!["*".to_string()], + allowed_scopes: vec![], // unlimited + max_session_duration_secs, + allow_missing_exp_from: vec![], + } +} + impl CredentialRegistry for StsCredentialRegistry { async fn get_credential( &self, diff --git a/tests/keys.rs b/tests/keys.rs new file mode 100644 index 00000000..41a9e90c --- /dev/null +++ b/tests/keys.rs @@ -0,0 +1,162 @@ +//! Native unit tests for the wasm-free `keys` module, included via `#[path]` +//! (the lib itself is `cdylib` with `test = false`). Mirrors the pattern in +//! `tests/backend_auth.rs`. + +#[path = "../src/keys.rs"] +mod keys; + +use keys::*; +use multistore_oidc_provider::jwt::JwtSigner; +use multistore_sts::TokenKey; + +const ISSUER: &str = "https://data.example.test"; + +fn signer(kid: &str) -> JwtSigner { + use rsa::pkcs8::EncodePrivateKey; + let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048).unwrap(); + let pem = key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF).unwrap(); + JwtSigner::from_pem(&pem, kid.into(), 60).unwrap() +} + +fn request(expires_at: Option<&str>) -> KeyRequest { + KeyRequest { + account_id: "nightly-sync".into(), + jti: "8b1c2d3e".into(), + expires_at: expires_at.map(String::from), + } +} + +fn now() -> i64 { + chrono::Utc::now().timestamp() +} + +fn rfc3339(ts: i64) -> String { + chrono::DateTime::from_timestamp(ts, 0) + .unwrap() + .to_rfc3339() +} + +fn role() -> multistore::types::RoleConfig { + api_key_role(ISSUER, 3600) +} + +// ── minting ──────────────────────────────────────────────────────── + +#[test] +fn claims_carry_the_record_and_address_the_proxy() { + let exp = now() + 86_400; + let claims = api_key_claims(ISSUER, &request(Some(&rfc3339(exp))), now()).unwrap(); + assert_eq!(claims["iss"], ISSUER); + assert_eq!(claims["aud"], ISSUER); + assert_eq!(claims["sub"], "nightly-sync"); + assert_eq!(claims["jti"], "8b1c2d3e"); + assert_eq!(claims["type"], API_KEY_TYPE); + assert_eq!(claims["exp"], exp); +} + +#[test] +fn a_key_without_expiry_has_no_exp_claim() { + let claims = api_key_claims(ISSUER, &request(None), now()).unwrap(); + assert!(claims.get("exp").is_none()); +} + +#[test] +fn minting_refuses_a_past_expiry_and_empty_fields() { + assert!(api_key_claims(ISSUER, &request(Some(&rfc3339(now() - 1))), now()).is_err()); + assert!(api_key_claims(ISSUER, &request(Some("tomorrow")), now()).is_err()); + let mut blank = request(None); + blank.jti.clear(); + assert!(api_key_claims(ISSUER, &blank, now()).is_err()); +} + +#[test] +fn only_prefixed_tokens_are_keys() { + assert_eq!(strip_api_key("sck_a.b.c"), Some("a.b.c")); + assert_eq!(strip_api_key("a.b.c"), None); + assert_eq!(strip_api_key("SCK_a.b.c"), None); +} + +// ── verifying ────────────────────────────────────────────────────── + +#[test] +fn a_minted_key_verifies_to_its_record() { + let s = signer("k1"); + let key = mint_api_key(&s, ISSUER, &request(None), now()).unwrap(); + assert!(key.starts_with("sck_eyJ")); + let jwt = strip_api_key(&key).unwrap(); + let verified = verify_api_key(jwt, &own_jwks(&[&s]), ISSUER, &role()).unwrap(); + assert_eq!(verified.account_id, "nightly-sync"); + assert_eq!(verified.jti, "8b1c2d3e"); +} + +#[test] +fn an_expired_key_is_refused() { + let s = signer("k1"); + let then = now() - 7_200; + let key = mint_api_key(&s, ISSUER, &request(Some(&rfc3339(then + 60))), then).unwrap(); + let err = verify_api_key( + strip_api_key(&key).unwrap(), + &own_jwks(&[&s]), + ISSUER, + &role(), + ) + .unwrap_err(); + assert!(err.to_string().contains("expired"), "{err}"); +} + +#[test] +fn a_key_signed_before_a_rotation_verifies_while_the_old_key_is_served() { + let previous = signer("k1"); + let current = signer("k2"); + let key = mint_api_key(&previous, ISSUER, &request(None), now()).unwrap(); + let jwt = strip_api_key(&key).unwrap(); + assert!(verify_api_key(jwt, &own_jwks(&[¤t, &previous]), ISSUER, &role()).is_ok()); + assert!(verify_api_key(jwt, &own_jwks(&[¤t]), ISSUER, &role()).is_err()); +} + +#[test] +fn a_federation_assertion_under_the_same_key_is_not_a_key() { + let s = signer("k1"); + let assertion = s.sign("nightly-sync", ISSUER, ISSUER, &[]).unwrap(); + let err = verify_api_key(&assertion, &own_jwks(&[&s]), ISSUER, &role()).unwrap_err(); + assert!(err.to_string().contains("not an API key"), "{err}"); +} + +#[test] +fn a_key_for_another_issuer_is_refused() { + let s = signer("k1"); + let key = mint_api_key(&s, "https://elsewhere.test", &request(None), now()).unwrap(); + assert!(verify_api_key( + strip_api_key(&key).unwrap(), + &own_jwks(&[&s]), + ISSUER, + &role() + ) + .is_err()); +} + +// ── credentials ──────────────────────────────────────────────────── + +#[test] +fn credentials_are_sealed_for_the_account_within_the_cap() { + let s = signer("k1"); + let key = mint_api_key(&s, ISSUER, &request(None), now()).unwrap(); + let verified = verify_api_key( + strip_api_key(&key).unwrap(), + &own_jwks(&[&s]), + ISSUER, + &role(), + ) + .unwrap(); + let token_key = TokenKey::from_base64(&format!("{}=", "A".repeat(43))).unwrap(); + + let creds = credentials_for(&role(), &verified, Some(10), &token_key).unwrap(); + let unsealed = token_key.unseal(&creds.session_token).unwrap().unwrap(); + assert_eq!(unsealed.source_identity, "nightly-sync"); + assert_eq!(unsealed.assumed_role_id, "_default"); + let lifetime = (creds.expiration - chrono::Utc::now()).num_seconds(); + assert!( + (880..=900).contains(&lifetime), + "floored at 900s, got {lifetime}" + ); +} From e4e418f3aea8cdecf3675e2cd0a3cef2e039c1ef Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 22 Sep 2026 21:17:27 -0700 Subject: [PATCH 2/2] chore(deps): bump rustls to 0.23.45 for RUSTSEC-2026-0285 Lockfile only: the audit job refuses 0.23.42, and nothing here uses TLS directly. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- Cargo.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 611f2afb..b828d1f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,9 +71,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -81,9 +81,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -1431,7 +1431,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1613,9 +1613,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -1665,7 +1665,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1676,9 +1676,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2365,7 +2365,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]]