1use crate::config::{HarnessConfig, WorkloadMapping};
16use crate::pipeline::AppState;
17use axum::body::Bytes;
18use axum::http::StatusCode;
19use axum::response::{IntoResponse, Response};
20use axum::Json;
21use serde_json::json;
22use std::sync::Arc;
23
24pub fn issuer(config: &HarnessConfig) -> String {
26 format!("{}#workload", config.audience)
27}
28
29pub fn trust_gateway_tokens(
34 validator: &mut av_identity::IdentityValidator,
35 config: &HarnessConfig,
36) -> Result<(), String> {
37 if config.workload_identity.is_none() {
38 return Ok(());
39 }
40 let seed_path = config
41 .token_exchange_seed_file
42 .as_deref()
43 .ok_or_else(|| "[workload_identity] requires token_exchange_seed_file".to_owned())?;
44 let seed = crate::pipeline::load_exchange_seed(seed_path)?;
45 let signer = crate::authz::TokenSigner::from_seed(&seed);
46 validator
47 .add_key(
48 signer.kid(),
49 av_identity::KeyMaterial::Ed25519Jwk(signer.public_key_b64url()),
50 )
51 .map_err(|error| format!("trust the workload token key: {error}"))?;
52 if !config.identity_allowed_issuers.is_empty() {
53 let mut issuers = config.identity_allowed_issuers.clone();
54 issuers.push(issuer(config));
55 validator.allow_issuers(issuers);
56 }
57 Ok(())
58}
59
60pub struct WorkloadIssuer {
62 trust: av_identity::WorkloadTrust,
63 mappings: Vec<WorkloadMapping>,
64 token_ttl_s: u64,
65}
66
67impl WorkloadIssuer {
68 pub fn from_config(config: &HarnessConfig) -> Result<Option<Self>, String> {
70 let Some(settings) = &config.workload_identity else {
71 return Ok(None);
72 };
73 let pem = std::fs::read(&settings.ca_file)
74 .map_err(|error| format!("read workload_identity.ca_file {:?}: {error}", settings.ca_file))?;
75 let trust = av_identity::WorkloadTrust::from_pem(&pem).map_err(|error| error.to_string())?;
76 Ok(Some(Self {
77 trust,
78 mappings: config.workload_identities.clone(),
79 token_ttl_s: settings.token_ttl_s,
80 }))
81 }
82
83 fn mapping_for(
85 &self,
86 identity: &av_identity::CfInstanceIdentity,
87 ) -> Result<&WorkloadMapping, &'static str> {
88 let mut matches = self.mappings.iter().filter(|mapping| mapping.selects(identity));
89 match (matches.next(), matches.next()) {
90 (Some(mapping), None) => Ok(mapping),
91 (None, _) => Err("unregistered"),
92 (Some(_), Some(_)) => Err("ambiguous"),
93 }
94 }
95}
96
97fn oauth_error(status: StatusCode, error: &str, description: &str) -> Response {
98 let mut response = (
99 status,
100 Json(json!({"error": error, "error_description": description})),
101 )
102 .into_response();
103 response.headers_mut().insert(
104 axum::http::header::CACHE_CONTROL,
105 axum::http::HeaderValue::from_static("no-store"),
106 );
107 response
108}
109
110fn rejected(state: &AppState, reason: &str) {
111 state
112 .metrics
113 .counter(
114 &format!("av_workload_assertions_rejected_total{{reason=\"{reason}\"}}"),
115 "Workload identity assertions refused at /v1/token, by reason",
116 )
117 .inc();
118}
119
120#[derive(serde::Deserialize)]
121struct GrantForm {
122 #[allow(dead_code)]
123 grant_type: String,
124 assertion: String,
125 #[serde(default)]
126 scope: Option<String>,
127}
128
129pub(crate) fn grant(state: &AppState, body: &Bytes) -> Response {
131 let Some(issuer_state) = state.workload.as_ref() else {
132 return oauth_error(
133 StatusCode::BAD_REQUEST,
134 "unsupported_grant_type",
135 "this gateway does not accept workload assertions",
136 );
137 };
138 let Ok(form) = serde_urlencoded::from_bytes::<GrantForm>(body) else {
139 return oauth_error(
140 StatusCode::BAD_REQUEST,
141 "invalid_request",
142 "the jwt-bearer grant needs an assertion",
143 );
144 };
145 let now_s = av_core::time::now_ms() / 1000;
146 let verified = match issuer_state
147 .trust
148 .verify(&form.assertion, &state.config.audience, now_s)
149 {
150 Ok(verified) => verified,
151 Err(error) => {
152 let reason = match &error {
153 av_identity::WorkloadError::Chain(_) => "chain",
154 av_identity::WorkloadError::Signature => "signature",
155 av_identity::WorkloadError::Replay => "replay",
156 av_identity::WorkloadError::Claims(_) => "claims",
157 av_identity::WorkloadError::Subject(_) => "subject",
158 _ => "malformed",
159 };
160 rejected(state, reason);
161 tracing::warn!(target: "agentvisor.workload", %error, "workload assertion refused");
162 return oauth_error(
164 StatusCode::BAD_REQUEST,
165 "invalid_grant",
166 "the workload assertion was not accepted",
167 );
168 }
169 };
170 let mapping = match issuer_state.mapping_for(&verified.identity) {
171 Ok(mapping) => mapping,
172 Err(reason) => {
173 rejected(state, reason);
174 tracing::warn!(
175 target: "agentvisor.workload",
176 app = %verified.identity.app_guid,
177 space = %verified.identity.space_guid,
178 org = %verified.identity.org_guid,
179 reason,
180 "workload has no single [[workload_identities]] entry"
181 );
182 return oauth_error(
183 StatusCode::BAD_REQUEST,
184 "invalid_grant",
185 "this workload is not registered with the gateway",
186 );
187 }
188 };
189 let scopes = match form.scope.as_deref() {
190 None => mapping.scopes.clone(),
191 Some(requested) => {
192 let requested: Vec<String> = requested
193 .split(' ')
194 .filter(|s| !s.is_empty())
195 .map(str::to_owned)
196 .collect();
197 let granted = av_identity::scope_intersection(&requested, &mapping.scopes);
198 if granted.is_empty() {
199 rejected(state, "scope");
200 return oauth_error(
201 StatusCode::BAD_REQUEST,
202 "invalid_scope",
203 "no requested scope is granted to this workload",
204 );
205 }
206 granted
207 }
208 };
209 let Some(signer) = state.token_signer.as_ref() else {
210 return oauth_error(
211 StatusCode::INTERNAL_SERVER_ERROR,
212 "server_error",
213 "workload identity is enabled but no signing key is configured",
214 );
215 };
216 let ttl = issuer_state.token_ttl_s.min(av_identity::MAX_TTL_SECS);
217 let claims = av_identity::NhiClaims {
218 sub: mapping.sub.clone(),
219 iss: issuer(&state.config),
220 aud: av_identity::Audience::Single(state.config.audience.clone()),
221 iat: now_s,
222 nbf: Some(now_s),
223 exp: now_s.saturating_add(ttl),
224 jti: av_core::ids::new_event_uid(),
225 azp: Some(format!("cf-app:{}", verified.identity.app_guid)),
226 act: None,
227 instance_uid: verified.identity.instance_guid.clone(),
228 charter: mapping.charter.clone(),
229 version: mapping.version.clone(),
230 scopes: scopes.clone(),
231 parent_token: None,
232 };
233 let access_token = match signer.sign(&claims) {
234 Ok(token) => token,
235 Err(error) => {
236 tracing::error!(%error, "workload token signing failed");
237 return oauth_error(
238 StatusCode::INTERNAL_SERVER_ERROR,
239 "server_error",
240 "token signing failed",
241 );
242 }
243 };
244 state
245 .metrics
246 .counter(
247 &format!("av_workload_tokens_issued_total{{workload=\"{}\"}}", mapping.name),
248 "Agent tokens issued to platform workloads, by workload",
249 )
250 .inc();
251 tracing::info!(
252 target: "agentvisor.workload",
253 workload = %mapping.name,
254 sub = %mapping.sub,
255 instance = %verified.identity.instance_guid,
256 app = %verified.identity.app_guid,
257 jti = %claims.jti,
258 "issued an agent token to a platform workload"
259 );
260 let mut response = Json(json!({
261 "access_token": access_token,
262 "token_type": "Bearer",
263 "issued_token_type": av_identity::exchange::JWT_TOKEN_TYPE,
264 "expires_in": ttl,
265 "scope": scopes.join(" "),
266 }))
267 .into_response();
268 for (name, value) in [("cache-control", "no-store"), ("pragma", "no-cache")] {
269 response.headers_mut().insert(
270 axum::http::HeaderName::from_static(name),
271 axum::http::HeaderValue::from_static(value),
272 );
273 }
274 response
275}
276
277pub type SharedWorkloadIssuer = Option<Arc<WorkloadIssuer>>;