1pub 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
50pub 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
93fn 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
111pub 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 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 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 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 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 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 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 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
194fn subject_allowed(subject: &str, conditions: &[String]) -> bool {
198 conditions
199 .iter()
200 .any(|pattern| subject_matches(subject, pattern))
201}
202
203fn 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 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 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 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 assert!(subject_matches(
263 "repo:org/repo:ref:refs/heads/main",
264 "repo:org/repo:*"
265 ));
266
267 assert!(subject_matches("repo:org/repo:ref:refs/heads/main", "*"));
269
270 assert!(subject_matches(
272 "repo:org/repo:ref:refs/heads/main",
273 "repo:org/repo:ref:refs/heads/main"
274 ));
275
276 assert!(!subject_matches(
278 "repo:org/repo:ref:refs/heads/main",
279 "repo:other/*"
280 ));
281
282 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 assert!(subject_matches("axbb", "a*b*b"));
327 assert!(!subject_matches("axb", "a*b*b"));
328
329 assert!(!subject_matches("abc", "a*bc*c"));
331 assert!(subject_matches("abcc", "a*bc*c"));
332
333 assert!(subject_matches("aab", "*a*ab"));
335 assert!(!subject_matches("xab", "*a*ab"));
336
337 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}