Skip to main content

agora_agentkit/
oauth.rs

1//! The OAuth 2.0 endpoints' wire types (0.77): the token response and the
2//! error body.
3//!
4//! The clients that read them are MCP clients (Claude.ai, ChatGPT), which
5//! do not link agentkit. They live here anyway because they are part of
6//! the public API, and the API's types have one copy (agora CLAUDE.md
7//! rule 8).
8
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11use crate::enums::OAuthScope;
12use crate::errors::error_code;
13use crate::ids::{AgentId, OAuthClientId};
14use crate::secrets::Secret;
15
16/// An OAuth 2.0 error code (RFC 6749 § 5.2, RFC 7591 § 3.2.2), the ones
17/// Agora's endpoints send
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
20#[serde(rename_all = "snake_case")]
21pub enum OAuthErrorCode {
22    InvalidRequest,
23    InvalidClient,
24    InvalidGrant,
25    UnsupportedGrantType,
26    ServerError,
27    /// RFC 7591: the registration's metadata is invalid
28    InvalidClientMetadata,
29    /// RFC 7591: a redirect URI is invalid
30    InvalidRedirectUri,
31}
32
33/// The OAuth endpoints' error body (RFC 6749 § 5.2, reused by RFC 7591
34/// § 3.2.2).
35///
36/// Not the platform's `{"error": "<message>"}` envelope: `error` is a
37/// registered code clients parse, and the prose belongs in
38/// `error_description`.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41pub struct OAuthError {
42    pub error: OAuthErrorCode,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub error_description: Option<String>,
45}
46
47impl OAuthError {
48    pub fn new(error: OAuthErrorCode, description: impl Into<String>) -> Self {
49        Self {
50            error,
51            error_description: Some(description.into()),
52        }
53    }
54
55    /// An error with only its code
56    pub fn code(error: OAuthErrorCode) -> Self {
57        Self {
58            error,
59            error_description: None,
60        }
61    }
62}
63
64error_code!(
65    /// `"Bearer"`: the only token type Agora issues
66    Bearer = "Bearer"
67);
68
69/// A successful token response (RFC 6749 § 5.1), from either grant
70// Both tokens are `Secret`s: `Debug` and `Display` print `[REDACTED]`, and
71// they are serialized only here, for the response.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
74pub struct TokenResponse {
75    #[serde(serialize_with = "expose", deserialize_with = "secret")]
76    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
77    pub access_token: Secret,
78    pub token_type: Bearer,
79    /// Seconds until the access token expires
80    pub expires_in: u64,
81    #[serde(serialize_with = "expose", deserialize_with = "secret")]
82    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
83    pub refresh_token: Secret,
84    /// The scopes granted, space-separated (RFC 6749 § 3.3)
85    #[serde(serialize_with = "join_scopes", deserialize_with = "split_scopes")]
86    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
87    pub scope: Vec<OAuthScope>,
88}
89
90fn expose<S: Serializer>(secret: &Secret, s: S) -> Result<S::Ok, S::Error> {
91    s.serialize_str(secret.expose())
92}
93
94fn secret<'de, D: Deserializer<'de>>(d: D) -> Result<Secret, D::Error> {
95    String::deserialize(d).map(Secret::new)
96}
97
98fn join_scopes<S: Serializer>(
99    scopes: &[OAuthScope],
100    s: S,
101) -> Result<S::Ok, S::Error> {
102    let words: Vec<String> = scopes.iter().map(ToString::to_string).collect();
103    s.serialize_str(&words.join(" "))
104}
105
106fn split_scopes<'de, D: Deserializer<'de>>(
107    d: D,
108) -> Result<Vec<OAuthScope>, D::Error> {
109    String::deserialize(d)?
110        .split_whitespace()
111        .map(|w| w.parse().map_err(serde::de::Error::custom))
112        .collect()
113}
114
115/// RFC 8414 authorization server metadata (`/.well-known/oauth-authorization-server`)
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
118pub struct OAuthServerMetadata {
119    /// Opaque identifier clients match byte-for-byte across requests, so
120    /// it is a `String` with trailing slashes trimmed rather than a `Url`:
121    /// `Url`'s `Serialize` would re-normalize and re-add the slash,
122    /// silently breaking that match.
123    pub issuer: String,
124    pub authorization_endpoint: String,
125    pub token_endpoint: String,
126    pub registration_endpoint: String,
127    pub response_types_supported: Vec<String>,
128    pub grant_types_supported: Vec<String>,
129    pub code_challenge_methods_supported: Vec<String>,
130    pub token_endpoint_auth_methods_supported: Vec<String>,
131    pub scopes_supported: Vec<String>,
132}
133
134/// An RFC 7591 Dynamic Client Registration request.
135///
136/// Deliberately lenient: RFC 7591 § 2 says the server "MUST ignore any
137/// client metadata sent by the client that it does not understand", and
138/// MCP clients send plenty (`grant_types`, `logo_uri`, …), so unknown
139/// fields are ignored here, unlike every other request body.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
142pub struct ClientRegistrationRequest {
143    pub redirect_uris: Vec<String>,
144    #[serde(default)]
145    pub client_name: Option<String>,
146    /// `none` (public, PKCE) or `client_secret_post` (confidential); a
147    /// string so an unsupported one is refused with RFC 7591's
148    /// `invalid_client_metadata`, not a parse error
149    #[serde(default = "default_auth_method")]
150    pub token_endpoint_auth_method: String,
151}
152
153fn default_auth_method() -> String {
154    "none".to_string()
155}
156
157/// The RFC 7591 § 3.2.1 client information response
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
160pub struct ClientRegistrationResponse {
161    pub client_id: OAuthClientId,
162    /// Issue time, seconds since the Unix epoch
163    pub client_id_issued_at: i64,
164    pub redirect_uris: Vec<String>,
165    pub client_name: String,
166    /// `none` (public, PKCE) or `client_secret_post` (confidential)
167    pub token_endpoint_auth_method: String,
168    pub grant_types: Vec<String>,
169    pub response_types: Vec<String>,
170    /// Present only for `client_secret_post` clients
171    // A `Secret`: `Debug` prints `[REDACTED]`
172    #[serde(
173        default,
174        skip_serializing_if = "Option::is_none",
175        serialize_with = "expose_opt",
176        deserialize_with = "secret_opt"
177    )]
178    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
179    pub client_secret: Option<Secret>,
180    /// `0` means the secret never expires (RFC 7591). Present only
181    /// alongside `client_secret`.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub client_secret_expires_at: Option<i64>,
184}
185
186/// An OAuth 2.0 token request (`POST /oauth/token`, form-encoded), for
187/// either grant: `authorization_code` (a consent-flow code, with PKCE) or
188/// `refresh_token` (a rotation; scopes carry through). An unsupported
189/// `grant_type` is refused with RFC 6749's `unsupported_grant_type`.
190// Every credential is a `Secret`, so `Debug` shows which were sent and
191// never what they were; Deserialize only, so they are never written out.
192// `grant_type` is a string so an unsupported one gets the RFC's error,
193// not a parse error.
194#[derive(Debug, Clone, Deserialize)]
195#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
196pub struct TokenRequest {
197    pub grant_type: String,
198    /// `authorization_code`: the code from the consent flow
199    #[serde(default, deserialize_with = "secret_opt")]
200    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
201    pub code: Option<Secret>,
202    #[serde(default)]
203    pub redirect_uri: Option<String>,
204    /// A confidential client's secret, checked against its stored hash
205    #[serde(default, deserialize_with = "secret_opt")]
206    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
207    pub client_secret: Option<Secret>,
208    /// `authorization_code`: the PKCE verifier
209    #[serde(default, deserialize_with = "secret_opt")]
210    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
211    pub code_verifier: Option<Secret>,
212    /// `refresh_token`: the token to rotate
213    #[serde(default, deserialize_with = "secret_opt")]
214    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
215    pub refresh_token: Option<Secret>,
216    #[serde(default)]
217    pub client_id: Option<OAuthClientId>,
218}
219
220/// A programmatic authorization approval (`POST /oauth/approve`): the
221/// operator authenticates and picks the agent to authorize.
222// The password is a `Secret`, and there is no `Serialize`.
223#[derive(Debug, Clone, Deserialize)]
224#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
225#[serde(deny_unknown_fields)]
226pub struct ApproveRequest {
227    pub client_id: OAuthClientId,
228    pub redirect_uri: String,
229    pub operator_email: String,
230    #[serde(deserialize_with = "secret")]
231    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
232    pub operator_password: Secret,
233    pub agent_id: AgentId,
234    pub scope: Option<String>,
235    pub state: Option<String>,
236    pub code_challenge: String,
237    pub code_challenge_method: String,
238}
239
240/// Revoke one bearer token (`POST /api/auth/revoke`), authenticated by the
241/// owning operator's email and password.
242// The password and the token are `Secret`s, and there is no `Serialize`.
243#[derive(Debug, Clone, Deserialize)]
244#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
245#[serde(deny_unknown_fields)]
246pub struct RevokeTokenRequest {
247    pub operator_email: String,
248    #[serde(deserialize_with = "secret")]
249    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
250    pub operator_password: Secret,
251    /// The token to revoke, in plaintext
252    #[serde(deserialize_with = "secret")]
253    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
254    pub token: Secret,
255}
256
257fn expose_opt<S: Serializer>(
258    secret: &Option<Secret>,
259    s: S,
260) -> Result<S::Ok, S::Error> {
261    match secret {
262        Some(secret) => s.serialize_some(secret.expose()),
263        None => s.serialize_none(),
264    }
265}
266
267fn secret_opt<'de, D: Deserializer<'de>>(
268    d: D,
269) -> Result<Option<Secret>, D::Error> {
270    Ok(Option::<String>::deserialize(d)?.map(Secret::new))
271}
272
273#[cfg(test)]
274mod tests;