Skip to main content

protocol_droid/domain/a2a/agent_card/
security_scheme.rs

1//! Authentication and authorization schemes the agent declares.
2//!
3//! A2A v1.0 §5.4 specifies that [`AgentCard::security_schemes`]
4//! is a map from scheme name (the caller's chosen handle) to
5//! the scheme itself. The wire form follows the `OpenAPI 3.x`
6//! security scheme convention: each scheme is a JSON object
7//! with a `type` field that discriminates the variant.
8//!
9//! # Why `serde(tag = "type")`
10//!
11//! The proto declares a `oneof scheme { ... }` with five
12//! variants, but the spec's wire form is `OpenAPI`'s, which
13//! tags the variant by a string in the `type` field
14//! (`"apiKey"`, `"http"`, `"oauth2"`, `"openIdConnect"`,
15//! `"mutualTls"`). Serde's `#[serde(tag = "type")]` is the
16//! right shape: a single JSON object per scheme, with the
17//! `type` field set to the variant name and the rest of the
18//! fields filled in by the chosen variant.
19//!
20//! # Out of scope this commit
21//!
22//! - `OAuth2SecurityScheme` and the four `OAuthFlows`
23//!   sub-types — substantial, get their own commit.
24//! - `OpenIdConnectSecurityScheme` — small, but depends on
25//!   the `OpenID Connect` URL parsing rules.
26//! - `MutualTlsSecurityScheme` — marker type, also its own
27//!   commit so the `type: "mutualTls"` wire form gets a
28//!   dedicated test.
29//!
30//! This commit ships the [`SecurityScheme`] enum and the
31//! two simple variants — API key and HTTP — so the
32//! consumer-side happy path is unblocked while the larger
33//! work lands.
34
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38use url::Url;
39
40/// An authentication or authorization scheme the agent
41/// declares.
42///
43/// The wire form is `OpenAPI 3.x`: a single JSON object
44/// with a `type` field that names the variant. The five
45/// variants map to the proto's `oneof scheme`; this
46/// commit ships the final two, [`OpenIdConnect`](Self::OpenIdConnect)
47/// and [`MutualTls`](Self::MutualTls), completing the
48/// set the spec names.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "type", rename_all = "camelCase")]
51pub enum SecurityScheme {
52    /// API key authentication. The key is carried in a
53    /// query parameter, a header, or a cookie, named by
54    /// [`APIKeySecurityScheme::name`].
55    #[serde(rename = "apiKey")]
56    ApiKey(APIKeySecurityScheme),
57    /// HTTP authentication, as defined in RFC 7235. The
58    /// most common value is `Bearer` (RFC 6750); `Basic`
59    /// is also standard.
60    #[serde(rename = "http")]
61    Http(HTTPAuthSecurityScheme),
62    /// OAuth 2.0 authentication (RFC 6749). The variant
63    /// carries the [`OAuth2SecurityScheme`](crate::domain::a2a::agent_card::oauth_flow::OAuth2SecurityScheme) payload
64    /// (flows + RFC 8414 metadata URL) boxed to keep the
65    /// enum's total size down — the OAuth 2 sub-types
66    /// are several hundred bytes, and the `ApiKey` /
67    /// `Http` variants are forty.
68    #[serde(rename = "oauth2")]
69    OAuth2(Box<super::oauth_flow::OAuth2SecurityScheme>),
70    /// `OpenID Connect` authentication (the discovery
71    /// URL is the entry point the client fetches to
72    /// learn the OAuth 2 endpoints the OIDC provider
73    /// supports).
74    #[serde(rename = "openIdConnect")]
75    OpenIdConnect(OpenIdConnectSecurityScheme),
76    /// Mutual TLS authentication (RFC 8705). The agent
77    /// authenticates the client by validating the
78    /// client's X.509 certificate against a configured
79    /// trust anchor; no per-request credentials are
80    /// sent.
81    #[serde(rename = "mutualTls")]
82    MutualTls(MutualTlsSecurityScheme),
83}
84
85/// An `OpenID Connect` discovery scheme.
86///
87/// The wire form is `OpenAPI 3.x`'s: a JSON object with
88/// `type: "openIdConnect"` and an `openIdConnectUrl`
89/// the client uses to discover the provider's
90/// configuration (per the
91/// `OpenID Connect Discovery 1.0` spec).
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct OpenIdConnectSecurityScheme {
95    /// An optional human-readable description of the
96    /// scheme.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub description: Option<Arc<str>>,
99    /// The `OpenID Connect` discovery URL. REQUIRED.
100    pub open_id_connect_url: Url,
101}
102
103/// A mutual-TLS authentication scheme (RFC 8705).
104///
105/// The wire form is `OpenAPI 3.x`'s: a JSON object with
106/// `type: "mutualTls"` and an optional description.
107/// The client presents an X.509 certificate at TLS
108/// handshake; the agent validates the certificate
109/// against its configured trust anchors. No per-request
110/// `Authorization` header is sent — the TLS handshake
111/// is the authentication.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct MutualTlsSecurityScheme {
115    /// An optional human-readable description of the
116    /// scheme.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub description: Option<Arc<str>>,
119}
120
121/// An API-key-based authentication scheme.
122///
123/// The wire form is `OpenAPI`'s: a JSON object with `type:
124/// "apiKey"`, `in: <location>`, and `name: <parameter>`.
125/// The `in` field is `OpenAPI`'s name for what the proto
126/// calls `location`; the rename is in [`APIKeySecurityScheme::in_`].
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct APIKeySecurityScheme {
130    /// An optional human-readable description of the
131    /// scheme.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub description: Option<Arc<str>>,
134    /// Where the API key is carried. Wire form is
135    /// `OpenAPI`'s `in`; the Rust field is `in_` because
136    /// `in` is a reserved keyword.
137    #[serde(rename = "in")]
138    pub in_: APIKeyLocation,
139    /// The name of the header, query parameter, or cookie
140    /// the key is carried in. REQUIRED, non-empty.
141    pub name: Arc<str>,
142}
143
144/// Where an API key is carried in a request.
145///
146/// The wire form is `OpenAPI`'s: one of `"query"`, `"header"`,
147/// or `"cookie"`. The proto's `string location` accepts the
148/// same three values; the enum rejects anything else at
149/// the call site that constructs the value.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum APIKeyLocation {
153    /// The key is a query parameter
154    /// (e.g. `?api_key=...`).
155    Query,
156    /// The key is an HTTP request header
157    /// (e.g. `X-API-Key: ...`).
158    Header,
159    /// The key is a cookie
160    /// (e.g. `Cookie: session=...`).
161    Cookie,
162}
163
164/// An HTTP authentication scheme, as defined in RFC 7235.
165///
166/// The most common value is `Bearer` (RFC 6750); `Basic`
167/// is also standard. The `scheme` field is the
168/// `IANA`-registered value the agent expects in the
169/// `Authorization` header.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct HTTPAuthSecurityScheme {
173    /// An optional human-readable description of the
174    /// scheme.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub description: Option<Arc<str>>,
177    /// The IANA-registered authentication scheme name
178    /// (e.g. `Bearer`, `Basic`). REQUIRED, non-empty.
179    pub scheme: Arc<str>,
180    /// A hint to the client about how the bearer token
181    /// is formatted (e.g. `JWT`). Primarily for
182    /// documentation; clients do not parse it.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub bearer_format: Option<Arc<str>>,
185}
186
187#[cfg(test)]
188mod tests {
189    use std::sync::Arc;
190
191    use super::{
192        APIKeyLocation, APIKeySecurityScheme, HTTPAuthSecurityScheme, MutualTlsSecurityScheme,
193        OpenIdConnectSecurityScheme, SecurityScheme,
194    };
195
196    type TestError = Box<dyn std::error::Error>;
197
198    // --- API key -----------------------------------------------------
199
200    #[test]
201    fn api_key_serialises_with_openapi_wire_form() -> Result<(), TestError> {
202        let scheme = SecurityScheme::ApiKey(APIKeySecurityScheme {
203            description: None,
204            in_: APIKeyLocation::Header,
205            name: Arc::from("X-API-Key"),
206        });
207        let value = serde_json::to_value(&scheme)?;
208        let Some(obj) = value.as_object() else {
209            return Err("a SecurityScheme must serialise to a JSON object"
210                .to_owned()
211                .into());
212        };
213        assert_eq!(
214            obj.get("type").and_then(serde_json::Value::as_str),
215            Some("apiKey"),
216            "the type field must be the OpenAPI literal \"apiKey\""
217        );
218        assert_eq!(
219            obj.get("in").and_then(serde_json::Value::as_str),
220            Some("header"),
221            "the in field must be the OpenAPI name for location"
222        );
223        assert_eq!(
224            obj.get("name").and_then(serde_json::Value::as_str),
225            Some("X-API-Key")
226        );
227        assert!(
228            !obj.contains_key("description"),
229            "an absent description must not serialise as null"
230        );
231        Ok(())
232    }
233
234    #[test]
235    fn api_key_round_trips_through_serde() -> Result<(), TestError> {
236        let scheme = SecurityScheme::ApiKey(APIKeySecurityScheme {
237            description: Some(Arc::from("API key for the public read endpoint.")),
238            in_: APIKeyLocation::Query,
239            name: Arc::from("api_key"),
240        });
241        let json = serde_json::to_string(&scheme)?;
242        let parsed: SecurityScheme = serde_json::from_str(&json)?;
243        assert_eq!(parsed, scheme);
244        Ok(())
245    }
246
247    #[test]
248    fn api_key_location_serialises_as_openapi_literal() {
249        // OpenAPI's three valid values: "query", "header",
250        // "cookie". A typo in the enum's `rename_all` would
251        // pass the enum tests but break the wire form.
252        let query = serde_json::to_value(APIKeyLocation::Query).ok();
253        let header = serde_json::to_value(APIKeyLocation::Header).ok();
254        let cookie = serde_json::to_value(APIKeyLocation::Cookie).ok();
255        assert_eq!(query.as_ref().and_then(|v| v.as_str()), Some("query"));
256        assert_eq!(header.as_ref().and_then(|v| v.as_str()), Some("header"));
257        assert_eq!(cookie.as_ref().and_then(|v| v.as_str()), Some("cookie"));
258    }
259
260    // --- HTTP --------------------------------------------------------
261
262    #[test]
263    fn http_bearer_serialises_with_openapi_wire_form() -> Result<(), TestError> {
264        let scheme = SecurityScheme::Http(HTTPAuthSecurityScheme {
265            description: None,
266            scheme: Arc::from("Bearer"),
267            bearer_format: Some(Arc::from("JWT")),
268        });
269        let value = serde_json::to_value(&scheme)?;
270        let Some(obj) = value.as_object() else {
271            return Err("a SecurityScheme must serialise to a JSON object"
272                .to_owned()
273                .into());
274        };
275        assert_eq!(
276            obj.get("type").and_then(serde_json::Value::as_str),
277            Some("http")
278        );
279        assert_eq!(
280            obj.get("scheme").and_then(serde_json::Value::as_str),
281            Some("Bearer")
282        );
283        assert_eq!(
284            obj.get("bearerFormat").and_then(serde_json::Value::as_str),
285            Some("JWT")
286        );
287        Ok(())
288    }
289
290    #[test]
291    fn http_basic_round_trips_through_serde() -> Result<(), TestError> {
292        let scheme = SecurityScheme::Http(HTTPAuthSecurityScheme {
293            description: Some(Arc::from("HTTP Basic auth.")),
294            scheme: Arc::from("Basic"),
295            bearer_format: None,
296        });
297        let json = serde_json::to_string(&scheme)?;
298        let parsed: SecurityScheme = serde_json::from_str(&json)?;
299        assert_eq!(parsed, scheme);
300        Ok(())
301    }
302
303    // --- `OpenIdConnect` ---------------------------------------------
304
305    #[test]
306    fn open_id_connect_serialises_with_openapi_wire_form() -> Result<(), TestError> {
307        let scheme = SecurityScheme::OpenIdConnect(OpenIdConnectSecurityScheme {
308            description: Some(Arc::from("Login with the corporate OIDC provider.")),
309            open_id_connect_url: url::Url::parse(
310                "https://auth.example.com/.well-known/openid-configuration",
311            )?,
312        });
313        let value = serde_json::to_value(&scheme)?;
314        let Some(obj) = value.as_object() else {
315            return Err("a SecurityScheme must serialise to a JSON object"
316                .to_owned()
317                .into());
318        };
319        assert_eq!(
320            obj.get("type").and_then(serde_json::Value::as_str),
321            Some("openIdConnect"),
322            "the type field must be the OpenAPI literal \"openIdConnect\""
323        );
324        assert_eq!(
325            obj.get("openIdConnectUrl")
326                .and_then(serde_json::Value::as_str),
327            Some("https://auth.example.com/.well-known/openid-configuration")
328        );
329        Ok(())
330    }
331
332    #[test]
333    fn open_id_connect_round_trips_through_serde() -> Result<(), TestError> {
334        let scheme = SecurityScheme::OpenIdConnect(OpenIdConnectSecurityScheme {
335            description: None,
336            open_id_connect_url: url::Url::parse(
337                "https://auth.example.com/.well-known/openid-configuration",
338            )?,
339        });
340        let json = serde_json::to_string(&scheme)?;
341        let parsed: SecurityScheme = serde_json::from_str(&json)?;
342        assert_eq!(parsed, scheme);
343        Ok(())
344    }
345
346    // --- `MutualTls` -------------------------------------------------
347
348    #[test]
349    fn mutual_tls_serialises_with_openapi_wire_form() -> Result<(), TestError> {
350        let scheme = SecurityScheme::MutualTls(MutualTlsSecurityScheme {
351            description: Some(Arc::from(
352                "Client certs validated against the corporate CA.",
353            )),
354        });
355        let value = serde_json::to_value(&scheme)?;
356        let Some(obj) = value.as_object() else {
357            return Err("a SecurityScheme must serialise to a JSON object"
358                .to_owned()
359                .into());
360        };
361        assert_eq!(
362            obj.get("type").and_then(serde_json::Value::as_str),
363            Some("mutualTls"),
364            "the type field must be the OpenAPI literal \"mutualTls\""
365        );
366        assert_eq!(
367            obj.get("description").and_then(serde_json::Value::as_str),
368            Some("Client certs validated against the corporate CA.")
369        );
370        Ok(())
371    }
372
373    #[test]
374    fn mutual_tls_round_trips_through_serde() -> Result<(), TestError> {
375        let scheme = SecurityScheme::MutualTls(MutualTlsSecurityScheme { description: None });
376        let json = serde_json::to_string(&scheme)?;
377        let parsed: SecurityScheme = serde_json::from_str(&json)?;
378        assert_eq!(parsed, scheme);
379        Ok(())
380    }
381}