Skip to main content

jacquard_oauth/
atproto.rs

1use std::str::FromStr;
2
3use crate::types::OAuthClientMetadata;
4use crate::{
5    keyset::Keyset,
6    scopes::{Scope, Scopes},
7};
8use jacquard_common::deps::fluent_uri::Uri;
9use jacquard_common::{BosStr, IntoStatic};
10use serde::{Deserialize, Serialize};
11use smol_str::{SmolStr, ToSmolStr};
12use thiserror::Error;
13
14/// Errors that can occur when building AT Protocol OAuth client metadata.
15#[derive(Error, Debug)]
16#[non_exhaustive]
17pub enum Error {
18    /// The `client_id` is not a valid URL.
19    #[error("`client_id` must be a valid URL")]
20    InvalidClientId,
21    /// The `grant_types` list does not include `authorization_code`, which is required by atproto.
22    #[error("`grant_types` must include `authorization_code`")]
23    InvalidGrantTypes,
24    /// The `scope` list does not include `atproto`, which is required for all atproto clients.
25    #[error("`scope` must not include `atproto`")]
26    InvalidScope,
27    /// No redirect URIs were provided; at least one is required.
28    #[error("`redirect_uris` must not be empty")]
29    EmptyRedirectUris,
30    /// The `private_key_jwt` auth method was requested but no JWK keys were provided.
31    #[error("`private_key_jwt` auth method requires `jwks` keys")]
32    EmptyJwks,
33    /// Signing algorithm mismatch: `private_key_jwt` requires `token_endpoint_auth_signing_alg`,
34    /// and non-`private_key_jwt` methods must not provide it.
35    #[error(
36        "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided"
37    )]
38    AuthSigningAlg,
39    /// HTML form serialization of the loopback `client_id` query string failed.
40    #[error(transparent)]
41    SerdeHtmlForm(#[from] serde_html_form::ser::Error),
42    /// A localhost-specific validation error occurred.
43    #[error(transparent)]
44    LocalhostClient(#[from] LocalhostClientError),
45}
46
47/// Errors specific to validating a loopback (localhost) OAuth client's redirect URIs.
48///
49/// The AT Protocol spec has specific requirements for loopback clients: redirect URIs must
50/// use the `http` scheme and must point to actual loopback addresses (not the hostname `localhost`).
51#[derive(Error, Debug)]
52#[non_exhaustive]
53pub enum LocalhostClientError {
54    /// The redirect URI could not be parsed.
55    #[error("invalid redirect_uri: {0}")]
56    Invalid(#[from] jacquard_common::deps::fluent_uri::ParseError),
57    /// Loopback redirect URIs must use `http:`, not `https:` or any other scheme.
58    #[error("loopback client_id must use `http:` redirect_uri")]
59    NotHttpScheme,
60    /// The hostname `localhost` is not allowed; use a numeric loopback address instead.
61    #[error("loopback client_id must not use `localhost` as redirect_uri hostname")]
62    Localhost,
63    /// The redirect URI host is not a loopback address (127.x.x.x or ::1).
64    #[error("loopback client_id must not use loopback addresses as redirect_uri")]
65    NotLoopbackHost,
66}
67
68/// Convenience result type for AT Protocol client metadata operations.
69pub type Result<T> = core::result::Result<T, Error>;
70
71/// The token endpoint authentication method for an OAuth client.
72///
73/// AT Protocol clients either authenticate with no client secret (public/loopback clients)
74/// or with a private key JWT signed by a key from the client's JWK set.
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum AuthMethod {
78    /// No client authentication; used for public and loopback clients.
79    None,
80    /// Authenticate using a JWT signed with a private key from the client's JWK set.
81    /// <https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication>
82    PrivateKeyJwt,
83}
84
85impl From<AuthMethod> for SmolStr {
86    fn from(value: AuthMethod) -> Self {
87        match value {
88            AuthMethod::None => SmolStr::new_static("none"),
89            AuthMethod::PrivateKeyJwt => SmolStr::new_static("private_key_jwt"),
90        }
91    }
92}
93
94impl From<AuthMethod> for &'static str {
95    fn from(value: AuthMethod) -> Self {
96        match value {
97            AuthMethod::None => "none",
98            AuthMethod::PrivateKeyJwt => "private_key_jwt",
99        }
100    }
101}
102
103/// OAuth 2.0 grant types supported by AT Protocol clients.
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum GrantType {
107    /// Standard authorization code grant, required by atproto.
108    AuthorizationCode,
109    /// Refresh token grant, used to obtain new access tokens without re-authorization.
110    RefreshToken,
111}
112
113impl From<GrantType> for SmolStr {
114    fn from(value: GrantType) -> Self {
115        match value {
116            GrantType::AuthorizationCode => SmolStr::new_static("authorization_code"),
117            GrantType::RefreshToken => SmolStr::new_static("refresh_token"),
118        }
119    }
120}
121
122impl From<GrantType> for &'static str {
123    fn from(value: GrantType) -> Self {
124        match value {
125            GrantType::AuthorizationCode => "authorization_code",
126            GrantType::RefreshToken => "refresh_token",
127        }
128    }
129}
130
131/// AT Protocol-specific OAuth client metadata, used to describe a client before converting to
132/// the generic [`OAuthClientMetadata`] format for server registration.
133///
134/// This type provides a validated, atproto-aware view of client registration data, with
135/// typed fields for URIs and scopes rather than raw strings. Use [`atproto_client_metadata`]
136/// to convert this into the wire format expected by OAuth servers.
137#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
138pub struct AtprotoClientMetadata<S: BosStr + FromStr + Ord>
139where
140    <S as FromStr>::Err: core::fmt::Debug,
141{
142    /// The unique identifier for this client, typically the URL of its metadata document.
143    pub client_id: Uri<String>,
144    /// The URI of the client's homepage or information page.
145    pub client_uri: Option<Uri<String>>,
146    /// The list of allowed redirect URIs for the authorization code flow.
147    pub redirect_uris: Vec<Uri<String>>,
148    /// The grant types this client will use.
149    pub grant_types: Vec<GrantType>,
150    /// The OAuth scopes this client requests; must include `atproto`.
151    pub scopes: Scopes<S>,
152    /// URI pointing to the client's JWK Set; mutually exclusive with inline `jwks`.
153    pub jwks_uri: Option<Uri<String>>,
154    /// Human-readable display name for the client.
155    pub client_name: Option<S>,
156    /// URI of the client's logo image.
157    pub logo_uri: Option<Uri<String>>,
158    /// URI of the client's terms of service document.
159    pub tos_uri: Option<Uri<String>>,
160    /// URI of the client's privacy policy document.
161    pub privacy_policy_uri: Option<Uri<String>>,
162}
163
164impl<S> IntoStatic for AtprotoClientMetadata<S>
165where
166    S: BosStr + IntoStatic + Ord + FromStr + AsRef<str>,
167    <S as FromStr>::Err: core::fmt::Debug,
168    S::Output: BosStr + FromStr + Ord + AsRef<str>,
169    <S::Output as FromStr>::Err: core::fmt::Debug,
170{
171    type Output = AtprotoClientMetadata<S::Output>;
172    fn into_static(self) -> AtprotoClientMetadata<S::Output> {
173        AtprotoClientMetadata {
174            client_id: self.client_id,
175            client_uri: self.client_uri,
176            redirect_uris: self.redirect_uris,
177            grant_types: self.grant_types,
178            scopes: self.scopes.into_static(),
179            jwks_uri: self.jwks_uri,
180            client_name: self.client_name.into_static(),
181            logo_uri: self.logo_uri,
182            tos_uri: self.tos_uri,
183            privacy_policy_uri: None,
184        }
185    }
186}
187
188impl<S> AtprotoClientMetadata<S>
189where
190    S: BosStr + IntoStatic + Ord + FromStr,
191    <S as FromStr>::Err: core::fmt::Debug,
192    S::Output: BosStr + FromStr + Ord,
193    <S::Output as FromStr>::Err: core::fmt::Debug,
194{
195    /// Attach optional production branding fields to the metadata.
196    ///
197    /// Chainable builder method for setting display name, logo, and policy URLs after
198    /// constructing the base metadata.
199    pub fn with_prod_info(
200        mut self,
201        client_name: S,
202        logo_uri: Option<Uri<String>>,
203        tos_uri: Option<Uri<String>>,
204        privacy_policy_uri: Option<Uri<String>>,
205    ) -> Self {
206        self.client_name = Some(client_name);
207        self.logo_uri = logo_uri;
208        self.tos_uri = tos_uri;
209        self.privacy_policy_uri = privacy_policy_uri;
210        self
211    }
212
213    /// Set the OAuth scopes for this client.
214    pub fn with_scopes(mut self, scopes: Scopes<S>) -> Self {
215        self.scopes = scopes;
216        self
217    }
218
219    /// Set the uri where the client's keys are hosted.
220    pub fn with_jwks_uri(mut self, jwks_uri: Uri<String>) -> Self {
221        self.jwks_uri = Some(jwks_uri);
222        self
223    }
224
225    /// Set the human-readable display name for this client.
226    pub fn with_client_name(mut self, client_name: S) -> Self {
227        self.client_name = Some(client_name);
228        self
229    }
230
231    /// Create a default loopback client metadata with the `atproto` and `transition:generic` scopes.
232    ///
233    /// This is a convenience constructor for local development and CLI tools. The resulting
234    /// metadata uses `http://localhost` as the `client_id` with both IPv4 and IPv6 loopback
235    /// redirect URIs.
236    pub fn default_localhost() -> Self
237    where
238        S: From<SmolStr> + AsRef<str>,
239    {
240        let scopes = Scopes::new(SmolStr::new_static("atproto transition:generic"))
241            .expect("valid scopes")
242            .convert();
243        Self::new_localhost(None, Some(scopes))
244    }
245
246    /// Create hosted client metadata with optional custom scopes.
247    ///
248    /// When `scopes` is `None`, the `atproto` scope is used.
249    /// Use the builder functions to set fields like the jwks_uri or client name, etc.
250    pub fn new(
251        redirect_uris: Vec<Uri<String>>,
252        client_id: Uri<String>,
253        scopes: Option<Scopes<S>>,
254    ) -> AtprotoClientMetadata<S>
255    where
256        S: From<SmolStr> + AsRef<str>,
257    {
258        let default_scopes: Scopes<S> = Scopes::new(SmolStr::new_static("atproto"))
259            .expect("valid scopes")
260            .convert();
261        AtprotoClientMetadata {
262            client_id: client_id.clone(),
263            client_uri: Some(client_id),
264            redirect_uris: redirect_uris,
265            grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
266            scopes: scopes.unwrap_or(default_scopes),
267            jwks_uri: None,
268            client_name: None,
269            logo_uri: None,
270            tos_uri: None,
271            privacy_policy_uri: None,
272        }
273    }
274
275    /// Create loopback client metadata with optional custom redirect URIs and scopes.
276    ///
277    /// Encodes non-default redirect URIs and scopes into the `client_id` query string as
278    /// required by the AT Protocol loopback client specification. When `redirect_uris` or
279    /// `scopes` are `None`, sensible defaults (IPv4 + IPv6 loopback addresses, `atproto` scope)
280    /// are used.
281    pub fn new_localhost(
282        redirect_uris: Option<Vec<Uri<String>>>,
283        scopes: Option<Scopes<S>>,
284    ) -> AtprotoClientMetadata<S>
285    where
286        S: From<SmolStr> + AsRef<str>,
287    {
288        // determine client_id
289        #[derive(serde::Serialize)]
290        struct Parameters {
291            #[serde(skip_serializing_if = "Option::is_none")]
292            redirect_uri: Option<Vec<SmolStr>>,
293            #[serde(skip_serializing_if = "Option::is_none")]
294            scope: Option<SmolStr>,
295        }
296        let redir_str = redirect_uris.as_ref().map(|uris| {
297            uris.iter()
298                .map(|u| u.as_str().trim_end_matches("/").to_smolstr())
299                .collect()
300        });
301        let query = serde_html_form::to_string(Parameters {
302            redirect_uri: redir_str,
303            scope: scopes.as_ref().map(|s| s.to_normalized_string()),
304        })
305        .ok();
306        let mut client_id = String::from("http://localhost/");
307        if let Some(query) = query
308            && !query.is_empty()
309        {
310            client_id.push_str(&format!("?{query}"));
311        }
312        let default_scopes: Scopes<S> = Scopes::new(SmolStr::new_static("atproto"))
313            .expect("valid scopes")
314            .convert();
315        AtprotoClientMetadata {
316            client_id: Uri::parse(client_id).unwrap(),
317            client_uri: None,
318            redirect_uris: redirect_uris.unwrap_or(vec![
319                Uri::parse("http://127.0.0.1".to_string()).unwrap(),
320                Uri::parse("http://[::1]".to_string()).unwrap(),
321            ]),
322            grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
323            scopes: scopes.unwrap_or(default_scopes),
324            jwks_uri: None,
325            client_name: None,
326            logo_uri: None,
327            tos_uri: None,
328            privacy_policy_uri: None,
329        }
330    }
331}
332
333/// Convert [`AtprotoClientMetadata`] into the [`OAuthClientMetadata`] wire format.
334///
335/// Validates all atproto-specific constraints (required scopes, grant types, redirect URIs),
336/// selects the appropriate `token_endpoint_auth_method` based on whether a keyset is provided,
337/// and serializes scopes and grant types into their string representations. Returns an error
338/// if any required field is missing or invalid.
339pub fn atproto_client_metadata<S>(
340    metadata: &AtprotoClientMetadata<S>,
341    keyset: &Option<Keyset>,
342) -> Result<OAuthClientMetadata<S>>
343where
344    S: BosStr + Ord + FromStr + Clone,
345    <S as FromStr>::Err: core::fmt::Debug,
346{
347    let is_loopback = metadata.client_id.scheme().as_str() == "http"
348        && metadata.client_id.authority().map(|a| a.host()) == Some("localhost");
349    let application_type = if is_loopback {
350        Some(S::from_static("native"))
351    } else {
352        Some(S::from_static("web"))
353    };
354    if metadata.redirect_uris.is_empty() {
355        return Err(Error::EmptyRedirectUris);
356    }
357    if !metadata.grant_types.contains(&GrantType::AuthorizationCode) {
358        return Err(Error::InvalidGrantTypes);
359    }
360    if !metadata.scopes.grants(&Scope::<S>::Atproto) {
361        return Err(Error::InvalidScope);
362    }
363    let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset {
364        let jwks = if metadata.jwks_uri.is_none() {
365            Some(keyset.public_jwks())
366        } else {
367            None
368        };
369        (AuthMethod::PrivateKeyJwt, metadata.jwks_uri.as_ref(), jwks)
370    } else {
371        (AuthMethod::None, None, None)
372    };
373    let client_id = metadata.client_id.as_str();
374    let client_uri = metadata
375        .client_uri
376        .as_ref()
377        .and_then(|u| S::from_str(u.as_str()).ok());
378    let redirect_uris = metadata
379        .redirect_uris
380        .iter()
381        .filter_map(|u| S::from_str(u.as_str()).ok())
382        .collect();
383    let jwks_uri = jwks_uri.as_ref().and_then(|u| S::from_str(u.as_str()).ok());
384    Ok(OAuthClientMetadata {
385        client_id: S::from_str(client_id).unwrap(),
386        client_uri,
387        redirect_uris,
388        application_type: application_type,
389        token_endpoint_auth_method: Some(S::from_static(auth_method.into())),
390        grant_types: Some(
391            metadata
392                .grant_types
393                .iter()
394                .map(|v| S::from_static(v.clone().into()))
395                .collect(),
396        ),
397        response_types: vec![S::from_static("code")],
398        scope: Some(S::from_str(metadata.scopes.to_normalized_string().as_str()).unwrap()),
399        dpop_bound_access_tokens: Some(true),
400        jwks_uri,
401        jwks,
402        token_endpoint_auth_signing_alg: if keyset.is_some() {
403            Some(S::from_static("ES256"))
404        } else {
405            None
406        },
407        client_name: metadata.client_name.as_ref().map(|c| c.clone()),
408        logo_uri: metadata
409            .logo_uri
410            .as_ref()
411            .and_then(|u| S::from_str(u.as_str()).ok()),
412        tos_uri: metadata
413            .tos_uri
414            .as_ref()
415            .and_then(|u| S::from_str(u.as_str()).ok()),
416        privacy_policy_uri: metadata
417            .privacy_policy_uri
418            .as_ref()
419            .and_then(|u| S::from_str(u.as_str()).ok()),
420    })
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use elliptic_curve::SecretKey;
427    use jose_jwk::{Jwk, Key, Parameters};
428    use p256::pkcs8::DecodePrivateKey;
429
430    const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
431MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T
4324i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P
433gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3
434-----END PRIVATE KEY-----"#;
435
436    #[test]
437    fn test_localhost_client_metadata_default() {
438        assert_eq!(
439            atproto_client_metadata(&AtprotoClientMetadata::new_localhost(None, None), &None)
440                .unwrap(),
441            OAuthClientMetadata {
442                client_id: SmolStr::new_static("http://localhost/"),
443                client_uri: None,
444                redirect_uris: vec![
445                    SmolStr::new_static("http://127.0.0.1"),
446                    SmolStr::new_static("http://[::1]"),
447                ],
448                application_type: Some(SmolStr::new_static("native")),
449                scope: Some(SmolStr::new_static("atproto")),
450                grant_types: Some(vec![
451                    SmolStr::new_static("authorization_code"),
452                    SmolStr::new_static("refresh_token")
453                ]),
454                response_types: vec![SmolStr::new_static("code")],
455                token_endpoint_auth_method: Some(AuthMethod::None.into()),
456                dpop_bound_access_tokens: Some(true),
457                jwks_uri: None,
458                jwks: None,
459                token_endpoint_auth_signing_alg: None,
460                tos_uri: None,
461                privacy_policy_uri: None,
462                client_name: None,
463                logo_uri: None,
464            }
465        );
466    }
467
468    #[test]
469    fn test_localhost_client_metadata_custom() {
470        assert_eq!(
471            atproto_client_metadata(
472                &AtprotoClientMetadata::new_localhost(
473                    Some(vec![
474                        Uri::parse("http://127.0.0.1/callback".to_string()).unwrap(),
475                        Uri::parse("http://[::1]/callback".to_string()).unwrap(),
476                    ]),
477                    Some(
478                        Scopes::new(SmolStr::from("account:email atproto transition:generic"))
479                            .unwrap()
480                    )
481                ),
482                &None
483            )
484            .expect("failed to convert metadata"),
485            OAuthClientMetadata {
486                client_id: SmolStr::new_static(
487                    "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric"
488                ),
489                client_uri: None,
490                redirect_uris: vec![
491                    SmolStr::new_static("http://127.0.0.1/callback"),
492                    SmolStr::new_static("http://[::1]/callback"),
493                ],
494                scope: Some(SmolStr::new_static(
495                    "account:email atproto transition:generic"
496                )),
497                application_type: Some(SmolStr::new_static("native")),
498                grant_types: Some(vec![
499                    SmolStr::new_static("authorization_code"),
500                    SmolStr::new_static("refresh_token")
501                ]),
502                response_types: vec![SmolStr::new_static("code")],
503                token_endpoint_auth_method: Some(AuthMethod::None.into()),
504                dpop_bound_access_tokens: Some(true),
505                jwks_uri: None,
506                jwks: None,
507                token_endpoint_auth_signing_alg: None,
508                tos_uri: None,
509                privacy_policy_uri: None,
510                client_name: None,
511                logo_uri: None,
512            }
513        );
514    }
515
516    #[test]
517    fn test_localhost_client_metadata_invalid() {
518        // Invalid inputs are coerced to http://localhost rather than failing
519        {
520            let out = atproto_client_metadata(
521                &AtprotoClientMetadata::new_localhost(
522                    Some(vec![Uri::parse("https://127.0.0.1".to_string()).unwrap()]),
523                    None,
524                ),
525                &None,
526            )
527            .expect("should coerce to 127.0.0.1");
528            assert_eq!(
529                out,
530                OAuthClientMetadata {
531                    client_id: SmolStr::new_static(
532                        "http://localhost/?redirect_uri=https%3A%2F%2F127.0.0.1"
533                    ),
534                    application_type: Some(SmolStr::new_static("native")),
535                    client_uri: None,
536                    redirect_uris: vec![SmolStr::new_static("https://127.0.0.1")],
537                    scope: Some(SmolStr::new_static("atproto")),
538                    grant_types: Some(vec![
539                        SmolStr::new_static("authorization_code"),
540                        SmolStr::new_static("refresh_token")
541                    ]),
542                    response_types: vec![SmolStr::new_static("code")],
543                    token_endpoint_auth_method: Some(AuthMethod::None.into()),
544                    dpop_bound_access_tokens: Some(true),
545                    jwks_uri: None,
546                    jwks: None,
547                    token_endpoint_auth_signing_alg: None,
548                    tos_uri: None,
549                    privacy_policy_uri: None,
550                    client_name: None,
551                    logo_uri: None,
552                }
553            );
554        }
555        {
556            let out = atproto_client_metadata(
557                &AtprotoClientMetadata::new_localhost(
558                    Some(vec![
559                        Uri::parse("http://localhost:8000".to_string()).unwrap(),
560                    ]),
561                    None,
562                ),
563                &None,
564            )
565            .expect("should coerce to 127.0.0.1");
566            assert_eq!(
567                out,
568                OAuthClientMetadata {
569                    client_id: SmolStr::new_static(
570                        "http://localhost/?redirect_uri=http%3A%2F%2Flocalhost%3A8000"
571                    ),
572                    client_uri: None,
573                    redirect_uris: vec![SmolStr::new_static("http://localhost:8000")],
574                    scope: Some(SmolStr::new_static("atproto")),
575                    grant_types: Some(vec![
576                        SmolStr::new_static("authorization_code"),
577                        SmolStr::new_static("refresh_token")
578                    ]),
579                    application_type: Some(SmolStr::new_static("native")),
580                    response_types: vec![SmolStr::new_static("code")],
581                    token_endpoint_auth_method: Some(AuthMethod::None.into()),
582                    dpop_bound_access_tokens: Some(true),
583                    jwks_uri: None,
584                    jwks: None,
585                    token_endpoint_auth_signing_alg: None,
586                    tos_uri: None,
587                    privacy_policy_uri: None,
588                    client_name: None,
589                    logo_uri: None,
590                }
591            );
592        }
593        {
594            let out = atproto_client_metadata(
595                &AtprotoClientMetadata::new_localhost(
596                    Some(vec![Uri::parse("http://192.168.0.0/".to_string()).unwrap()]),
597                    None,
598                ),
599                &None,
600            )
601            .expect("should coerce to 127.0.0.1");
602            assert_eq!(
603                out,
604                OAuthClientMetadata {
605                    client_id: SmolStr::new_static(
606                        "http://localhost/?redirect_uri=http%3A%2F%2F192.168.0.0"
607                    ),
608                    client_uri: None,
609                    redirect_uris: vec![SmolStr::new_static("http://192.168.0.0/")],
610                    scope: Some(SmolStr::new_static("atproto")),
611                    grant_types: Some(vec![
612                        SmolStr::new_static("authorization_code"),
613                        SmolStr::new_static("refresh_token")
614                    ]),
615                    application_type: Some(SmolStr::new_static("native")),
616                    response_types: vec![SmolStr::new_static("code")],
617                    token_endpoint_auth_method: Some(AuthMethod::None.into()),
618                    dpop_bound_access_tokens: Some(true),
619                    jwks_uri: None,
620                    jwks: None,
621                    token_endpoint_auth_signing_alg: None,
622                    tos_uri: None,
623                    privacy_policy_uri: None,
624                    client_name: None,
625                    logo_uri: None,
626                }
627            );
628        }
629    }
630
631    #[test]
632    fn test_client_metadata() {
633        let metadata = AtprotoClientMetadata {
634            client_id: Uri::parse("https://example.com/client_metadata.json".to_string()).unwrap(),
635            client_uri: Some(Uri::parse("https://example.com".to_string()).unwrap()),
636            redirect_uris: vec![Uri::parse("https://example.com/callback".to_string()).unwrap()],
637            grant_types: vec![GrantType::AuthorizationCode],
638            scopes: Scopes::new(SmolStr::new_static("atproto")).unwrap(),
639            jwks_uri: None,
640            client_name: None,
641            logo_uri: None,
642            tos_uri: None,
643            privacy_policy_uri: None,
644        };
645        {
646            // Non-loopback clients without a keyset should fail (must provide JWKS)
647            let metadata = metadata.clone();
648            let err = atproto_client_metadata(&metadata, &None);
649            assert!(err.is_ok());
650        }
651        {
652            let metadata = metadata.clone();
653            let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY)
654                .expect("failed to parse private key");
655            let keys = vec![Jwk {
656                key: Key::from(&secret_key.into()),
657                prm: Parameters {
658                    kid: Some(String::from("kid00")),
659                    ..Default::default()
660                },
661            }];
662            let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset");
663            assert_eq!(
664                atproto_client_metadata(&metadata, &Some(keyset.clone()))
665                    .expect("failed to convert metadata"),
666                OAuthClientMetadata {
667                    client_id: SmolStr::new_static("https://example.com/client_metadata.json"),
668                    client_uri: Some(SmolStr::new_static("https://example.com")),
669                    redirect_uris: vec![SmolStr::new_static("https://example.com/callback")],
670                    application_type: Some(SmolStr::new_static("web")),
671                    scope: Some(SmolStr::new_static("atproto")),
672                    grant_types: Some(vec![SmolStr::new_static("authorization_code")]),
673                    token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()),
674                    dpop_bound_access_tokens: Some(true),
675                    response_types: vec![SmolStr::new_static("code")],
676                    jwks_uri: None,
677                    jwks: Some(keyset.public_jwks()),
678                    token_endpoint_auth_signing_alg: Some(SmolStr::new_static("ES256")),
679                    client_name: None,
680                    logo_uri: None,
681                    tos_uri: None,
682                    privacy_policy_uri: None,
683                }
684            );
685        }
686    }
687}