protocol_droid/domain/a2a/agent_card/oauth_flow.rs
1//! OAuth 2.0 security scheme and the four flow types the
2//! A2A v1.0 spec inherits from `OpenAPI 3.x`.
3//!
4//! # What this is
5//!
6//! An [`OAuth2SecurityScheme`] declares how a client
7//! authenticates against the agent using OAuth 2.0. The
8//! scheme carries:
9//!
10//! - A [`OAuthFlows`] object with one of the three
11//! spec-supported flows: authorization code, client
12//! credentials, or device code. The proto's
13//! `oneof flow` and the `OpenAPI 3.x` wire form both
14//! say "at least one flow is set".
15//! - An optional `oauth2MetadataUrl` per RFC 8414 — the
16//! URL the client uses to discover the authorization
17//! server's metadata (token endpoint, supported scopes,
18//! PKCE requirements, etc.).
19//!
20//! # Why a struct, not an enum
21//!
22//! The proto declares [`OAuthFlows`] as a `oneof flow`
23//! with five variants (two deprecated). The
24//! `OpenAPI 3.x` wire form, which the A2A spec
25//! follows, is a JSON object with each flow as an
26//! optional field. The Rust shape that round-trips
27//! with the wire form is a struct with three
28//! `Option<...>` fields; the builder enforces
29//! "at least one is set" so the proto's oneof
30//! invariant survives the round-trip.
31//!
32//! # What this commit ships
33//!
34//! - [`OAuth2SecurityScheme`] (the scheme itself)
35//! - [`OAuthFlows`] (the aggregator)
36//! - [`AuthorizationCodeOAuthFlow`],
37//! [`ClientCredentialsOAuthFlow`],
38//! [`DeviceCodeOAuthFlow`] (the three spec-named flows)
39//!
40//! The two deprecated flows the proto names
41//! ([`ImplicitOAuthFlow`], [`PasswordOAuthFlow`]) are
42//! dropped per the proto's `[deprecated = true]`.
43//! A future client that needs to consume a card
44//! with one of those flows can use
45//! `#[serde(other)]` to round-trip without binding
46//! to a typed shape — the cost of modelling them
47//! now is a maintenance burden the spec actively
48//! disclaims.
49
50use std::collections::BTreeMap;
51use std::sync::Arc;
52
53use serde::{Deserialize, Serialize};
54use url::Url;
55
56/// An OAuth 2.0 authentication scheme, as defined in
57/// `OpenAPI 3.x` and RFC 6749.
58///
59/// The wire form is a single JSON object with
60/// `type: "oauth2"`, a `flows` object (one of the three
61/// spec-named flows), and an optional
62/// `oauth2MetadataUrl` per RFC 8414.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct OAuth2SecurityScheme {
66 /// An optional human-readable description of the
67 /// scheme.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub description: Option<Arc<str>>,
70 /// The OAuth flows the agent supports. The
71 /// `OAuthFlows` value must have at least one flow
72 /// set; the builder rejects an empty aggregator.
73 pub flows: OAuthFlows,
74 /// The URL the client uses to fetch the
75 /// authorization server's metadata, per
76 /// RFC 8414. Optional in the spec; absent on the
77 /// wire when the agent does not advertise it.
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub oauth2_metadata_url: Option<Url>,
80}
81
82/// The OAuth flows the agent supports.
83///
84/// The wire form is `OpenAPI 3.x`'s: a JSON object
85/// with three optional fields, at least one of which
86/// must be set. The proto's `oneof flow` is the same
87/// constraint — a `OAuthFlows` value with no flow is
88/// not a valid OAuth 2.0 configuration.
89#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct OAuthFlows {
92 /// The OAuth 2.0 authorization code flow (RFC 6749
93 /// §4.1). The recommended flow for user-agent
94 /// clients.
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub authorization_code: Option<AuthorizationCodeOAuthFlow>,
97 /// The OAuth 2.0 client credentials flow (RFC 6749
98 /// §4.4). The recommended flow for service-to-service
99 /// authentication.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub client_credentials: Option<ClientCredentialsOAuthFlow>,
102 /// The OAuth 2.0 device authorization flow (RFC 8628).
103 /// The recommended flow for input-constrained devices
104 /// like `TV`s, `IoT`, and `CLI` tools.
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub device_code: Option<DeviceCodeOAuthFlow>,
107}
108
109impl OAuthFlows {
110 /// `true` when at least one flow is set. The
111 /// builder calls this to enforce the proto's
112 /// `oneof` invariant at construction.
113 #[must_use]
114 pub const fn has_any(&self) -> bool {
115 self.authorization_code.is_some()
116 || self.client_credentials.is_some()
117 || self.device_code.is_some()
118 }
119}
120
121/// Configuration for the OAuth 2.0 authorization code
122/// flow (RFC 6749 §4.1).
123///
124/// The recommended flow for user-agent clients, and the
125/// only flow that supports `PKCE` (RFC 7636).
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct AuthorizationCodeOAuthFlow {
129 /// The authorization URL. REQUIRED.
130 pub authorization_url: Url,
131 /// The token URL. REQUIRED.
132 pub token_url: Url,
133 /// The refresh URL. Optional in the spec; absent
134 /// on the wire when the agent does not advertise
135 /// a separate refresh endpoint.
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub refresh_url: Option<Url>,
138 /// The available scopes, mapped from scope name
139 /// to a human-readable description. REQUIRED
140 /// (the field is always present on the wire; an
141 /// empty map is valid for an agent that exposes
142 /// no scope checks).
143 #[serde(default)]
144 pub scopes: BTreeMap<Arc<str>, Arc<str>>,
145 /// Whether `PKCE` (RFC 7636) is required. The
146 /// spec recommends `true` for public clients and
147 /// in fact every modern client should use it.
148 #[serde(default)]
149 pub pkce_required: bool,
150}
151
152/// Configuration for the OAuth 2.0 client credentials
153/// flow (RFC 6749 §4.4).
154///
155/// The recommended flow for service-to-service
156/// authentication where the client is itself the
157/// resource owner.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct ClientCredentialsOAuthFlow {
161 /// The token URL. REQUIRED.
162 pub token_url: Url,
163 /// The refresh URL. Optional in the spec; absent
164 /// on the wire when the agent does not advertise
165 /// a separate refresh endpoint.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub refresh_url: Option<Url>,
168 /// The available scopes, mapped from scope name
169 /// to a human-readable description. REQUIRED
170 /// (the field is always present on the wire; an
171 /// empty map is valid for an agent that exposes
172 /// no scope checks).
173 #[serde(default)]
174 pub scopes: BTreeMap<Arc<str>, Arc<str>>,
175}
176
177/// Configuration for the OAuth 2.0 device authorization
178/// flow (RFC 8628).
179///
180/// The recommended flow for input-constrained devices
181/// like `TV`s, `IoT`, and `CLI` tools where the user
182/// authorizes the device from a separate browser session.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct DeviceCodeOAuthFlow {
186 /// The device authorization endpoint URL. REQUIRED.
187 pub device_authorization_url: Url,
188 /// The token URL. REQUIRED.
189 pub token_url: Url,
190 /// The refresh URL. Optional in the spec; absent
191 /// on the wire when the agent does not advertise
192 /// a separate refresh endpoint.
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub refresh_url: Option<Url>,
195 /// The available scopes, mapped from scope name
196 /// to a human-readable description. REQUIRED
197 /// (the field is always present on the wire; an
198 /// empty map is valid for an agent that exposes
199 /// no scope checks).
200 #[serde(default)]
201 pub scopes: BTreeMap<Arc<str>, Arc<str>>,
202}
203
204#[cfg(test)]
205mod tests {
206 use std::collections::BTreeMap;
207 use std::sync::Arc;
208
209 use super::{
210 AuthorizationCodeOAuthFlow, ClientCredentialsOAuthFlow, DeviceCodeOAuthFlow,
211 OAuth2SecurityScheme, OAuthFlows,
212 };
213 use crate::domain::a2a::agent_card::security_scheme::SecurityScheme;
214
215 type TestError = Box<dyn std::error::Error>;
216
217 // --- `OAuthFlows` invariants --------------------------------------
218
219 #[test]
220 fn oauth_flows_has_any_is_false_for_default() {
221 // A `OAuthFlows::default()` has no flow set. The
222 // builder calls `has_any()` to reject this; the
223 // test pins the helper's behaviour.
224 let flows = OAuthFlows::default();
225 assert!(!flows.has_any());
226 }
227
228 #[test]
229 fn oauth_flows_has_any_is_true_when_authorization_code_set() -> Result<(), TestError> {
230 let mut scopes = BTreeMap::new();
231 scopes.insert(Arc::from("read"), Arc::from("Read access"));
232 let flows = OAuthFlows {
233 authorization_code: Some(AuthorizationCodeOAuthFlow {
234 authorization_url: url::Url::parse("https://auth.example.com/authorize")?,
235 token_url: url::Url::parse("https://auth.example.com/token")?,
236 refresh_url: None,
237 scopes: scopes.clone(),
238 pkce_required: true,
239 }),
240 client_credentials: None,
241 device_code: None,
242 };
243 assert!(flows.has_any());
244 Ok(())
245 }
246
247 #[test]
248 fn oauth_flows_serialises_with_camel_case_keys() -> Result<(), TestError> {
249 // The wire form is OpenAPI's: `authorizationCode`,
250 // `clientCredentials`, `deviceCode`. A typo in
251 // the `rename_all` would compile but break the
252 // wire form.
253 let mut scopes = BTreeMap::new();
254 scopes.insert(Arc::from("read"), Arc::from("Read access"));
255 let flows = OAuthFlows {
256 authorization_code: Some(AuthorizationCodeOAuthFlow {
257 authorization_url: url::Url::parse("https://auth.example.com/authorize")?,
258 token_url: url::Url::parse("https://auth.example.com/token")?,
259 refresh_url: None,
260 scopes: scopes.clone(),
261 pkce_required: true,
262 }),
263 client_credentials: None,
264 device_code: Some(DeviceCodeOAuthFlow {
265 device_authorization_url: url::Url::parse("https://auth.example.com/device")?,
266 token_url: url::Url::parse("https://auth.example.com/token")?,
267 refresh_url: None,
268 scopes,
269 }),
270 };
271 let value = serde_json::to_value(&flows)?;
272 let Some(obj) = value.as_object() else {
273 return Err("OAuthFlows must serialise to a JSON object"
274 .to_owned()
275 .into());
276 };
277 assert!(obj.contains_key("authorizationCode"));
278 assert!(!obj.contains_key("clientCredentials"));
279 assert!(obj.contains_key("deviceCode"));
280 Ok(())
281 }
282
283 // --- `OAuth2SecurityScheme` wire form ----------------------------
284
285 #[test]
286 fn oauth2_security_scheme_serialises_with_openapi_wire_form() -> Result<(), TestError> {
287 // Wire form: `{"type": "oauth2", "flows": {...},
288 // "oauth2MetadataUrl": "..."}`.
289 let mut scopes = BTreeMap::new();
290 scopes.insert(Arc::from("read"), Arc::from("Read access"));
291 let scheme = SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
292 description: Some(Arc::from("OAuth 2.0 with authorization code.")),
293 flows: OAuthFlows {
294 authorization_code: Some(AuthorizationCodeOAuthFlow {
295 authorization_url: url::Url::parse("https://auth.example.com/authorize")?,
296 token_url: url::Url::parse("https://auth.example.com/token")?,
297 refresh_url: None,
298 scopes: scopes.clone(),
299 pkce_required: true,
300 }),
301 client_credentials: None,
302 device_code: None,
303 },
304 oauth2_metadata_url: Some(url::Url::parse(
305 "https://auth.example.com/.well-known/oauth-authorization-server",
306 )?),
307 }));
308 let value = serde_json::to_value(&scheme)?;
309 let Some(obj) = value.as_object() else {
310 return Err("a SecurityScheme must serialise to a JSON object"
311 .to_owned()
312 .into());
313 };
314 assert_eq!(
315 obj.get("type").and_then(serde_json::Value::as_str),
316 Some("oauth2")
317 );
318 let Some(flows_obj) = obj.get("flows").and_then(serde_json::Value::as_object) else {
319 return Err("flows must be a JSON object".to_owned().into());
320 };
321 let Some(ac) = flows_obj
322 .get("authorizationCode")
323 .and_then(serde_json::Value::as_object)
324 else {
325 return Err("authorizationCode must be a JSON object".to_owned().into());
326 };
327 assert_eq!(
328 ac.get("authorizationUrl")
329 .and_then(serde_json::Value::as_str),
330 Some("https://auth.example.com/authorize")
331 );
332 assert_eq!(
333 ac.get("tokenUrl").and_then(serde_json::Value::as_str),
334 Some("https://auth.example.com/token")
335 );
336 assert_eq!(
337 ac.get("pkceRequired").and_then(serde_json::Value::as_bool),
338 Some(true)
339 );
340 let Some(scopes_obj) = ac.get("scopes").and_then(serde_json::Value::as_object) else {
341 return Err("scopes must be a JSON object".to_owned().into());
342 };
343 assert_eq!(
344 scopes_obj.get("read").and_then(serde_json::Value::as_str),
345 Some("Read access")
346 );
347 assert_eq!(
348 obj.get("oauth2MetadataUrl")
349 .and_then(serde_json::Value::as_str),
350 Some("https://auth.example.com/.well-known/oauth-authorization-server")
351 );
352 Ok(())
353 }
354
355 #[test]
356 fn oauth2_security_scheme_round_trips_through_serde() -> Result<(), TestError> {
357 let mut scopes = BTreeMap::new();
358 scopes.insert(Arc::from("read"), Arc::from("Read access"));
359 scopes.insert(Arc::from("write"), Arc::from("Write access"));
360 let scheme = SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
361 description: None,
362 flows: OAuthFlows {
363 authorization_code: None,
364 client_credentials: Some(ClientCredentialsOAuthFlow {
365 token_url: url::Url::parse("https://auth.example.com/token")?,
366 refresh_url: Some(url::Url::parse("https://auth.example.com/refresh")?),
367 scopes,
368 }),
369 device_code: None,
370 },
371 oauth2_metadata_url: None,
372 }));
373 let json = serde_json::to_string(&scheme)?;
374 let parsed: SecurityScheme = serde_json::from_str(&json)?;
375 assert_eq!(parsed, scheme);
376 Ok(())
377 }
378}