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