Skip to main content

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}