From 83b6891c0355c2cadb38f827cb36e3b7b63c1486 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 21 Sep 2026 17:07:18 -0700 Subject: [PATCH 1/4] feat(sts)!: fail closed on empty trust fields, check the token type, require exp, log successes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty required_audiences accepted any audience and an empty subject_conditions skipped the subject check, so a role that omitted either — and both default to empty — trusted everything its issuer signed. Both now accept nothing; "any subject" is written "*", and static-config validation rejects a role with either list empty so the omission is reported at load rather than discovered at exchange. A token whose header carries typ must say JWT: access tokens (at+jwt) and other typed tokens are not identity tokens, whoever signed them. A token must carry exp, unless its issuer is listed in the new RoleConfig.allow_missing_exp_from — for a host's own long-lived tokens whose validity it tracks itself. A third-party token with no expiry is an indefinitely replayable credential its issuer never meant to issue. Successful exchanges are logged at info with issuer, subject, role and duration; before only failures were. Claim validation is factored out of verify_token into validate_claims, taking the clock, so each rule is tested without a key pair. BREAKING CHANGE: a role must set required_audiences and subject_conditions (use ["*"] to accept any subject); RoleConfig gains allow_missing_exp_from, which struct-literal constructors must add. Refs #143 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- crates/core/src/types.rs | 24 ++++-- crates/static-config/src/lib.rs | 27 +++++- crates/sts/README.md | 5 +- crates/sts/src/jwks.rs | 143 +++++++++++++++++++++++++++---- crates/sts/src/lib.rs | 45 +++++++--- docs/auth/proxy-auth.md | 6 +- docs/configuration/roles.md | 13 +-- docs/reference/config-example.md | 2 + examples/server/config.toml | 3 + 9 files changed, 221 insertions(+), 47 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 1c82624..395c160 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -126,8 +126,10 @@ pub struct RoleConfig { pub trusted_oidc_issuers: Vec, /// Audience claim values accepted for this role. A token is accepted if its - /// `aud` claim matches any entry; empty (or absent/null) means no audience - /// restriction. Accepts a single string or a list, and the legacy + /// `aud` claim matches any entry. Empty (or absent/null) accepts **no** + /// token: the audience is what keeps a token minted for another service + /// from being exchanged here, so a role without one is misconfigured, not + /// open. Accepts a single string or a list, and the legacy /// `required_audience` key, for backward compatibility — set one key or the /// other, not both (specifying both is a config error). #[serde( @@ -137,11 +139,21 @@ pub struct RoleConfig { )] pub required_audiences: Vec, - /// Conditions on the subject claim (glob patterns). - /// e.g., "repo:myorg/myrepo:ref:refs/heads/main" + /// Conditions on the subject claim (glob patterns), e.g. + /// `"repo:myorg/myrepo:ref:refs/heads/main"`. A token's `sub` must match at + /// least one. Empty accepts **no** subject; to accept every subject, say + /// so with `"*"`. #[serde(default)] pub subject_conditions: Vec, + /// Issuers whose tokens may omit `exp`, because the host tracks their + /// validity itself — its own long-lived API keys with server-side + /// revocation, say. Tokens from every other issuer must carry `exp`: a + /// third-party token with no expiry is an indefinitely replayable + /// credential its issuer never meant to issue. + #[serde(default)] + pub allow_missing_exp_from: Vec, + /// Buckets and prefixes this role can access. #[serde(default)] pub allowed_scopes: Vec, @@ -164,8 +176,8 @@ where Many(Vec), } // `Option` so an explicit `null` (e.g. legacy `required_audience: null`) - // maps to "unrestricted", matching the old `Option` behavior - // instead of failing to parse. + // parses as an empty list — which accepts no token — rather than failing + // to parse; config validation is what reports it. Ok(match Option::::deserialize(deserializer)? { None => vec![], Some(OneOrMany::One(s)) => vec![s], diff --git a/crates/static-config/src/lib.rs b/crates/static-config/src/lib.rs index 4f71ce0..75d3557 100644 --- a/crates/static-config/src/lib.rs +++ b/crates/static-config/src/lib.rs @@ -69,6 +69,18 @@ impl StaticConfig { role.role_id )); } + if role.required_audiences.is_empty() { + errors.push(format!( + "role {:?} has no required_audiences (will never accept a token)", + role.role_id + )); + } + if role.subject_conditions.is_empty() { + errors.push(format!( + "role {:?} has no subject_conditions (will never accept a token; use \"*\" for any subject)", + role.role_id + )); + } } // Check credentials @@ -284,8 +296,9 @@ mod tests { role_id: "my-role".into(), name: "My Role".into(), trusted_oidc_issuers: vec!["https://issuer.example.com".into()], - required_audiences: vec![], - subject_conditions: vec![], + required_audiences: vec!["my-audience".into()], + subject_conditions: vec!["*".into()], + allow_missing_exp_from: vec![], allowed_scopes: vec![], max_session_duration_secs: 3600, }], @@ -358,6 +371,16 @@ mod tests { assert!(err.contains("no trusted_oidc_issuers"), "{}", err); } + #[test] + fn test_empty_audiences_and_subject_conditions_are_rejected() { + let mut config = valid_config(); + config.roles[0].required_audiences.clear(); + config.roles[0].subject_conditions.clear(); + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("no required_audiences"), "{}", err); + assert!(err.contains("no subject_conditions"), "{}", err); + } + #[test] fn test_empty_access_key_id() { let mut config = valid_config(); diff --git a/crates/sts/README.md b/crates/sts/README.md index 297dadb..6cde12f 100644 --- a/crates/sts/README.md +++ b/crates/sts/README.md @@ -28,6 +28,7 @@ Client signs S3 requests with temp creds Roles define who can assume them: - **`trusted_oidc_issuers`** — accepted OIDC providers (e.g., `https://token.actions.githubusercontent.com`) -- **`required_audiences`** — accepted `aud` claim values (string or list); a token passes if its `aud` matches any. Empty/omitted means unrestricted. Legacy `required_audience` (single string) still accepted. -- **`subject_conditions`** — glob patterns for the `sub` claim (e.g., `repo:myorg/*`) +- **`required_audiences`** — accepted `aud` claim values (string or list); a token passes if its `aud` matches any. Empty/omitted accepts no token. Legacy `required_audience` (single string) still accepted. +- **`subject_conditions`** — glob patterns for the `sub` claim (e.g., `repo:myorg/*`). Empty accepts no subject; `"*"` accepts any. +- **`allow_missing_exp_from`** — issuers whose tokens may omit `exp` because the host tracks their validity; every other issuer's tokens must carry it - **`allowed_scopes`** — buckets, prefixes, and actions the minted credentials grant diff --git a/crates/sts/src/jwks.rs b/crates/sts/src/jwks.rs index 0bce3c7..816af0f 100644 --- a/crates/sts/src/jwks.rs +++ b/crates/sts/src/jwks.rs @@ -122,12 +122,12 @@ fn rsa_public_key_from_components(n: &str, e: &str) -> Result, accepted: &[String]) -> bool { if accepted.is_empty() { - return true; + return false; } match aud_claim { Some(serde_json::Value::String(aud)) => accepted.iter().any(|a| a == aud), @@ -184,10 +184,40 @@ pub fn verify_token( ProxyError::InvalidOidcToken(format!("JWT signature verification failed: {}", e)) })?; - // Decode and validate claims let claims = decode_jwt_segment(payload_b64)?; + validate_claims( + &header, + &claims, + issuer, + role, + chrono::Utc::now().timestamp(), + )?; + Ok(claims) +} + +/// Validate a signature-verified token's header and claims against `role`. +/// +/// Kept apart from signature verification so every rule here is testable +/// without a key pair; `verify_token` calls it once the signature checks out. +fn validate_claims( + header: &serde_json::Value, + claims: &serde_json::Value, + issuer: &str, + role: &RoleConfig, + now: i64, +) -> Result<(), ProxyError> { + // A token that says what it is must say it is a JWT. Access tokens + // (`at+jwt`) and other typed tokens are not identity tokens, whoever + // signed them. + if let Some(typ) = header.get("typ").and_then(|v| v.as_str()) { + if !typ.eq_ignore_ascii_case("JWT") { + return Err(ProxyError::InvalidOidcToken(format!( + "unsupported token type: {}", + typ + ))); + } + } - // Validate issuer let token_issuer = claims.get("iss").and_then(|v| v.as_str()).unwrap_or(""); if token_issuer != issuer { return Err(ProxyError::InvalidOidcToken(format!( @@ -196,7 +226,6 @@ pub fn verify_token( ))); } - // Validate audience if restricted. if !audience_allowed(claims.get("aud"), &role.required_audiences) { return Err(ProxyError::InvalidOidcToken(format!( "audience mismatch: expected one of {:?}", @@ -204,13 +233,21 @@ pub fn verify_token( ))); } - // Validate time-based claims with clock skew tolerance - let now = chrono::Utc::now().timestamp(); const CLOCK_SKEW_SECS: i64 = 60; - if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) { - if now > exp + CLOCK_SKEW_SECS { - return Err(ProxyError::InvalidOidcToken("token has expired".into())); + match claims.get("exp").and_then(|v| v.as_i64()) { + Some(exp) => { + if now > exp + CLOCK_SKEW_SECS { + return Err(ProxyError::InvalidOidcToken("token has expired".into())); + } + } + // Only an issuer the host vouches for may leave expiry to the host. + None => { + if !role.allow_missing_exp_from.iter().any(|i| i == issuer) { + return Err(ProxyError::InvalidOidcToken( + "token has no exp claim".into(), + )); + } } } @@ -222,7 +259,7 @@ pub fn verify_token( } } - Ok(claims) + Ok(()) } /// In-memory cache for JWKS responses, keyed by issuer URL. @@ -319,13 +356,85 @@ impl JwksCache { #[cfg(test)] mod tests { - use super::audience_allowed; + use super::{audience_allowed, validate_claims}; + use multistore::types::RoleConfig; use serde_json::json; + const ISSUER: &str = "https://issuer.example"; + const NOW: i64 = 1_700_000_000; + + fn role() -> RoleConfig { + RoleConfig { + role_id: "r".into(), + name: "r".into(), + trusted_oidc_issuers: vec![ISSUER.into()], + required_audiences: vec!["aud".into()], + subject_conditions: vec!["*".into()], + allow_missing_exp_from: vec![], + allowed_scopes: vec![], + max_session_duration_secs: 3600, + } + } + + fn claims() -> serde_json::Value { + json!({"iss": ISSUER, "aud": "aud", "sub": "s", "exp": NOW + 300}) + } + + #[test] + fn empty_accepted_denies() { + assert!(!audience_allowed(None, &[])); + assert!(!audience_allowed(Some(&json!("anything")), &[])); + } + + #[test] + fn a_well_formed_token_passes() { + validate_claims(&json!({"typ": "JWT"}), &claims(), ISSUER, &role(), NOW).unwrap(); + validate_claims(&json!({}), &claims(), ISSUER, &role(), NOW).unwrap(); + } + + #[test] + fn a_typed_non_jwt_is_rejected() { + let err = validate_claims(&json!({"typ": "at+jwt"}), &claims(), ISSUER, &role(), NOW) + .unwrap_err() + .to_string(); + assert!(err.contains("unsupported token type"), "{}", err); + } + + #[test] + fn a_role_with_no_audience_accepts_nothing() { + let mut role = role(); + role.required_audiences.clear(); + let err = validate_claims(&json!({}), &claims(), ISSUER, &role, NOW) + .unwrap_err() + .to_string(); + assert!(err.contains("audience mismatch"), "{}", err); + } + + #[test] + fn exp_is_required_unless_the_issuer_is_exempt() { + let mut claims = claims(); + claims.as_object_mut().unwrap().remove("exp"); + + let err = validate_claims(&json!({}), &claims, ISSUER, &role(), NOW) + .unwrap_err() + .to_string(); + assert!(err.contains("no exp claim"), "{}", err); + + let mut exempt = role(); + exempt.allow_missing_exp_from = vec![ISSUER.into()]; + validate_claims(&json!({}), &claims, ISSUER, &exempt, NOW).unwrap(); + } + #[test] - fn empty_accepted_means_no_restriction() { - assert!(audience_allowed(None, &[])); - assert!(audience_allowed(Some(&json!("anything")), &[])); + fn an_expired_token_is_rejected_beyond_the_skew() { + let mut claims = claims(); + claims["exp"] = json!(NOW - 30); + validate_claims(&json!({}), &claims, ISSUER, &role(), NOW).unwrap(); + claims["exp"] = json!(NOW - 61); + let err = validate_claims(&json!({}), &claims, ISSUER, &role(), NOW) + .unwrap_err() + .to_string(); + assert!(err.contains("expired"), "{}", err); } #[test] diff --git a/crates/sts/src/lib.rs b/crates/sts/src/lib.rs index d18c205..f238eae 100644 --- a/crates/sts/src/lib.rs +++ b/crates/sts/src/lib.rs @@ -155,20 +155,12 @@ pub async fn assume_role_with_web_identity( let key = jwks::find_key(&jwks, kid)?; let claims = jwks::verify_token(&sts_request.web_identity_token, key, issuer, &role)?; - // Check subject conditions let subject = claims.get("sub").and_then(|v| v.as_str()).unwrap_or(""); - - if !role.subject_conditions.is_empty() { - let matches = role - .subject_conditions - .iter() - .any(|pattern| subject_matches(subject, pattern)); - if !matches { - return Err(ProxyError::InvalidOidcToken(format!( - "subject '{}' does not match any conditions", - subject - ))); - } + if !subject_allowed(subject, &role.subject_conditions) { + return Err(ProxyError::InvalidOidcToken(format!( + "subject '{}' does not match any conditions", + subject + ))); } // Mint temporary credentials (AWS enforces 900s minimum) @@ -183,9 +175,26 @@ pub async fn assume_role_with_web_identity( // Encrypt the full credentials into the session token — stateless, no storage needed creds.session_token = token_key.seal(&creds)?; + tracing::info!( + issuer, + subject, + role = %role.role_id, + duration_secs = duration, + "STS exchange succeeded" + ); + Ok(creds) } +/// Whether `subject` matches at least one of `conditions`. An empty list +/// matches nothing: "any subject" has to be said, with `"*"`, so that a role +/// which forgot its conditions fails closed rather than open. +fn subject_allowed(subject: &str, conditions: &[String]) -> bool { + conditions + .iter() + .any(|pattern| subject_matches(subject, pattern)) +} + /// Simple glob-style matching for subject conditions. /// Supports `*` as a wildcard for any sequence of characters. fn subject_matches(subject: &str, pattern: &str) -> bool { @@ -232,6 +241,16 @@ fn subject_matches(subject: &str, pattern: &str) -> bool { mod tests { use super::*; + #[test] + fn no_subject_conditions_means_no_subject_is_allowed() { + assert!(!subject_allowed("repo:org/repo:ref:refs/heads/main", &[])); + assert!(subject_allowed("anything", &["*".to_string()])); + assert!(subject_allowed( + "repo:org/repo:ref:refs/heads/main", + &["repo:other/*".to_string(), "repo:org/*".to_string()] + )); + } + #[test] fn test_subject_matching() { // Trailing wildcard diff --git a/docs/auth/proxy-auth.md b/docs/auth/proxy-auth.md index 2019394..674d022 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -80,8 +80,10 @@ When a client calls `AssumeRoleWithWebIdentity`: 3. The proxy fetches the issuer's JWKS endpoint and verifies the JWT signature (RS256) 4. The proxy evaluates the trust policy: - **Issuer**: must be in the role's `trusted_oidc_issuers` - - **Audience**: if the role's `required_audiences` is non-empty, the token's `aud` claim must match at least one of the accepted values - - **Subject**: the token's `sub` claim must match at least one of the role's `subject_conditions` (supports `*` glob wildcards) + - **Token type**: if the header carries `typ`, it must be `JWT` + - **Audience**: the token's `aud` claim must match at least one of the role's `required_audiences`; a role with none accepts no token + - **Expiry**: the token must carry `exp`, unless its issuer is in the role's `allow_missing_exp_from` + - **Subject**: the token's `sub` claim must match at least one of the role's `subject_conditions` (supports `*` glob wildcards); a role with none accepts no subject 5. The proxy mints temporary credentials scoped to the role's `allowed_scopes` 6. If `SESSION_TOKEN_KEY` is configured, the credentials are AES-256-GCM encrypted into the session token (see [Sealed Session Tokens](./sealed-tokens)) 7. The proxy returns the credentials in an XML response matching the AWS STS format diff --git a/docs/configuration/roles.md b/docs/configuration/roles.md index 4e652d9..5359af3 100644 --- a/docs/configuration/roles.md +++ b/docs/configuration/roles.md @@ -34,8 +34,9 @@ actions = ["get_object", "head_object"] | `role_id` | string | Yes | Identifier used as the `RoleArn` in STS requests | | `name` | string | Yes | Human-readable display name | | `trusted_oidc_issuers` | string[] | Validated as required | OIDC provider URLs whose tokens are accepted. Deserializes fine when absent, but config validation rejects a role with no issuers (it could never accept a token). | -| `required_audiences` | string \| string[] | No | Accepted `aud` claim values. A token passes if its `aud` matches any entry; empty or omitted means no audience restriction. Accepts a single string or a list. The legacy `required_audience` key (single string) is still accepted for backward compatibility — set one key or the other, not both. | -| `subject_conditions` | string[] | No | Glob patterns matched against the `sub` claim. When omitted or empty, the subject check is skipped entirely and all subjects match. | +| `required_audiences` | string \| string[] | Validated as required | Accepted `aud` claim values. A token passes if its `aud` matches any entry. Empty or omitted accepts no token — the audience is what keeps a token minted for another service from being exchanged here — and config validation rejects the role. Accepts a single string or a list. The legacy `required_audience` key (single string) is still accepted for backward compatibility — set one key or the other, not both. | +| `subject_conditions` | string[] | Validated as required | Glob patterns matched against the `sub` claim. Empty or omitted accepts no subject, and config validation rejects the role; to accept every subject, say so with `"*"`. | +| `allow_missing_exp_from` | string[] | No | Issuers whose tokens may omit `exp` because the host tracks their validity itself — its own long-lived API keys with server-side revocation, say. Tokens from every other issuer must carry `exp`. | | `max_session_duration_secs` | integer | Yes | Maximum session lifetime granted by this role | | `allowed_scopes` | AccessScope[] | Yes | Buckets, prefixes, and actions granted | @@ -46,8 +47,10 @@ When a client calls `AssumeRoleWithWebIdentity`, the proxy evaluates the JWT aga 1. **Issuer** — The JWT's `iss` claim must match one of `trusted_oidc_issuers` 2. **Algorithm** — Only RS256 is supported 3. **Signature** — Verified against the issuer's JWKS (fetched and cached) -4. **Audience** — If `required_audiences` is non-empty, the JWT's `aud` claim must match at least one of the accepted values -5. **Subject** — If `subject_conditions` is non-empty, the JWT's `sub` claim must match at least one pattern. If it is empty (or omitted), the subject check is skipped and all subjects pass. +4. **Token type** — If the JWT header carries `typ`, it must be `JWT`; access tokens (`at+jwt`) and other typed tokens are not identity tokens +5. **Audience** — The JWT's `aud` claim must match at least one of `required_audiences`; a role with none accepts no token +6. **Expiry** — The JWT must carry `exp` (validated with 60 seconds of clock skew), unless its issuer is listed in `allow_missing_exp_from` +7. **Subject** — The JWT's `sub` claim must match at least one of `subject_conditions`; a role with none accepts no subject If any check fails, the STS request returns an error. @@ -64,7 +67,7 @@ subject_conditions = [ ] ``` -The `sub` claim only needs to match one of the patterns. If `subject_conditions` is omitted or left empty, the subject check is skipped entirely and every subject is accepted. +The `sub` claim only needs to match one of the patterns. An empty list matches nothing — "any subject" is written `"*"`, so that a role which forgot its conditions fails closed rather than open. ## Session Duration diff --git a/docs/reference/config-example.md b/docs/reference/config-example.md index f04a4c6..22901c6 100644 --- a/docs/reference/config-example.md +++ b/docs/reference/config-example.md @@ -102,6 +102,7 @@ actions = [ role_id = "user-role" name = "User Role" trusted_oidc_issuers = ["https://auth.example.com"] +required_audiences = ["s3proxy.example.com"] subject_conditions = ["*"] # Any subject max_session_duration_secs = 3600 @@ -116,6 +117,7 @@ actions = ["get_object", "head_object", "put_object", "list_bucket"] role_id = "ci-readonly" name = "CI Read-Only Role" trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audiences = ["sts.s3proxy.example.com"] subject_conditions = ["repo:myorg/*"] # Any repo in the org max_session_duration_secs = 1800 # 30 minutes diff --git a/examples/server/config.toml b/examples/server/config.toml index d90b1fd..1193019 100644 --- a/examples/server/config.toml +++ b/examples/server/config.toml @@ -29,6 +29,7 @@ skip_signature = "true" role_id = "github-actions" name = "GitHub Actions" trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["repo:developmentseed/multistore:*"] max_session_duration_secs = 3600 @@ -42,6 +43,7 @@ actions = ["get_object", "head_object", "list_bucket"] role_id = "default" name = "Source Cooperative User" trusted_oidc_issuers = ["https://auth.staging.source.coop"] +required_audiences = ["https://data.staging.source.coop"] subject_conditions = ["*"] max_session_duration_secs = 3600 @@ -65,5 +67,6 @@ actions = [ role_id = "github-actions-no-access" name = "GitHub Actions (No Access)" trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["repo:alukach/not-this-repo:*"] max_session_duration_secs = 3600 From 342d7b60d7ad61a3039ab129c1a63db008318859 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 21 Sep 2026 17:07:22 -0700 Subject: [PATCH 2/4] fix(sts): refuse to mint when a scope template names a claim the token lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_scopes substituted an empty string for a missing or non-string claim. For a bucket that fails safe; for a prefix an empty string matches every key, so a missing claim silently granted the whole bucket — the opposite of what the doc comment promised. An unresolvable template is now an error at mint time, and mint_temporary_credentials returns Result. Closes #143 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- crates/sts/src/lib.rs | 2 +- crates/sts/src/sts.rs | 91 ++++++++++++++++++++----------------- docs/configuration/roles.md | 2 +- 3 files changed, 51 insertions(+), 44 deletions(-) diff --git a/crates/sts/src/lib.rs b/crates/sts/src/lib.rs index f238eae..4b5716f 100644 --- a/crates/sts/src/lib.rs +++ b/crates/sts/src/lib.rs @@ -170,7 +170,7 @@ pub async fn assume_role_with_web_identity( .unwrap_or(3600) .clamp(MIN_SESSION_DURATION_SECS, role.max_session_duration_secs); - let mut creds = sts::mint_temporary_credentials(&role, subject, duration, key_prefix, &claims); + let mut creds = sts::mint_temporary_credentials(&role, subject, duration, key_prefix, &claims)?; // Encrypt the full credentials into the session token — stateless, no storage needed creds.session_token = token_key.seal(&creds)?; diff --git a/crates/sts/src/sts.rs b/crates/sts/src/sts.rs index 7ec6e24..0e3621a 100644 --- a/crates/sts/src/sts.rs +++ b/crates/sts/src/sts.rs @@ -1,76 +1,85 @@ //! STS credential minting. use chrono::{Duration, Utc}; +use multistore::error::ProxyError; use multistore::types::{AccessScope, RoleConfig, TemporaryCredentials}; use rand::RngCore; /// Resolve `{claim_name}` template variables in access scopes against JWT claims. /// /// Each `{name}` in `bucket` or `prefixes` is replaced with the corresponding -/// string claim value. Missing or non-string claims resolve to an empty string, -/// which will safely fail authorization downstream. -fn resolve_scopes(scopes: &[AccessScope], claims: &serde_json::Value) -> Vec { +/// string claim value. A claim that is missing or not a string is an error: +/// an empty prefix matches every key in the bucket, so a template that cannot +/// be resolved must not mint anything. +fn resolve_scopes( + scopes: &[AccessScope], + claims: &serde_json::Value, +) -> Result, ProxyError> { scopes .iter() .map(|scope| { - let bucket = resolve_template(&scope.bucket, claims); - let prefixes = scope - .prefixes - .iter() - .map(|p| resolve_template(p, claims)) - .collect(); - AccessScope { - bucket, - prefixes, + Ok(AccessScope { + bucket: resolve_template(&scope.bucket, claims)?, + prefixes: scope + .prefixes + .iter() + .map(|p| resolve_template(p, claims)) + .collect::>()?, actions: scope.actions.clone(), - } + }) }) .collect() } /// Replace all `{key}` placeholders in `template` with values from `claims`. -fn resolve_template(template: &str, claims: &serde_json::Value) -> String { +fn resolve_template(template: &str, claims: &serde_json::Value) -> Result { let mut result = template.to_string(); - // Find all {…} placeholders and replace them while let Some(start) = result.find('{') { - if let Some(end) = result[start..].find('}') { - let end = start + end; - let key = &result[start + 1..end]; - let value = claims.get(key).and_then(|v| v.as_str()).unwrap_or(""); - result = format!("{}{}{}", &result[..start], value, &result[end + 1..]); - } else { + let Some(end) = result[start..].find('}') else { break; - } + }; + let end = start + end; + let key = &result[start + 1..end]; + let value = claims.get(key).and_then(|v| v.as_str()).ok_or_else(|| { + ProxyError::InvalidOidcToken(format!( + "token has no string claim '{}', which an access scope requires", + key + )) + })?; + result = format!("{}{}{}", &result[..start], value, &result[end + 1..]); } - result + Ok(result) } /// Mint a new set of temporary credentials for an assumed role. /// /// Template variables (`{claim_name}`) in `role.allowed_scopes` are resolved -/// against the provided JWT `claims` before being stored in the credentials. +/// against the provided JWT `claims` before being stored in the credentials; +/// a claim the template needs but the token lacks is an error, not an empty +/// scope. pub fn mint_temporary_credentials( role: &RoleConfig, source_identity: &str, duration_seconds: u64, key_prefix: &str, claims: &serde_json::Value, -) -> TemporaryCredentials { +) -> Result { + let allowed_scopes = resolve_scopes(&role.allowed_scopes, claims)?; let access_key_id = format!("{}{}", key_prefix, generate_random_id(16)); let secret_access_key = generate_random_id(40); let session_token = generate_session_token(); let expiration = Utc::now() + Duration::seconds(duration_seconds as i64); - TemporaryCredentials { + Ok(TemporaryCredentials { access_key_id, secret_access_key, session_token, expiration, - allowed_scopes: resolve_scopes(&role.allowed_scopes, claims), + allowed_scopes, assumed_role_id: role.role_id.clone(), source_identity: source_identity.to_string(), - } + }) } fn generate_random_id(len: usize) -> String { @@ -111,7 +120,7 @@ mod tests { fn resolve_template_in_bucket() { let scopes = vec![scope("{sub}", &[], &[Action::GetObject])]; let claims = json!({"sub": "alice"}); - let resolved = resolve_scopes(&scopes, &claims); + let resolved = resolve_scopes(&scopes, &claims).unwrap(); assert_eq!(resolved[0].bucket, "alice"); } @@ -119,7 +128,7 @@ mod tests { fn resolve_template_in_prefix() { let scopes = vec![scope("my-bucket", &["data/{sub}/"], &[Action::GetObject])]; let claims = json!({"sub": "alice"}); - let resolved = resolve_scopes(&scopes, &claims); + let resolved = resolve_scopes(&scopes, &claims).unwrap(); assert_eq!(resolved[0].prefixes[0], "data/alice/"); } @@ -127,7 +136,7 @@ mod tests { fn resolve_multiple_claims() { let scopes = vec![scope("{org}", &["{sub}/"], &[Action::GetObject])]; let claims = json!({"sub": "alice", "org": "acme"}); - let resolved = resolve_scopes(&scopes, &claims); + let resolved = resolve_scopes(&scopes, &claims).unwrap(); assert_eq!(resolved[0].bucket, "acme"); assert_eq!(resolved[0].prefixes[0], "alice/"); } @@ -136,21 +145,19 @@ mod tests { fn no_templates_unchanged() { let scopes = vec![scope("static-bucket", &["prefix/"], &[Action::GetObject])]; let claims = json!({"sub": "alice"}); - let resolved = resolve_scopes(&scopes, &claims); + let resolved = resolve_scopes(&scopes, &claims).unwrap(); assert_eq!(resolved[0].bucket, "static-bucket"); assert_eq!(resolved[0].prefixes[0], "prefix/"); } #[test] - fn missing_claim_resolves_to_empty() { - let scopes = vec![scope( - "{missing}", - &["{also_missing}/"], - &[Action::GetObject], - )]; - let claims = json!({"sub": "alice"}); - let resolved = resolve_scopes(&scopes, &claims); - assert_eq!(resolved[0].bucket, ""); - assert_eq!(resolved[0].prefixes[0], "/"); + fn missing_claim_is_an_error_not_an_empty_scope() { + // An empty prefix matches every key, so a claim the template needs + // but the token lacks must refuse to mint rather than widen. + let scopes = vec![scope("bucket", &["{org}/"], &[Action::GetObject])]; + let claims = json!({"sub": "alice", "org": 7}); + let err = resolve_scopes(&scopes, &claims).unwrap_err().to_string(); + assert!(err.contains("no string claim 'org'"), "{}", err); + assert!(resolve_scopes(&scopes, &json!({"sub": "alice"})).is_err()); } } diff --git a/docs/configuration/roles.md b/docs/configuration/roles.md index 5359af3..77909cb 100644 --- a/docs/configuration/roles.md +++ b/docs/configuration/roles.md @@ -135,7 +135,7 @@ actions = ["get_object", "head_object", "put_object", "list_bucket"] A user with `sub = "alice"` receives credentials scoped to `bucket = "alice"`. Any string claim from the JWT can be referenced — `{email}`, `{org}`, etc. -Missing or non-string claims resolve to an empty string, which safely fails authorization. +A claim the template names that is missing from the token, or is not a string, is an error at mint time: an empty prefix would match every key in the bucket, so an unresolvable template refuses to mint rather than widen. ### Examples From 2a985be0c13f5a5098c8bbde16352abc7cc7a7bf Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 21 Sep 2026 17:23:27 -0700 Subject: [PATCH 3/4] feat(oidc-provider): sign a JWT from caller-supplied claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JwtSigner::sign always dates the token itself: fresh jti, nbf, and exp at now plus the signer's ttl. A host that issues long-lived API keys with server-side revocation needs to choose those claims itself — a jti it stores to revoke against, and an exp the key's owner sets or leaves out. sign_claims signs exactly the object it is given, under the same key and kid, and adds nothing. sign is unchanged; both share the encoding and signing tail. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- crates/oidc-provider/src/jwt.rs | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/oidc-provider/src/jwt.rs b/crates/oidc-provider/src/jwt.rs index 2f435e5..ed8fc0f 100644 --- a/crates/oidc-provider/src/jwt.rs +++ b/crates/oidc-provider/src/jwt.rs @@ -84,13 +84,39 @@ impl JwtSigner { } let payload_b64 = b64.encode(payload.to_string().as_bytes()); - // Sign + Ok(self.sign_encoded(&header_b64, &payload_b64)) + } + + /// Sign a JWT whose claims the caller supplies in full. + /// + /// For tokens whose `sub`, `jti` and expiry — or lack of one — are the + /// caller's to decide: a host's long-lived API keys with server-side + /// revocation, say. Nothing is added or checked; `sign` remains the path + /// for the proxy's own short-lived assertions, which it dates itself. + pub fn sign_claims(&self, claims: &serde_json::Value) -> Result { + if !claims.is_object() { + return Err(OidcProviderError::KeyError( + "JWT claims must be a JSON object".into(), + )); + } + let b64 = &base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = serde_json::json!({ + "alg": "RS256", + "typ": "JWT", + "kid": self.kid, + }); + let header_b64 = b64.encode(header.to_string().as_bytes()); + let payload_b64 = b64.encode(claims.to_string().as_bytes()); + Ok(self.sign_encoded(&header_b64, &payload_b64)) + } + + fn sign_encoded(&self, header_b64: &str, payload_b64: &str) -> String { + let b64 = &base64::engine::general_purpose::URL_SAFE_NO_PAD; let signing_input = format!("{header_b64}.{payload_b64}"); let signing_key = SigningKey::::new(self.private_key.clone()); let signature = signing_key.sign(signing_input.as_bytes()); let sig_b64 = b64.encode(signature.to_bytes()); - - Ok(format!("{signing_input}.{sig_b64}")) + format!("{signing_input}.{sig_b64}") } } @@ -98,6 +124,38 @@ impl JwtSigner { mod tests { use super::*; + #[test] + fn sign_claims_signs_exactly_the_claims_given() { + let pem = test_key_pem(); + let signer = JwtSigner::from_pem(&pem, "test-kid".into(), 300).unwrap(); + let claims = serde_json::json!({ + "iss": "https://proxy.example.com", + "sub": "nightly-sync", + "jti": "my-own-id", + "type": "api_key" + }); + let token = signer.sign_claims(&claims).unwrap(); + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3); + + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); + assert_eq!(payload, claims, "no claim added, none dropped"); + assert!(payload.get("exp").is_none()); + + let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[0]) + .unwrap(); + let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); + assert_eq!(header["kid"], "test-kid"); + + assert!(signer + .sign_claims(&serde_json::json!("not an object")) + .is_err()); + } + fn test_key_pem() -> String { // Generate a small RSA key for testing use rsa::pkcs8::EncodePrivateKey; From ea81c52f58a6836f205ff9d737095c262e62a1cf Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 24 Sep 2026 08:49:24 -0700 Subject: [PATCH 4/4] fix(cf-workers): set required_audiences on example worker roles Config validation now rejects roles without required_audiences, so the worker failed to start on the preview deploy. GitHub roles use sts.amazonaws.com (what the smoke/integration tests request); the source.coop role uses https://data.staging.source.coop. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/cf-workers/wrangler.deploy.toml | 3 +++ examples/cf-workers/wrangler.integration.toml | 2 ++ examples/cf-workers/wrangler.toml | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/examples/cf-workers/wrangler.deploy.toml b/examples/cf-workers/wrangler.deploy.toml index d218145..5cbc2ee 100644 --- a/examples/cf-workers/wrangler.deploy.toml +++ b/examples/cf-workers/wrangler.deploy.toml @@ -105,6 +105,7 @@ actions = ["get_object", "head_object", "list_bucket"] max_session_duration_secs = 3600 name = "GitHub Actions" role_id = "github-actions" +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["repo:developmentseed/multistore:*"] trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com", @@ -136,6 +137,7 @@ prefixes = [] max_session_duration_secs = 3600 name = "Default User Access" role_id = "default" +required_audiences = ["https://data.staging.source.coop"] subject_conditions = ["*"] trusted_oidc_issuers = [ "https://auth.staging.source.coop", @@ -160,6 +162,7 @@ prefixes = [] max_session_duration_secs = 3600 name = "GitHub Actions (No Access)" role_id = "github-actions-no-access" +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["repo:developmentseed/multistore:*"] trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com", diff --git a/examples/cf-workers/wrangler.integration.toml b/examples/cf-workers/wrangler.integration.toml index b1e2466..5c725aa 100644 --- a/examples/cf-workers/wrangler.integration.toml +++ b/examples/cf-workers/wrangler.integration.toml @@ -85,6 +85,7 @@ prefixes = ["allowed/"] max_session_duration_secs = 3600 name = "GitHub Actions" role_id = "github-actions" +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["*"] trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com", @@ -104,6 +105,7 @@ prefixes = [] max_session_duration_secs = 3600 name = "GitHub Actions (No Access)" role_id = "github-actions-no-access" +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["*"] trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com", diff --git a/examples/cf-workers/wrangler.toml b/examples/cf-workers/wrangler.toml index dca4bc2..f46b709 100644 --- a/examples/cf-workers/wrangler.toml +++ b/examples/cf-workers/wrangler.toml @@ -120,6 +120,7 @@ prefixes = [] max_session_duration_secs = 3600 name = "Example User" role_id = "example-user" +required_audiences = ["https://api.example.com"] subject_conditions = ["*"] trusted_oidc_issuers = [ "https://auth.example.com", @@ -139,6 +140,7 @@ prefixes = [] max_session_duration_secs = 3600 name = "GitHub Actions" role_id = "github-actions" +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["repo:developmentseed/multistore:*"] trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com", @@ -153,6 +155,7 @@ prefixes = [] max_session_duration_secs = 3600 name = "Default User Access" role_id = "default" +required_audiences = ["https://data.staging.source.coop"] subject_conditions = ["*"] trusted_oidc_issuers = [ "https://auth.staging.source.coop", @@ -177,6 +180,7 @@ prefixes = [] max_session_duration_secs = 3600 name = "GitHub Actions (No Access)" role_id = "github-actions-no-access" +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["repo:developmentseed/multistore:*"] trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com",