Skip to main content

a2a_protocol_types/
security.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4// "OpenAPI", "OpenID", and similar proper-noun initialisms are intentionally
5// not wrapped in backticks in this module's documentation.
6#![allow(clippy::doc_markdown)]
7
8//! Security scheme types for A2A agent authentication.
9//!
10//! These types follow the security-scheme specification used by A2A v1.0,
11//! which is based on the OpenAPI 3.x security model.
12//! The root discriminated union is [`SecurityScheme`], tagged on the `"type"` field.
13//!
14//! [`NamedSecuritySchemes`] is a type alias, and [`SecurityRequirement`] is a
15//! struct used in [`crate::agent_card::AgentCard`] and
16//! [`crate::agent_card::AgentSkill`].
17
18use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21
22// ── Type aliases ──────────────────────────────────────────────────────────────
23
24/// A map from security scheme name to its definition, as used in
25/// `AgentCard.securitySchemes`.
26pub type NamedSecuritySchemes = HashMap<String, SecurityScheme>;
27
28/// A list of strings used within a [`SecurityRequirement`] map value.
29///
30/// Proto equivalent: `StringList { repeated string list = 1; }`.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct StringList {
34    /// The string values (e.g. OAuth scopes).
35    pub list: Vec<String>,
36}
37
38/// A security requirement object mapping scheme names to their required scopes.
39///
40/// Proto equivalent: `SecurityRequirement { map<string, StringList> schemes = 1; }`.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct SecurityRequirement {
44    /// Map from scheme name to required scopes.
45    pub schemes: HashMap<String, StringList>,
46}
47
48// ── SecurityScheme ────────────────────────────────────────────────────────────
49
50/// A security scheme supported by an agent, discriminated by the `"type"` field.
51#[non_exhaustive]
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(tag = "type")]
54pub enum SecurityScheme {
55    /// API key authentication (`"apiKey"`).
56    #[serde(rename = "apiKey")]
57    ApiKey(ApiKeySecurityScheme),
58
59    /// HTTP authentication (e.g. Bearer, Basic) (`"http"`).
60    #[serde(rename = "http")]
61    Http(HttpAuthSecurityScheme),
62
63    /// OAuth 2.0 (`"oauth2"`).
64    ///
65    /// Boxed to reduce the enum's stack size.
66    #[serde(rename = "oauth2")]
67    OAuth2(Box<OAuth2SecurityScheme>),
68
69    /// OpenID Connect (`"openIdConnect"`).
70    #[serde(rename = "openIdConnect")]
71    OpenIdConnect(OpenIdConnectSecurityScheme),
72
73    /// Mutual TLS (`"mutualTLS"`).
74    #[serde(rename = "mutualTLS")]
75    MutualTls(MutualTlsSecurityScheme),
76}
77
78// ── ApiKeySecurityScheme ──────────────────────────────────────────────────────
79
80/// API key security scheme: a token sent in a header, query parameter, or cookie.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ApiKeySecurityScheme {
84    /// Where the API key is transmitted.
85    ///
86    /// Serialized as `"in"` (a Rust keyword; mapped via `rename`).
87    #[serde(rename = "in")]
88    pub location: ApiKeyLocation,
89
90    /// Name of the header, query parameter, or cookie.
91    pub name: String,
92
93    /// Optional human-readable description.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub description: Option<String>,
96}
97
98/// Where an API key is placed in the request.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum ApiKeyLocation {
102    /// Transmitted as an HTTP header.
103    Header,
104    /// Transmitted as a URL query parameter.
105    Query,
106    /// Transmitted as a cookie.
107    Cookie,
108}
109
110// ── HttpAuthSecurityScheme ────────────────────────────────────────────────────
111
112/// HTTP authentication security scheme (Bearer, Basic, etc.).
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct HttpAuthSecurityScheme {
116    /// The HTTP authentication scheme name (e.g. `"bearer"`, `"basic"`).
117    pub scheme: String,
118
119    /// Format hint for Bearer tokens (e.g. `"JWT"`).
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub bearer_format: Option<String>,
122
123    /// Optional human-readable description.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub description: Option<String>,
126}
127
128// ── OAuth2SecurityScheme ──────────────────────────────────────────────────────
129
130/// OAuth 2.0 security scheme.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct OAuth2SecurityScheme {
134    /// Available OAuth 2.0 flows.
135    pub flows: OAuthFlows,
136
137    /// URL of the OAuth 2.0 server metadata document (RFC 8414).
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub oauth2_metadata_url: Option<String>,
140
141    /// Optional human-readable description.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub description: Option<String>,
144}
145
146/// Available OAuth 2.0 flows for an [`OAuth2SecurityScheme`].
147///
148/// Mirrors the OpenAPI 3.x `OAuthFlows` object.
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct OAuthFlows {
152    /// Authorization code flow.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub authorization_code: Option<AuthorizationCodeFlow>,
155
156    /// Client credentials flow.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub client_credentials: Option<ClientCredentialsFlow>,
159
160    /// Device authorization flow (RFC 8628).
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub device_code: Option<DeviceCodeFlow>,
163
164    /// Implicit flow (deprecated in OAuth 2.1 but retained for compatibility).
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub implicit: Option<ImplicitFlow>,
167
168    /// Resource owner password credentials flow (deprecated but present in spec).
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub password: Option<PasswordOAuthFlow>,
171}
172
173/// OAuth 2.0 authorization code flow.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct AuthorizationCodeFlow {
177    /// URL of the authorization endpoint.
178    pub authorization_url: String,
179
180    /// URL of the token endpoint.
181    pub token_url: String,
182
183    /// URL of the refresh token endpoint.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub refresh_url: Option<String>,
186
187    /// Available scopes: name → description.
188    pub scopes: HashMap<String, String>,
189
190    /// Whether PKCE (RFC 7636) is required for this flow.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub pkce_required: Option<bool>,
193}
194
195/// OAuth 2.0 client credentials flow.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct ClientCredentialsFlow {
199    /// URL of the token endpoint.
200    pub token_url: String,
201
202    /// URL of the refresh token endpoint.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub refresh_url: Option<String>,
205
206    /// Available scopes: name → description.
207    pub scopes: HashMap<String, String>,
208}
209
210/// OAuth 2.0 device authorization flow (RFC 8628).
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct DeviceCodeFlow {
214    /// URL of the device authorization endpoint.
215    pub device_authorization_url: String,
216
217    /// URL of the token endpoint.
218    pub token_url: String,
219
220    /// URL of the refresh token endpoint.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub refresh_url: Option<String>,
223
224    /// Available scopes: name → description.
225    pub scopes: HashMap<String, String>,
226}
227
228/// OAuth 2.0 implicit flow (deprecated; retained for compatibility).
229#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct ImplicitFlow {
232    /// URL of the authorization endpoint.
233    pub authorization_url: String,
234
235    /// URL of the refresh token endpoint.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub refresh_url: Option<String>,
238
239    /// Available scopes: name → description.
240    pub scopes: HashMap<String, String>,
241}
242
243/// OAuth 2.0 resource owner password credentials flow (deprecated but in spec).
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct PasswordOAuthFlow {
247    /// URL of the token endpoint.
248    pub token_url: String,
249
250    /// URL of the refresh token endpoint.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub refresh_url: Option<String>,
253
254    /// Available scopes: name → description.
255    pub scopes: HashMap<String, String>,
256}
257
258// ── OpenIdConnectSecurityScheme ───────────────────────────────────────────────
259
260/// OpenID Connect security scheme.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct OpenIdConnectSecurityScheme {
264    /// URL of the OpenID Connect discovery document.
265    pub open_id_connect_url: String,
266
267    /// Optional human-readable description.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub description: Option<String>,
270}
271
272// ── MutualTlsSecurityScheme ───────────────────────────────────────────────────
273
274/// Mutual TLS security scheme.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct MutualTlsSecurityScheme {
278    /// Optional human-readable description.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub description: Option<String>,
281}
282
283// ── Tests ─────────────────────────────────────────────────────────────────────
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn api_key_scheme_roundtrip() {
291        let scheme = SecurityScheme::ApiKey(ApiKeySecurityScheme {
292            location: ApiKeyLocation::Header,
293            name: "X-API-Key".into(),
294            description: None,
295        });
296        let json = serde_json::to_string(&scheme).expect("serialize");
297        assert!(
298            json.contains("\"type\":\"apiKey\""),
299            "tag must be present: {json}"
300        );
301        assert!(
302            json.contains("\"in\":\"header\""),
303            "location must use 'in': {json}"
304        );
305
306        let back: SecurityScheme = serde_json::from_str(&json).expect("deserialize");
307        assert!(matches!(back, SecurityScheme::ApiKey(_)));
308    }
309
310    #[test]
311    fn http_bearer_scheme_roundtrip() {
312        let scheme = SecurityScheme::Http(HttpAuthSecurityScheme {
313            scheme: "bearer".into(),
314            bearer_format: Some("JWT".into()),
315            description: None,
316        });
317        let json = serde_json::to_string(&scheme).expect("serialize");
318        assert!(json.contains("\"type\":\"http\""));
319        let back: SecurityScheme = serde_json::from_str(&json).expect("deserialize");
320        if let SecurityScheme::Http(h) = back {
321            assert_eq!(h.bearer_format.as_deref(), Some("JWT"));
322        } else {
323            panic!("wrong variant");
324        }
325    }
326
327    #[test]
328    fn oauth2_scheme_roundtrip() {
329        let scheme = SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
330            flows: OAuthFlows {
331                authorization_code: None,
332                client_credentials: Some(ClientCredentialsFlow {
333                    token_url: "https://auth.example.com/token".into(),
334                    refresh_url: None,
335                    scopes: HashMap::from([("read".into(), "Read access".into())]),
336                }),
337                device_code: None,
338                implicit: None,
339                password: None,
340            },
341            oauth2_metadata_url: None,
342            description: None,
343        }));
344        let json = serde_json::to_string(&scheme).expect("serialize");
345        assert!(json.contains("\"type\":\"oauth2\""));
346        let back: SecurityScheme = serde_json::from_str(&json).expect("deserialize");
347        assert!(matches!(back, SecurityScheme::OAuth2(_)));
348    }
349
350    #[test]
351    fn mutual_tls_scheme_roundtrip() {
352        let scheme = SecurityScheme::MutualTls(MutualTlsSecurityScheme { description: None });
353        let json = serde_json::to_string(&scheme).expect("serialize");
354        assert!(json.contains("\"type\":\"mutualTLS\""));
355        let back: SecurityScheme = serde_json::from_str(&json).expect("deserialize");
356        assert!(matches!(back, SecurityScheme::MutualTls(_)));
357    }
358
359    #[test]
360    fn api_key_location_serialization() {
361        assert_eq!(
362            serde_json::to_string(&ApiKeyLocation::Header).expect("ser"),
363            "\"header\""
364        );
365        assert_eq!(
366            serde_json::to_string(&ApiKeyLocation::Query).expect("ser"),
367            "\"query\""
368        );
369        assert_eq!(
370            serde_json::to_string(&ApiKeyLocation::Cookie).expect("ser"),
371            "\"cookie\""
372        );
373    }
374
375    #[test]
376    fn wire_format_security_requirement() {
377        // Spec: {"schemes":{"oauth2":{"list":["read","write"]}}}
378        let req = SecurityRequirement {
379            schemes: HashMap::from([(
380                "oauth2".into(),
381                StringList {
382                    list: vec!["read".into(), "write".into()],
383                },
384            )]),
385        };
386        let json = serde_json::to_string(&req).unwrap();
387        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
388        assert_eq!(
389            parsed["schemes"]["oauth2"]["list"],
390            serde_json::json!(["read", "write"])
391        );
392
393        // Roundtrip
394        let back: SecurityRequirement = serde_json::from_str(&json).unwrap();
395        assert_eq!(back.schemes["oauth2"].list, vec!["read", "write"]);
396    }
397
398    #[test]
399    fn wire_format_password_oauth_flow() {
400        let flows = OAuthFlows {
401            authorization_code: None,
402            client_credentials: None,
403            device_code: None,
404            implicit: None,
405            password: Some(PasswordOAuthFlow {
406                token_url: "https://auth.example.com/token".into(),
407                refresh_url: None,
408                scopes: HashMap::from([("read".into(), "Read access".into())]),
409            }),
410        };
411        let json = serde_json::to_string(&flows).unwrap();
412        assert!(
413            json.contains("\"password\""),
414            "password flow must be present: {json}"
415        );
416
417        let back: OAuthFlows = serde_json::from_str(&json).unwrap();
418        assert!(back.password.is_some());
419        assert_eq!(
420            back.password.unwrap().token_url,
421            "https://auth.example.com/token"
422        );
423    }
424}