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 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 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 #[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, token_endpoint_auth_method: Some(String::from("none")),
168 dpop_bound_access_tokens: None, 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}