Skip to main content

atrium_oauth/
atproto.rs

1use crate::keyset::Keyset;
2use crate::types::{OAuthClientMetadata, TryIntoOAuthClientMetadata};
3use atrium_xrpc::http::uri::{InvalidUri, Scheme, Uri};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7#[derive(Error, Debug)]
8pub enum Error {
9    #[error("`client_id` must be a valid URL")]
10    InvalidClientId,
11    #[error("`grant_types` must include `authorization_code`")]
12    InvalidGrantTypes,
13    #[error("`scope` must not include `atproto`")]
14    InvalidScope,
15    #[error("`redirect_uris` must not be empty")]
16    EmptyRedirectUris,
17    #[error("`private_key_jwt` auth method requires `jwks` keys")]
18    EmptyJwks,
19    #[error(
20        "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided"
21    )]
22    AuthSigningAlg,
23    #[error(transparent)]
24    SerdeHtmlForm(#[from] serde_html_form::ser::Error),
25    #[error(transparent)]
26    LocalhostClient(#[from] LocalhostClientError),
27}
28
29#[derive(Error, Debug)]
30pub enum LocalhostClientError {
31    #[error("invalid redirect_uri: {0}")]
32    Invalid(#[from] InvalidUri),
33    #[error("loopback client_id must use `http:` redirect_uri")]
34    NotHttpScheme,
35    #[error("loopback client_id must not use `localhost` as redirect_uri hostname")]
36    Localhost,
37    #[error("loopback client_id must not use loopback addresses as redirect_uri")]
38    NotLoopbackHost,
39}
40
41pub type Result<T> = core::result::Result<T, Error>;
42
43#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum AuthMethod {
46    None,
47    // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
48    PrivateKeyJwt,
49}
50
51impl From<AuthMethod> for String {
52    fn from(value: AuthMethod) -> Self {
53        match value {
54            AuthMethod::None => String::from("none"),
55            AuthMethod::PrivateKeyJwt => String::from("private_key_jwt"),
56        }
57    }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum GrantType {
63    AuthorizationCode,
64    RefreshToken,
65}
66
67impl From<GrantType> for String {
68    fn from(value: GrantType) -> Self {
69        match value {
70            GrantType::AuthorizationCode => String::from("authorization_code"),
71            GrantType::RefreshToken => String::from("refresh_token"),
72        }
73    }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(untagged)]
78pub enum Scope {
79    Known(KnownScope),
80    Unknown(String),
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub enum KnownScope {
85    #[serde(rename = "atproto")]
86    Atproto,
87    #[serde(rename = "transition:generic")]
88    TransitionGeneric,
89    #[serde(rename = "transition:chat.bsky")]
90    TransitionChatBsky,
91}
92
93impl AsRef<str> for Scope {
94    fn as_ref(&self) -> &str {
95        match self {
96            Self::Known(KnownScope::Atproto) => "atproto",
97            Self::Known(KnownScope::TransitionGeneric) => "transition:generic",
98            Self::Known(KnownScope::TransitionChatBsky) => "transition:chat.bsky",
99            Self::Unknown(value) => value,
100        }
101    }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
105pub struct AtprotoLocalhostClientMetadata {
106    pub redirect_uris: Option<Vec<String>>,
107    pub scopes: Option<Vec<Scope>>,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AtprotoClientMetadata {
112    pub client_id: String,
113    pub client_uri: Option<String>,
114    pub redirect_uris: Vec<String>,
115    pub token_endpoint_auth_method: AuthMethod,
116    pub grant_types: Vec<GrantType>,
117    pub scopes: Vec<Scope>,
118    pub jwks_uri: Option<String>,
119    pub token_endpoint_auth_signing_alg: Option<String>,
120}
121
122impl TryIntoOAuthClientMetadata for AtprotoLocalhostClientMetadata {
123    type Error = Error;
124
125    fn try_into_client_metadata(self, _: &Option<Keyset>) -> Result<OAuthClientMetadata> {
126        // validate redirect_uris
127        if let Some(redirect_uris) = &self.redirect_uris {
128            for redirect_uri in redirect_uris {
129                let uri = redirect_uri.parse::<Uri>().map_err(LocalhostClientError::Invalid)?;
130                if uri.scheme() != Some(&Scheme::HTTP) {
131                    return Err(Error::LocalhostClient(LocalhostClientError::NotHttpScheme));
132                }
133                if uri.host() == Some("localhost") {
134                    return Err(Error::LocalhostClient(LocalhostClientError::Localhost));
135                }
136                if uri.host().is_none_or(|host| host != "127.0.0.1" && host != "[::1]") {
137                    return Err(Error::LocalhostClient(LocalhostClientError::NotLoopbackHost));
138                }
139            }
140        }
141        // determine client_id
142        #[derive(serde::Serialize)]
143        struct Parameters {
144            #[serde(skip_serializing_if = "Option::is_none")]
145            redirect_uri: Option<Vec<String>>,
146            #[serde(skip_serializing_if = "Option::is_none")]
147            scope: Option<String>,
148        }
149        let query = serde_html_form::to_string(Parameters {
150            redirect_uri: self.redirect_uris.clone(),
151            scope: self
152                .scopes
153                .map(|scopes| scopes.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(" ")),
154        })?;
155        let mut client_id = String::from("http://localhost");
156        if !query.is_empty() {
157            client_id.push_str(&format!("?{query}"));
158        }
159        Ok(OAuthClientMetadata {
160            client_id,
161            client_uri: None,
162            redirect_uris: self
163                .redirect_uris
164                .unwrap_or(vec![String::from("http://127.0.0.1/"), String::from("http://[::1]/")]),
165            scope: None,
166            grant_types: None, // will be set to `authorization_code` and `refresh_token`
167            token_endpoint_auth_method: Some(String::from("none")),
168            dpop_bound_access_tokens: None, // will be set to `true`
169            jwks_uri: None,
170            jwks: None,
171            token_endpoint_auth_signing_alg: None,
172        })
173    }
174}
175
176impl TryIntoOAuthClientMetadata for AtprotoClientMetadata {
177    type Error = Error;
178
179    fn try_into_client_metadata(self, keyset: &Option<Keyset>) -> Result<OAuthClientMetadata> {
180        if self.client_id.parse::<Uri>().is_err() {
181            return Err(Error::InvalidClientId);
182        }
183        if self.redirect_uris.is_empty() {
184            return Err(Error::EmptyRedirectUris);
185        }
186        if !self.grant_types.contains(&GrantType::AuthorizationCode) {
187            return Err(Error::InvalidGrantTypes);
188        }
189        if !self.scopes.contains(&Scope::Known(KnownScope::Atproto)) {
190            return Err(Error::InvalidScope);
191        }
192        let (jwks_uri, mut jwks) = (self.jwks_uri, None);
193        match self.token_endpoint_auth_method {
194            AuthMethod::None => {
195                if self.token_endpoint_auth_signing_alg.is_some() {
196                    return Err(Error::AuthSigningAlg);
197                }
198            }
199            AuthMethod::PrivateKeyJwt => {
200                if let Some(keyset) = keyset {
201                    if self.token_endpoint_auth_signing_alg.is_none() {
202                        return Err(Error::AuthSigningAlg);
203                    }
204                    if jwks_uri.is_none() {
205                        jwks = Some(keyset.public_jwks());
206                    }
207                } else {
208                    return Err(Error::EmptyJwks);
209                }
210            }
211        }
212        Ok(OAuthClientMetadata {
213            client_id: self.client_id,
214            client_uri: self.client_uri,
215            redirect_uris: self.redirect_uris,
216            token_endpoint_auth_method: Some(self.token_endpoint_auth_method.into()),
217            grant_types: Some(self.grant_types.into_iter().map(|v| v.into()).collect()),
218            scope: Some(self.scopes.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(" ")),
219            dpop_bound_access_tokens: Some(true),
220            jwks_uri,
221            jwks,
222            token_endpoint_auth_signing_alg: self.token_endpoint_auth_signing_alg,
223        })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use elliptic_curve::SecretKey;
231    use jose_jwk::{Jwk, Key, Parameters};
232    use p256::pkcs8::DecodePrivateKey;
233
234    const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
235MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T
2364i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P
237gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3
238-----END PRIVATE KEY-----"#;
239
240    #[test]
241    fn test_localhost_client_metadata_default() {
242        let metadata = AtprotoLocalhostClientMetadata::default();
243        assert_eq!(
244            metadata.try_into_client_metadata(&None).expect("failed to convert metadata"),
245            OAuthClientMetadata {
246                client_id: String::from("http://localhost"),
247                client_uri: None,
248                redirect_uris: vec![
249                    String::from("http://127.0.0.1/"),
250                    String::from("http://[::1]/"),
251                ],
252                scope: None,
253                grant_types: None,
254                token_endpoint_auth_method: Some(AuthMethod::None.into()),
255                dpop_bound_access_tokens: None,
256                jwks_uri: None,
257                jwks: None,
258                token_endpoint_auth_signing_alg: None,
259            }
260        );
261    }
262
263    #[test]
264    fn test_localhost_client_metadata_custom() {
265        let metadata = AtprotoLocalhostClientMetadata {
266            redirect_uris: Some(vec![
267                String::from("http://127.0.0.1/callback"),
268                String::from("http://[::1]/callback"),
269            ]),
270            scopes: Some(vec![
271                Scope::Known(KnownScope::Atproto),
272                Scope::Known(KnownScope::TransitionGeneric),
273                Scope::Unknown(String::from("unknown")),
274            ]),
275        };
276        assert_eq!(
277            metadata.try_into_client_metadata(&None).expect("failed to convert metadata"),
278            OAuthClientMetadata {
279                client_id: String::from(
280                    "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcallback&scope=atproto+transition%3Ageneric+unknown"
281                ),
282                client_uri: None,
283                redirect_uris: vec![
284                    String::from("http://127.0.0.1/callback"),
285                    String::from("http://[::1]/callback"),
286                ],
287                scope: None,
288                grant_types: None,
289                token_endpoint_auth_method: Some(AuthMethod::None.into()),
290                dpop_bound_access_tokens: None,
291                jwks_uri: None,
292                jwks: None,
293                token_endpoint_auth_signing_alg: None,
294            }
295        );
296    }
297
298    #[test]
299    fn test_localhost_client_metadata_invalid() {
300        {
301            let metadata = AtprotoLocalhostClientMetadata {
302                redirect_uris: Some(vec![String::from("http://")]),
303                ..Default::default()
304            };
305            let err = metadata.try_into_client_metadata(&None).expect_err("expected to fail");
306            assert!(matches!(err, Error::LocalhostClient(LocalhostClientError::Invalid(_))));
307        }
308        {
309            let metadata = AtprotoLocalhostClientMetadata {
310                redirect_uris: Some(vec![String::from("https://127.0.0.1/")]),
311                ..Default::default()
312            };
313            let err = metadata.try_into_client_metadata(&None).expect_err("expected to fail");
314            assert!(matches!(err, Error::LocalhostClient(LocalhostClientError::NotHttpScheme)));
315        }
316        {
317            let metadata = AtprotoLocalhostClientMetadata {
318                redirect_uris: Some(vec![String::from("http://localhost:8000/")]),
319                ..Default::default()
320            };
321            let err = metadata.try_into_client_metadata(&None).expect_err("expected to fail");
322            assert!(matches!(err, Error::LocalhostClient(LocalhostClientError::Localhost)));
323        }
324        {
325            let metadata = AtprotoLocalhostClientMetadata {
326                redirect_uris: Some(vec![String::from("http://192.168.0.0/")]),
327                ..Default::default()
328            };
329            let err = metadata.try_into_client_metadata(&None).expect_err("expected to fail");
330            assert!(matches!(err, Error::LocalhostClient(LocalhostClientError::NotLoopbackHost)));
331        }
332    }
333
334    #[test]
335    fn test_client_metadata() {
336        let metadata = AtprotoClientMetadata {
337            client_id: String::from("https://example.com/client_metadata.json"),
338            client_uri: Some(String::from("https://example.com")),
339            redirect_uris: vec![String::from("https://example.com/callback")],
340            token_endpoint_auth_method: AuthMethod::PrivateKeyJwt,
341            grant_types: vec![GrantType::AuthorizationCode],
342            scopes: vec![Scope::Known(KnownScope::Atproto)],
343            jwks_uri: None,
344            token_endpoint_auth_signing_alg: Some(String::from("ES256")),
345        };
346        {
347            let metadata = metadata.clone();
348            let err = metadata.try_into_client_metadata(&None).expect_err("expected to fail");
349            assert!(matches!(err, Error::EmptyJwks));
350        }
351        {
352            let metadata = metadata.clone();
353            let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY)
354                .expect("failed to parse private key");
355            let keys = vec![Jwk {
356                key: Key::from(&secret_key.into()),
357                prm: Parameters { kid: Some(String::from("kid00")), ..Default::default() },
358            }];
359            let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset");
360            assert_eq!(
361                metadata
362                    .try_into_client_metadata(&Some(keyset.clone()))
363                    .expect("failed to convert metadata"),
364                OAuthClientMetadata {
365                    client_id: String::from("https://example.com/client_metadata.json"),
366                    client_uri: Some(String::from("https://example.com")),
367                    redirect_uris: vec![String::from("https://example.com/callback"),],
368                    scope: Some(String::from("atproto")),
369                    grant_types: Some(vec![String::from("authorization_code")]),
370                    token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()),
371                    dpop_bound_access_tokens: Some(true),
372                    jwks_uri: None,
373                    jwks: Some(keyset.public_jwks()),
374                    token_endpoint_auth_signing_alg: Some(String::from("ES256")),
375                }
376            );
377        }
378    }
379
380    #[test]
381    fn test_scope_serde() {
382        #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
383        struct Scopes {
384            scopes: Vec<Scope>,
385        }
386
387        let scopes = Scopes {
388            scopes: vec![
389                Scope::Known(KnownScope::Atproto),
390                Scope::Known(KnownScope::TransitionGeneric),
391                Scope::Unknown(String::from("unknown")),
392            ],
393        };
394        let json = serde_json::to_string(&scopes).expect("failed to serialize scopes");
395        assert_eq!(json, r#"{"scopes":["atproto","transition:generic","unknown"]}"#);
396        let deserialized =
397            serde_json::from_str::<Scopes>(&json).expect("failed to deserialize scopes");
398        assert_eq!(deserialized, scopes);
399    }
400}