Skip to main content

multistore_sts/
lib.rs

1//! OIDC/STS authentication for the S3 proxy gateway.
2//!
3//! This crate implements the `AssumeRoleWithWebIdentity` STS API, allowing
4//! workloads like GitHub Actions to exchange OIDC tokens for temporary S3
5//! credentials scoped to specific buckets and prefixes. It also implements
6//! `GetCallerIdentity` (see [`caller_identity`]), which standard AWS tooling —
7//! notably `aws-actions/configure-aws-credentials` — calls to validate assumed
8//! credentials, so the endpoint is a drop-in STS target.
9//!
10//! # Integration
11//!
12//! Register STS routes via [`route_handler::StsRouterExt`]:
13//!
14//! ```rust,ignore
15//! use multistore_sts::route_handler::StsRouterExt;
16//!
17//! let router = Router::new()
18//!     .with_sts("/.sts", config, jwks_cache, token_key);
19//! ```
20//!
21//! # Flow
22//!
23//! 1. Client obtains a JWT from their OIDC provider (e.g., GitHub Actions ID token)
24//! 2. Client calls `AssumeRoleWithWebIdentity` with the JWT and desired role
25//! 3. This crate validates the JWT against the OIDC provider's JWKS
26//! 4. Checks trust policy (issuer, audience, subject conditions)
27//! 5. Mints temporary credentials (AccessKeyId/SecretAccessKey/SessionToken)
28//! 6. Returns credentials to the client
29//!
30//! The client then uses these credentials to sign S3 requests normally.
31
32pub mod caller_identity;
33pub mod jwks;
34pub mod request;
35pub mod responses;
36pub mod route_handler;
37pub mod sealed_token;
38pub mod sts;
39
40pub use caller_identity::handle_get_caller_identity;
41pub use jwks::JwksCache;
42use multistore::error::ProxyError;
43use multistore::registry::CredentialRegistry;
44use multistore::types::TemporaryCredentials;
45use request::StsRequest;
46pub use request::{is_get_caller_identity, try_parse_sts_request};
47pub use responses::{build_caller_identity_response, build_sts_error_response, build_sts_response};
48pub use sealed_token::TokenKey;
49
50/// Try to handle an STS request. Returns `Some((status, xml))` if the query
51/// string or form-encoded body contained an STS action, or `None` if it
52/// wasn't an STS request.
53///
54/// AWS STS accepts `AssumeRoleWithWebIdentity` parameters either in the query
55/// string or as an `application/x-www-form-urlencoded` `POST` body — SDKs send
56/// the latter — so both are checked, query first. Parameters are read from
57/// exactly one source, never merged across the two.
58///
59/// Requires a `TokenKey` — minted credentials are encrypted into the session
60/// token itself, so no server-side storage is needed. If `token_key` is `None`
61/// and an STS request arrives, an error response is returned.
62pub async fn try_handle_sts<C: CredentialRegistry>(
63    query: Option<&str>,
64    form_body: Option<&str>,
65    config: &C,
66    jwks_cache: &JwksCache,
67    token_key: Option<&TokenKey>,
68) -> Option<(u16, String)> {
69    let sts_result = try_parse_sts_request(query).or_else(|| try_parse_sts_request(form_body))?;
70    let (status, xml) = match sts_result {
71        Ok(sts_request) => {
72            let Some(key) = token_key else {
73                tracing::error!("STS request received but SESSION_TOKEN_KEY is not configured");
74                return Some(build_sts_error_response(&ProxyError::ConfigError(
75                    "STS requires SESSION_TOKEN_KEY to be configured".into(),
76                )));
77            };
78            match assume_role_with_web_identity(config, &sts_request, "STSPRXY", jwks_cache, key)
79                .await
80            {
81                Ok(creds) => build_sts_response(&creds),
82                Err(e) => {
83                    tracing::warn!(error = %e, "STS request failed");
84                    build_sts_error_response(&e)
85                }
86            }
87        }
88        Err(e) => build_sts_error_response(&e),
89    };
90    Some((status, xml))
91}
92
93/// Decode JWT header and claims without signature verification.
94fn jwt_decode_unverified(
95    token: &str,
96) -> Result<(serde_json::Value, serde_json::Value), ProxyError> {
97    let mut parts = token.splitn(3, '.');
98    let header_b64 = parts
99        .next()
100        .ok_or_else(|| ProxyError::InvalidOidcToken("malformed JWT".into()))?;
101    let payload_b64 = parts
102        .next()
103        .ok_or_else(|| ProxyError::InvalidOidcToken("malformed JWT".into()))?;
104
105    Ok((
106        jwks::decode_jwt_segment(header_b64)?,
107        jwks::decode_jwt_segment(payload_b64)?,
108    ))
109}
110
111/// Validate an OIDC token and mint temporary credentials.
112///
113/// Credentials are encrypted into a self-contained session token via `token_key`.
114/// No server-side credential storage is needed.
115pub async fn assume_role_with_web_identity<C: CredentialRegistry>(
116    config: &C,
117    sts_request: &StsRequest,
118    key_prefix: &str,
119    jwks_cache: &JwksCache,
120    token_key: &TokenKey,
121) -> Result<TemporaryCredentials, ProxyError> {
122    // Look up the role
123    let role = config
124        .get_role(&sts_request.role_arn)
125        .await?
126        .ok_or_else(|| ProxyError::RoleNotFound(sts_request.role_arn.to_string()))?;
127
128    // Decode the JWT header and claims without verification to extract issuer and kid
129    let (header, insecure_claims) = jwt_decode_unverified(&sts_request.web_identity_token)?;
130
131    let issuer = insecure_claims
132        .get("iss")
133        .and_then(|v| v.as_str())
134        .ok_or_else(|| ProxyError::InvalidOidcToken("missing iss claim".into()))?;
135
136    // Verify the issuer is trusted
137    if !role.trusted_oidc_issuers.iter().any(|i| i == issuer) {
138        return Err(ProxyError::InvalidOidcToken(format!(
139            "untrusted issuer: {}",
140            issuer
141        )));
142    }
143
144    // Fail fast on unsupported algorithms before making any network requests
145    let alg = header.get("alg").and_then(|v| v.as_str()).unwrap_or("");
146    if alg != "RS256" {
147        return Err(ProxyError::InvalidOidcToken(format!(
148            "unsupported JWT algorithm: {}",
149            alg
150        )));
151    }
152
153    // Fetch JWKS (using cache) and verify the token
154    let jwks = jwks_cache.get_or_fetch(issuer).await?;
155    let kid = header
156        .get("kid")
157        .and_then(|v| v.as_str())
158        .ok_or_else(|| ProxyError::InvalidOidcToken("JWT missing kid".into()))?;
159
160    let key = jwks::find_key(&jwks, kid)?;
161    let claims = jwks::verify_token(&sts_request.web_identity_token, key, issuer, &role)?;
162
163    let subject = claims.get("sub").and_then(|v| v.as_str()).unwrap_or("");
164    if !subject_allowed(subject, &role.subject_conditions) {
165        return Err(ProxyError::InvalidOidcToken(format!(
166            "subject '{}' does not match any conditions",
167            subject
168        )));
169    }
170
171    // Mint temporary credentials (AWS enforces 900s minimum)
172    const MIN_SESSION_DURATION_SECS: u64 = 900;
173    let duration = sts_request
174        .duration_seconds
175        .unwrap_or(3600)
176        .clamp(MIN_SESSION_DURATION_SECS, role.max_session_duration_secs);
177
178    let mut creds = sts::mint_temporary_credentials(&role, subject, duration, key_prefix, &claims)?;
179
180    // Encrypt the full credentials into the session token — stateless, no storage needed
181    creds.session_token = token_key.seal(&creds)?;
182
183    tracing::info!(
184        issuer,
185        subject,
186        role = %role.role_id,
187        duration_secs = duration,
188        "STS exchange succeeded"
189    );
190
191    Ok(creds)
192}
193
194/// Whether `subject` matches at least one of `conditions`. An empty list
195/// matches nothing: "any subject" has to be said, with `"*"`, so that a role
196/// which forgot its conditions fails closed rather than open.
197fn subject_allowed(subject: &str, conditions: &[String]) -> bool {
198    conditions
199        .iter()
200        .any(|pattern| subject_matches(subject, pattern))
201}
202
203/// Simple glob-style matching for subject conditions.
204/// Supports `*` as a wildcard for any sequence of characters.
205fn subject_matches(subject: &str, pattern: &str) -> bool {
206    if pattern == "*" {
207        return true;
208    }
209
210    let parts: Vec<&str> = pattern.split('*').collect();
211    if parts.len() == 1 {
212        return subject == pattern;
213    }
214
215    let mut remaining = subject;
216
217    // First part must be a prefix
218    if !parts[0].is_empty() {
219        if !remaining.starts_with(parts[0]) {
220            return false;
221        }
222        remaining = &remaining[parts[0].len()..];
223    }
224
225    // Middle parts must appear in order
226    for part in &parts[1..parts.len() - 1] {
227        if part.is_empty() {
228            continue;
229        }
230        match remaining.find(part) {
231            Some(idx) => remaining = &remaining[idx + part.len()..],
232            None => return false,
233        }
234    }
235
236    // Last part must be a suffix
237    let last = parts.last().unwrap();
238    if !last.is_empty() {
239        return remaining.ends_with(last);
240    }
241
242    true
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn no_subject_conditions_means_no_subject_is_allowed() {
251        assert!(!subject_allowed("repo:org/repo:ref:refs/heads/main", &[]));
252        assert!(subject_allowed("anything", &["*".to_string()]));
253        assert!(subject_allowed(
254            "repo:org/repo:ref:refs/heads/main",
255            &["repo:other/*".to_string(), "repo:org/*".to_string()]
256        ));
257    }
258
259    #[test]
260    fn test_subject_matching() {
261        // Trailing wildcard
262        assert!(subject_matches(
263            "repo:org/repo:ref:refs/heads/main",
264            "repo:org/repo:*"
265        ));
266
267        // Match-all wildcard
268        assert!(subject_matches("repo:org/repo:ref:refs/heads/main", "*"));
269
270        // Exact match (no wildcards)
271        assert!(subject_matches(
272            "repo:org/repo:ref:refs/heads/main",
273            "repo:org/repo:ref:refs/heads/main"
274        ));
275
276        // Wrong prefix
277        assert!(!subject_matches(
278            "repo:org/repo:ref:refs/heads/main",
279            "repo:other/*"
280        ));
281
282        // Multiple wildcards
283        assert!(subject_matches(
284            "repo:org/repo:ref:refs/heads/main",
285            "repo:org/*:ref:refs/heads/*"
286        ));
287    }
288
289    #[test]
290    fn test_subject_matching_exact() {
291        assert!(subject_matches("abc", "abc"));
292        assert!(!subject_matches("abc", "abcd"));
293        assert!(!subject_matches("abcd", "abc"));
294        assert!(!subject_matches("", "abc"));
295        assert!(subject_matches("", ""));
296    }
297
298    #[test]
299    fn test_subject_matching_leading_wildcard() {
300        assert!(subject_matches("anything", "*"));
301        assert!(subject_matches("", "*"));
302        assert!(subject_matches("foo", "*foo"));
303        assert!(subject_matches("xfoo", "*foo"));
304        assert!(!subject_matches("foox", "*foo"));
305    }
306
307    #[test]
308    fn test_subject_matching_trailing_wildcard() {
309        assert!(subject_matches("foo", "foo*"));
310        assert!(subject_matches("foobar", "foo*"));
311        assert!(!subject_matches("xfoo", "foo*"));
312    }
313
314    #[test]
315    fn test_subject_matching_middle_wildcard() {
316        assert!(subject_matches("foobar", "foo*bar"));
317        assert!(subject_matches("fooXbar", "foo*bar"));
318        assert!(subject_matches("fooXYZbar", "foo*bar"));
319        assert!(!subject_matches("fooXbaz", "foo*bar"));
320        assert!(!subject_matches("xfoobar", "foo*bar"));
321    }
322
323    #[test]
324    fn test_subject_matching_multiple_wildcards() {
325        // Two wildcards with repeated literal
326        assert!(subject_matches("axbb", "a*b*b"));
327        assert!(!subject_matches("axb", "a*b*b"));
328
329        // Wildcard must not overlap with suffix
330        assert!(!subject_matches("abc", "a*bc*c"));
331        assert!(subject_matches("abcc", "a*bc*c"));
332
333        // Multiple wildcards requiring non-greedy left-to-right match
334        assert!(subject_matches("aab", "*a*ab"));
335        assert!(!subject_matches("xab", "*a*ab"));
336
337        // Repeated pattern in subject
338        assert!(subject_matches("xababab", "*ab*ab"));
339        assert!(!subject_matches("xab", "*ab*ab"));
340    }
341
342    #[test]
343    fn test_subject_matching_double_wildcard() {
344        assert!(subject_matches("anything", "**"));
345        assert!(subject_matches("", "**"));
346    }
347
348    #[test]
349    fn test_subject_matching_empty_subject() {
350        assert!(subject_matches("", "*"));
351        assert!(!subject_matches("", "a"));
352        assert!(subject_matches("", ""));
353    }
354}