Skip to main content

volga_oauth_core/
registration.rs

1//! Dynamic Client Registration models
2//!
3//! Serde models for OAuth 2.0 Dynamic Client Registration
4//! ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)): the client
5//! metadata sent to the registration endpoint (Section 2) and the client
6//! information response returned by it (Section 3.2.1).
7//!
8//! These are plain data types: submitting them (registration client) and
9//! serving them (a registration endpoint) are built on top separately.
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// Client metadata submitted for registration per RFC 7591 Section 2
15///
16/// [`ClientMetadata::new`] prefills the OAuth 2.1 client profile
17/// (`authorization_code` grant, `code` response type); extension and
18/// OIDC-specific fields - including localized variants such as
19/// `client_name#ja-JP` - are preserved in
20/// [`additional_fields`](Self::additional_fields).
21#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
22pub struct ClientMetadata {
23    /// Redirection URIs for redirect-based flows; REQUIRED for clients
24    /// using the `authorization_code` or `implicit` grants
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub redirect_uris: Vec<String>,
27
28    /// Kind of application the client is: `web` (the default when absent)
29    /// or `native`
30    ///
31    /// Defined by OpenID Connect Dynamic Client Registration Section 2 and widely
32    /// honored by OAuth 2.0 registration endpoints: a `native` client is
33    /// what allows the loopback redirect URIs (`http://127.0.0.1:{port}/...`)
34    /// desktop and CLI applications rely on - servers reject those for
35    /// `web` clients.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub application_type: Option<String>,
38
39    /// Requested token endpoint authentication method
40    /// (e.g. `client_secret_basic`, `client_secret_post`, `none`)
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub token_endpoint_auth_method: Option<String>,
43
44    /// Grant types the client will use
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub grant_types: Vec<String>,
47
48    /// Response types the client will use
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub response_types: Vec<String>,
51
52    /// Human-readable client name shown to end users
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub client_name: Option<String>,
55
56    /// URL of the client's home page
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub client_uri: Option<String>,
59
60    /// URL of the client's logo
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub logo_uri: Option<String>,
63
64    /// Space-separated scope values the client will request
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub scope: Option<String>,
67
68    /// Contact addresses for people responsible for the client
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub contacts: Vec<String>,
71
72    /// URL of the client's terms of service
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub tos_uri: Option<String>,
75
76    /// URL of the client's privacy policy
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub policy_uri: Option<String>,
79
80    /// URL of the client's JWK Set document; mutually exclusive with
81    /// [`jwks`](Self::jwks)
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub jwks_uri: Option<String>,
84
85    /// The client's JWK Set document by value; mutually exclusive with
86    /// [`jwks_uri`](Self::jwks_uri)
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub jwks: Option<serde_json::Value>,
89
90    /// Identifier for the client software, stable across instances
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub software_id: Option<String>,
93
94    /// Version of the client software
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub software_version: Option<String>,
97
98    /// Software statement JWT asserting client metadata values (Section 2.3);
99    /// issued by a third party and passed through as-is, not validated
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub software_statement: Option<String>,
102
103    /// Extension and OIDC-specific fields not modeled above, including
104    /// localized (`field#language-tag`) variants
105    #[serde(flatten)]
106    pub additional_fields: HashMap<String, serde_json::Value>,
107}
108
109impl ClientMetadata {
110    /// Creates client metadata prefilled with the OAuth 2.1 profile:
111    /// the `authorization_code` grant and the `code` response type
112    pub fn new() -> Self {
113        Self {
114            grant_types: vec!["authorization_code".into()],
115            response_types: vec!["code".into()],
116            ..Self::default()
117        }
118    }
119
120    /// Sets the redirection URIs
121    pub fn with_redirect_uris<I, S>(mut self, uris: I) -> Self
122    where
123        I: IntoIterator<Item = S>,
124        S: Into<String>,
125    {
126        self.redirect_uris = uris.into_iter().map(Into::into).collect();
127        self
128    }
129
130    /// Sets the application type - `web` (the server-side default) or
131    /// `native` for a desktop/CLI client with a loopback redirect URI
132    pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
133        self.application_type = Some(application_type.into());
134        self
135    }
136
137    /// Sets the requested token endpoint authentication method
138    pub fn with_token_endpoint_auth_method(mut self, method: impl Into<String>) -> Self {
139        self.token_endpoint_auth_method = Some(method.into());
140        self
141    }
142
143    /// Sets the grant types the client will use
144    ///
145    /// Response types only accompany redirect-based grants; when none of
146    /// the given grants is redirect-based (`authorization_code` or
147    /// `implicit`), the response types are cleared so the profile default
148    /// `code` does not leak into e.g. a `client_credentials` registration
149    /// (RFC 7591 Section 2 requires the two fields to be consistent). Set
150    /// response types after grant types when an extension grant needs them.
151    pub fn with_grant_types<I, S>(mut self, grant_types: I) -> Self
152    where
153        I: IntoIterator<Item = S>,
154        S: Into<String>,
155    {
156        self.grant_types = grant_types.into_iter().map(Into::into).collect();
157        if !self
158            .grant_types
159            .iter()
160            .any(|grant| grant == "authorization_code" || grant == "implicit")
161        {
162            self.response_types.clear();
163        }
164        self
165    }
166
167    /// Sets the response types the client will use
168    pub fn with_response_types<I, S>(mut self, response_types: I) -> Self
169    where
170        I: IntoIterator<Item = S>,
171        S: Into<String>,
172    {
173        self.response_types = response_types.into_iter().map(Into::into).collect();
174        self
175    }
176
177    /// Sets the human-readable client name
178    pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
179        self.client_name = Some(name.into());
180        self
181    }
182
183    /// Sets the URL of the client's home page
184    pub fn with_client_uri(mut self, uri: impl Into<String>) -> Self {
185        self.client_uri = Some(uri.into());
186        self
187    }
188
189    /// Sets the URL of the client's logo
190    pub fn with_logo_uri(mut self, uri: impl Into<String>) -> Self {
191        self.logo_uri = Some(uri.into());
192        self
193    }
194
195    /// Sets the requested scopes, joined into the space-separated `scope`
196    /// field
197    pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
198    where
199        I: IntoIterator<Item = S>,
200        S: Into<String>,
201    {
202        let scopes: Vec<String> = scopes.into_iter().map(Into::into).collect();
203        self.scope = Some(scopes.join(" "));
204        self
205    }
206
207    /// Sets the contact addresses
208    pub fn with_contacts<I, S>(mut self, contacts: I) -> Self
209    where
210        I: IntoIterator<Item = S>,
211        S: Into<String>,
212    {
213        self.contacts = contacts.into_iter().map(Into::into).collect();
214        self
215    }
216
217    /// Sets the URL of the client's terms of service
218    pub fn with_tos_uri(mut self, uri: impl Into<String>) -> Self {
219        self.tos_uri = Some(uri.into());
220        self
221    }
222
223    /// Sets the URL of the client's privacy policy
224    pub fn with_policy_uri(mut self, uri: impl Into<String>) -> Self {
225        self.policy_uri = Some(uri.into());
226        self
227    }
228
229    /// Sets the URL of the client's JWK Set document
230    pub fn with_jwks_uri(mut self, uri: impl Into<String>) -> Self {
231        self.jwks_uri = Some(uri.into());
232        self
233    }
234
235    /// Sets the client's JWK Set document by value
236    pub fn with_jwks(mut self, jwks: impl Into<serde_json::Value>) -> Self {
237        self.jwks = Some(jwks.into());
238        self
239    }
240
241    /// Sets the software identifier
242    pub fn with_software_id(mut self, id: impl Into<String>) -> Self {
243        self.software_id = Some(id.into());
244        self
245    }
246
247    /// Sets the software version
248    pub fn with_software_version(mut self, version: impl Into<String>) -> Self {
249        self.software_version = Some(version.into());
250        self
251    }
252
253    /// Sets the software statement JWT (Section 2.3)
254    pub fn with_software_statement(mut self, jwt: impl Into<String>) -> Self {
255        self.software_statement = Some(jwt.into());
256        self
257    }
258
259    /// Adds an extension or OIDC-specific field not modeled by the typed fields
260    pub fn with_additional_field(
261        mut self,
262        name: impl Into<String>,
263        value: impl Into<serde_json::Value>,
264    ) -> Self {
265        self.additional_fields.insert(name.into(), value.into());
266        self
267    }
268}
269
270/// Client information response per RFC 7591 Section 3.2.1
271///
272/// Returned by the registration endpoint on success: the issued client
273/// credentials plus all registered metadata (the server may have replaced
274/// or extended the requested values - read them back from
275/// [`metadata`](Self::metadata) rather than assuming the request was
276/// stored verbatim). The `registration_access_token` /
277/// `registration_client_uri` pair is issued by servers implementing the
278/// RFC 7592 management protocol.
279#[derive(Clone, PartialEq, Serialize, Deserialize)]
280pub struct ClientRegistrationResponse {
281    /// The issued client identifier
282    pub client_id: String,
283
284    /// The issued client secret, when the client is confidential
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub client_secret: Option<String>,
287
288    /// When `client_id` was issued, as seconds since the Unix epoch
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub client_id_issued_at: Option<u64>,
291
292    /// When `client_secret` expires as seconds since the Unix epoch,
293    /// `0` meaning it never expires; REQUIRED when a secret is issued
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub client_secret_expires_at: Option<u64>,
296
297    /// Access token for the RFC 7592 client management endpoint
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub registration_access_token: Option<String>,
300
301    /// URL of the RFC 7592 client management endpoint
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub registration_client_uri: Option<String>,
304
305    /// The metadata as registered by the server
306    #[serde(flatten)]
307    pub metadata: ClientMetadata,
308}
309
310impl std::fmt::Debug for ClientRegistrationResponse {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        // the secret and the management token are credentials - never
313        // expose them in debug output
314        f.debug_struct("ClientRegistrationResponse")
315            .field("client_id", &self.client_id)
316            .field(
317                "client_secret",
318                &self.client_secret.as_ref().map(|_| "[redacted]"),
319            )
320            .field("client_id_issued_at", &self.client_id_issued_at)
321            .field("client_secret_expires_at", &self.client_secret_expires_at)
322            .field(
323                "registration_access_token",
324                &self
325                    .registration_access_token
326                    .as_ref()
327                    .map(|_| "[redacted]"),
328            )
329            .field("registration_client_uri", &self.registration_client_uri)
330            .field("metadata", &self.metadata)
331            .finish()
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use serde_json::json;
339
340    #[test]
341    fn it_prefills_the_oauth21_profile() {
342        let metadata = ClientMetadata::new();
343        assert_eq!(metadata.grant_types, ["authorization_code"]);
344        assert_eq!(metadata.response_types, ["code"]);
345    }
346
347    #[test]
348    fn it_drops_response_types_for_non_redirect_grants() {
349        let metadata = ClientMetadata::new().with_grant_types(["client_credentials"]);
350        assert!(metadata.response_types.is_empty());
351        // and the empty list is omitted from the wire document
352        let json = serde_json::to_value(&metadata).unwrap();
353        assert_eq!(json, json!({ "grant_types": ["client_credentials"] }));
354
355        // a redirect-based grant in the set keeps the response types
356        let metadata =
357            ClientMetadata::new().with_grant_types(["authorization_code", "refresh_token"]);
358        assert_eq!(metadata.response_types, ["code"]);
359
360        // explicitly set response types afterwards are kept as-is
361        let metadata = ClientMetadata::new()
362            .with_grant_types(["urn:example:custom"])
363            .with_response_types(["custom"]);
364        assert_eq!(metadata.response_types, ["custom"]);
365    }
366
367    #[test]
368    fn it_serializes_only_populated_fields() {
369        let metadata = ClientMetadata::new()
370            .with_redirect_uris(["https://app.example.com/callback"])
371            .with_client_name("My App")
372            .with_scopes(["read", "write"]);
373        let json = serde_json::to_value(&metadata).unwrap();
374        assert_eq!(
375            json,
376            json!({
377                "redirect_uris": ["https://app.example.com/callback"],
378                "grant_types": ["authorization_code"],
379                "response_types": ["code"],
380                "client_name": "My App",
381                "scope": "read write"
382            })
383        );
384    }
385
386    #[test]
387    fn it_preserves_extension_and_localized_fields() {
388        let document = json!({
389            "redirect_uris": ["https://app.example.com/callback"],
390            "client_name": "My App",
391            "client_name#ja-JP": "マイアプリ",
392            "backchannel_logout_uri": "https://app.example.com/logout"
393        });
394        let metadata: ClientMetadata = serde_json::from_value(document.clone()).unwrap();
395        assert_eq!(
396            metadata.additional_fields["client_name#ja-JP"],
397            json!("マイアプリ")
398        );
399        assert_eq!(
400            metadata.additional_fields["backchannel_logout_uri"],
401            json!("https://app.example.com/logout")
402        );
403        // lossless round-trip
404        assert_eq!(serde_json::to_value(&metadata).unwrap(), document);
405    }
406
407    #[test]
408    fn it_round_trips_the_application_type() {
409        let metadata = ClientMetadata::new()
410            .with_redirect_uris(["http://127.0.0.1:8080/callback"])
411            .with_application_type("native");
412        assert_eq!(metadata.application_type.as_deref(), Some("native"));
413
414        let json = serde_json::to_value(&metadata).unwrap();
415        assert_eq!(json["application_type"], json!("native"));
416        // typed, not swept into the extension bag
417        assert!(!metadata.additional_fields.contains_key("application_type"));
418
419        let parsed: ClientMetadata = serde_json::from_value(json).unwrap();
420        assert_eq!(parsed, metadata);
421
422        // absent by default - servers assume `web`
423        let json = serde_json::to_value(ClientMetadata::new()).unwrap();
424        assert!(json.get("application_type").is_none());
425    }
426
427    #[test]
428    fn it_deserializes_a_registration_response() {
429        let response: ClientRegistrationResponse = serde_json::from_value(json!({
430            "client_id": "s6BhdRkqt3",
431            "client_secret": "cf136dc3c1fc93f31185e5885805d",
432            "client_id_issued_at": 2893256800u64,
433            "client_secret_expires_at": 0,
434            "registration_access_token": "this.is.an.access.token",
435            "registration_client_uri": "https://server.example.com/register/s6BhdRkqt3",
436            "redirect_uris": ["https://client.example.org/callback"],
437            "grant_types": ["authorization_code", "refresh_token"],
438            "client_name": "My Example Client",
439            "token_endpoint_auth_method": "client_secret_basic"
440        }))
441        .unwrap();
442
443        assert_eq!(response.client_id, "s6BhdRkqt3");
444        assert_eq!(response.client_secret_expires_at, Some(0));
445        assert_eq!(
446            response.metadata.redirect_uris,
447            ["https://client.example.org/callback"]
448        );
449        assert_eq!(
450            response.metadata.token_endpoint_auth_method.as_deref(),
451            Some("client_secret_basic")
452        );
453    }
454
455    #[test]
456    fn it_requires_a_client_id_in_the_response() {
457        let result = serde_json::from_value::<ClientRegistrationResponse>(json!({
458            "client_secret": "secret"
459        }));
460        assert!(result.is_err());
461    }
462
463    #[test]
464    fn it_redacts_credentials_in_debug_output() {
465        let response: ClientRegistrationResponse = serde_json::from_value(json!({
466            "client_id": "s6BhdRkqt3",
467            "client_secret": "s3cret-value",
468            "registration_access_token": "management-token"
469        }))
470        .unwrap();
471        let debug = format!("{response:?}");
472        assert!(debug.contains("s6BhdRkqt3"));
473        assert!(!debug.contains("s3cret-value"));
474        assert!(!debug.contains("management-token"));
475        assert!(debug.contains("[redacted]"));
476    }
477}