don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: setup push-key authorization flow

Signed-off-by: tjh <x@tjh.dev>

tjh 431608fa 977eab83

+4363 -1279
+1
.gitignore
··· 3 3 allowed_signers/ 4 4 did:*/ 5 5 deleted/ 6 + node_modules/ 6 7 jetstream.json 7 8 git_config 8 9 .env
+47 -11
Cargo.lock
··· 156 156 checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" 157 157 dependencies = [ 158 158 "aws-lc-sys", 159 - "untrusted 0.7.1", 160 159 "zeroize", 161 160 ] 162 161 163 162 [[package]] 164 163 name = "aws-lc-sys" 165 - version = "0.37.0" 164 + version = "0.37.1" 166 165 source = "registry+https://github.com/rust-lang/crates.io-index" 167 - checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" 166 + checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" 168 167 dependencies = [ 169 168 "cc", 170 169 "cmake", ··· 2049 2050 "gordian-identity", 2050 2051 "gordian-types", 2051 2052 "multibase", 2053 + "rand 0.9.2", 2054 + "reqwest", 2052 2055 "serde", 2053 2056 "serde_json", 2054 2057 "thiserror 2.0.18", 2058 + "time", 2059 + "tracing", 2055 2060 "url", 2056 2061 ] 2057 2062 ··· 2138 2135 "clap", 2139 2136 "clap_complete", 2140 2137 "data-encoding", 2138 + "exn", 2141 2139 "futures-util", 2142 2140 "gix", 2143 2141 "gordian-auth", ··· 2149 2145 "gordian-types", 2150 2146 "http-body-util", 2151 2147 "hyper-util", 2148 + "maud", 2152 2149 "mimetype-detector", 2153 2150 "moka", 2154 2151 "multibase", ··· 2933 2928 checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 2934 2929 2935 2930 [[package]] 2931 + name = "maud" 2932 + version = "0.27.0" 2933 + source = "registry+https://github.com/rust-lang/crates.io-index" 2934 + checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" 2935 + dependencies = [ 2936 + "axum-core", 2937 + "http", 2938 + "itoa", 2939 + "maud_macros", 2940 + ] 2941 + 2942 + [[package]] 2943 + name = "maud_macros" 2944 + version = "0.27.0" 2945 + source = "registry+https://github.com/rust-lang/crates.io-index" 2946 + checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" 2947 + dependencies = [ 2948 + "proc-macro2", 2949 + "proc-macro2-diagnostics", 2950 + "quote", 2951 + "syn", 2952 + ] 2953 + 2954 + [[package]] 2936 2955 name = "maybe-async" 2937 2956 version = "0.2.10" 2938 2957 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3354 3325 ] 3355 3326 3356 3327 [[package]] 3328 + name = "proc-macro2-diagnostics" 3329 + version = "0.10.1" 3330 + source = "registry+https://github.com/rust-lang/crates.io-index" 3331 + checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 3332 + dependencies = [ 3333 + "proc-macro2", 3334 + "quote", 3335 + "syn", 3336 + "version_check", 3337 + ] 3338 + 3339 + [[package]] 3357 3340 name = "prodash" 3358 3341 version = "31.0.0" 3359 3342 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3618 3577 "rustls-platform-verifier", 3619 3578 "serde", 3620 3579 "serde_json", 3580 + "serde_urlencoded", 3621 3581 "sync_wrapper", 3622 3582 "tokio", 3623 3583 "tokio-rustls", ··· 3657 3615 "cfg-if", 3658 3616 "getrandom 0.2.17", 3659 3617 "libc", 3660 - "untrusted 0.9.0", 3618 + "untrusted", 3661 3619 "windows-sys 0.52.0", 3662 3620 ] 3663 3621 ··· 3782 3740 "aws-lc-rs", 3783 3741 "ring", 3784 3742 "rustls-pki-types", 3785 - "untrusted 0.9.0", 3743 + "untrusted", 3786 3744 ] 3787 3745 3788 3746 [[package]] ··· 4911 4869 version = "0.2.6" 4912 4870 source = "registry+https://github.com/rust-lang/crates.io-index" 4913 4871 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 4914 - 4915 - [[package]] 4916 - name = "untrusted" 4917 - version = "0.7.1" 4918 - source = "registry+https://github.com/rust-lang/crates.io-index" 4919 - checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" 4920 4872 4921 4873 [[package]] 4922 4874 name = "untrusted"
+4 -3
Cargo.toml
··· 15 15 [workspace.package] 16 16 version = "0.0.0" 17 17 authors = ["tjh <did:plc:65gha4t3avpfpzmvpbwovss7>"] 18 - repository = "https://tangled.org/@tjh.dev/gordian" 18 + repository = "https://tangled.org/tjh.dev/gordian" 19 19 license = "MIT or Apache-2.0" 20 20 edition = "2024" 21 21 publish = false ··· 29 29 gordian-pds = { path = "crates/gordian-pds" } 30 30 31 31 anyhow = "1.0.100" 32 + aws-lc-rs = { version = "1.15.4", default-features = false, features = ["alloc", "aws-lc-sys"] } 32 33 axum = "0.8.4" 33 34 data-encoding = "2.9.0" 34 35 exn = "0.3.0" 35 36 gix = { version = "0.78.0", features = ["max-performance"] } 36 - reqwest = { version = "0.13.1", features = ["json"] } 37 + reqwest = { version = "0.13.1", features = ["form", "json"] } 37 38 serde = { version = "1.0.226", features = ["derive"] } 38 39 serde_json = { version = "1.0.145", features = ["raw_value"] } 39 40 thiserror = "2.0.16" ··· 44 43 45 44 [profile.release] 46 45 panic = "abort" 47 - lto = "fat" 46 + # lto = "fat" 48 47 strip = true 49 48 50 49 [profile.dev]
+9
Cross.toml
··· 1 1 [build.env] 2 2 passthrough = ["SQLX_OFFLINE=true"] 3 + 4 + [target.x86_64-unknown-linux-gnu] 5 + image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu@sha256:99758e86639d254c90c8b121550b4e5c5faaceebc6e3cd1a6d6dc37540112f97" 6 + pre-build = [ 7 + "dpkg --add-architecture $CROSS_DEB_ARCH", 8 + "apt-get update && apt-get install --assume-yes curl:$CROSS_DEB_ARCH", 9 + "curl -fsSL https://deb.nodesource.com/setup_23.x | bash", 10 + "apt-get install --assume-yes nodejs:$CROSS_DEB_ARCH" 11 + ]
+9 -5
crates/gordian-auth/Cargo.toml
··· 11 11 gordian-types = { workspace = true, features = ["serde"] } 12 12 gordian-identity = { workspace = true } 13 13 14 - serde.workspace = true 14 + reqwest = { workspace = true } 15 + serde = { workspace = true } 15 16 serde_json = { workspace = true, features = ["preserve_order"] } 16 - thiserror.workspace = true 17 - url.workspace = true 17 + thiserror = { workspace = true } 18 + time = { workspace = true } 19 + tracing = { workspace = true } 20 + url = { workspace = true } 18 21 19 - aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] } 22 + aws-lc-rs.workspace = true 20 23 data-encoding = "2.9.0" 21 - multibase = "0.9.1" 22 24 exn = "0.3.0" 25 + multibase = "0.9.1" 26 + rand = "0.9.2"
+522
crates/gordian-auth/src/client.rs
··· 1 + use std::borrow::Cow; 2 + use std::sync::Arc; 3 + use std::time::Duration; 4 + 5 + use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED_SIGNING; 6 + use aws_lc_rs::signature::EcdsaKeyPair; 7 + use exn::Exn; 8 + use exn::OptionExt as _; 9 + use exn::ResultExt as _; 10 + use gordian_identity::Resolver; 11 + use gordian_types::Did; 12 + use gordian_types::Handle; 13 + use serde::Serialize; 14 + use time::OffsetDateTime; 15 + use url::Url; 16 + 17 + use crate::HttpClient; 18 + use crate::Request; 19 + use crate::dpop::HttpClientExt as _; 20 + use crate::pkce; 21 + use crate::pkce::CodeChallengeMethod; 22 + use crate::pkce::ProofKeyCodeExchange; 23 + use crate::resources::AuthorizationMetadata; 24 + use crate::resources::ClientMetadata; 25 + use crate::resources::IntoAuthorizationServerUrl; 26 + use crate::resources::IntoProtectedResourceUrl; 27 + use crate::resources::ProtectedResource; 28 + use crate::types::CallbackSuccess; 29 + use crate::types::ClientAssertionType; 30 + use crate::types::GrantType; 31 + use crate::types::PushedAuthorizationResponse; 32 + use crate::types::ResponseType; 33 + use crate::types::SigningAlgorithm; 34 + use crate::types::TokenResponse; 35 + 36 + /// OAuth client. 37 + #[derive(Debug)] 38 + pub struct Client { 39 + /// Client metadata used to initialise the client. 40 + pub metadata: ClientMetadata, 41 + 42 + // Key-pair for client assertion. 43 + assertion_key: Option<Arc<EcdsaKeyPair>>, 44 + 45 + http: HttpClient, 46 + resolver: Resolver, 47 + } 48 + 49 + impl Client { 50 + #[must_use] 51 + pub fn new_with(metadata: ClientMetadata, http: &HttpClient, resolver: &Resolver) -> Self { 52 + Self { 53 + metadata, 54 + assertion_key: None, 55 + http: http.clone(), 56 + resolver: resolver.clone(), 57 + } 58 + } 59 + 60 + /// Set the client assertion key. 61 + #[must_use] 62 + pub fn with_client_assertion_key(mut self, client_key: Arc<EcdsaKeyPair>) -> Self { 63 + self.assertion_key = Some(client_key); 64 + self 65 + } 66 + 67 + /// Prepare a pushed authorization request. 68 + /// 69 + /// # Errors 70 + /// 71 + /// Returns an error under the following conditions: 72 + /// 73 + /// - A suitable authorization server cannot be found from `hint`. 74 + /// - A DPoP key-pair cannot be generated. 75 + pub async fn prepare_pushed_authorization<S: AsRef<str>>( 76 + &self, 77 + hint: S, 78 + ) -> Result<PushedAuthorizationClient<'_>, Exn<Error>> { 79 + let err = || Error::new("Unable to begin authorization"); 80 + 81 + let (hint, authorization_meta) = self 82 + .resolve_authorization_meta(hint.as_ref()) 83 + .await 84 + .or_raise(err)?; 85 + 86 + Self::validate_authorization_meta(&authorization_meta).or_raise(err)?; 87 + 88 + let pkce = ProofKeyCodeExchange::default(); 89 + let dpop_key_pair = EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING) 90 + .or_raise(|| Error::new("Could not generate a DPoP key pair")) 91 + .or_raise(err)?; 92 + 93 + Ok(PushedAuthorizationClient { 94 + client: self, 95 + authorization_meta, 96 + pkce: Some(pkce), 97 + dpop_key_pair, 98 + hint, 99 + }) 100 + } 101 + 102 + /// Request an authorization code. 103 + /// 104 + /// # Errors 105 + /// 106 + /// Returns an error if: 107 + /// 108 + /// - The token request cannot be constructed. 109 + /// - The authorization server responds with an error. 110 + pub async fn request_token( 111 + &self, 112 + par: &PushedAuthorization, 113 + callback: &CallbackSuccess, 114 + redirect_uri: &Url, 115 + ) -> Result<TokenResponse, Exn<Error>> { 116 + #[derive(Debug, Serialize)] 117 + struct TokenRequest<'a> { 118 + redirect_uri: &'a Url, 119 + grant_type: GrantType, 120 + code: &'a str, 121 + code_verifier: Option<&'a pkce::CodeVerifier>, 122 + client_id: &'a Url, 123 + #[serde(flatten)] 124 + pub client_assertion: Option<ClientAssertion>, 125 + } 126 + 127 + let request = TokenRequest { 128 + redirect_uri, 129 + grant_type: GrantType::AuthorizationCode, 130 + code: &callback.code, 131 + code_verifier: par.pkce.as_ref().map(|pkce| &pkce.code_verifier), 132 + client_id: &self.metadata.client_id, 133 + client_assertion: self.client_assertion(&par.authorization_meta.issuer), 134 + }; 135 + 136 + let request = self 137 + .http 138 + .post(par.authorization_meta.token_endpoint.clone()) 139 + .form(&request) 140 + .build() 141 + .or_raise(|| Error::new("Could not build token request"))?; 142 + 143 + let mut nonce = self.get_dpop_nonce(&par.authorization_meta.issuer).await; 144 + 145 + let response: TokenResponse = self 146 + .http 147 + .execute_with_dpop(request, &mut nonce, &par.dpop_key_pair) 148 + .await 149 + .or_raise(|| Error::new("Unable to request token"))?; 150 + 151 + if let Some(nonce) = nonce { 152 + self.store_dpop_nonce(&par.authorization_meta.issuer, &nonce) 153 + .await; 154 + } 155 + 156 + Ok(response) 157 + } 158 + 159 + fn validate_authorization_meta(meta: &AuthorizationMetadata) -> Result<(), Exn<Error>> { 160 + exn::ensure!( 161 + meta.require_pushed_authorization_requests, 162 + Error::new("Authorization server does not require pushed authorization requests") 163 + ); 164 + 165 + exn::ensure!( 166 + meta.scopes_supported.contains("atproto"), 167 + Error::new("Authorization server does not support \"atproto\" scope") 168 + ); 169 + 170 + exn::ensure!( 171 + meta.code_challenge_methods_supported 172 + .contains(&CodeChallengeMethod::S256), 173 + Error::new("Authorization server does not support \"S256\" code challenge method") 174 + ); 175 + 176 + exn::ensure!( 177 + meta.dpop_signing_alg_values_supported 178 + .contains(&SigningAlgorithm::ES256), 179 + Error::new("Authorization server does not support \"ES256\" signing algorithm") 180 + ); 181 + 182 + exn::ensure!( 183 + meta.grant_types_supported 184 + .contains(&GrantType::AuthorizationCode), 185 + Error::new("Authorization server does not support \"authorization_code\" grant type") 186 + ); 187 + 188 + Ok(()) 189 + } 190 + 191 + /// Resolve `hint` to an authorization server, returning the authorization 192 + /// server's metadata, and a normalised `hint` if it was a handle or 193 + /// DID. 194 + /// 195 + /// `hint` may be a DID, handle, or URL. 196 + /// 197 + /// # Errors 198 + /// 199 + /// Returns an error if an authorization server cannot be found. 200 + async fn resolve_authorization_meta( 201 + &self, 202 + hint: &str, 203 + ) -> Result<(Option<String>, AuthorizationMetadata), Exn<Error>> { 204 + let hint = hint.trim_start_matches('@').trim_start_matches("at://"); 205 + 206 + let mut err = None; 207 + 208 + if Did::parse(hint).is_ok() || Handle::parse(hint).is_ok() { 209 + match self.resolver.resolve(hint).await { 210 + Ok((_, doc)) => { 211 + // 212 + // We've successfully resolved the hint as a identity so assume any further 213 + // errors resolving the authorization server are terminal. 214 + // 215 + let resource = doc 216 + .atproto_pds() 217 + .ok_or_raise(|| Error::new("Identity does not have an associated PDS"))?; 218 + let resource = self 219 + .protected_resource_meta(resource) 220 + .await 221 + .or_raise(|| Error::new("Failed to resolve protected resource"))?; 222 + for auth_url in resource.authorization_servers { 223 + if let Ok(auth_meta) = self.authorization_meta(auth_url).await { 224 + return Ok((Some(hint.to_string()), auth_meta)); 225 + } 226 + } 227 + 228 + exn::bail!(Error::new( 229 + "Protected resource does declare any usable authorization servers" 230 + )); 231 + } 232 + Err(error) => _ = err.replace(error), 233 + } 234 + } 235 + 236 + if let Some(url_hint) = coalesce_to_url(hint) { 237 + // The hint may be a PDS. 238 + if let Ok(resource) = self.protected_resource_meta(&url_hint).await { 239 + for auth_url in resource.authorization_servers { 240 + if let Ok(auth_meta) = self.authorization_meta(auth_url).await { 241 + return Ok((None, auth_meta)); 242 + } 243 + } 244 + 245 + exn::bail!(Error::new( 246 + "Protected resource does declare any usable authorization servers" 247 + )); 248 + } 249 + 250 + // The hint may be an authorization server. 251 + if let Ok(auth_meta) = self.authorization_meta(url_hint).await { 252 + return Ok((None, auth_meta)); 253 + } 254 + } 255 + 256 + Err(match err { 257 + Some(error) => Err(error).or_raise(|| Error::new(""))?, 258 + None => Exn::new(Error::new( 259 + "Unable to extract authorization server from hint", 260 + )), 261 + }) 262 + } 263 + 264 + /// Fetch the "/.well-known/oauth-protected-resource" document for a 265 + /// resource. 266 + async fn protected_resource_meta<R: IntoProtectedResourceUrl>( 267 + &self, 268 + resource: R, 269 + ) -> Result<ProtectedResource, reqwest::Error> { 270 + let url = resource.into_protected_resource_url(); 271 + let meta: ProtectedResource = self.http.get(url).send().await?.json().await?; 272 + 273 + // @TODO Check meta.resource corresponds to the fetched URL. 274 + 275 + Ok(meta) 276 + } 277 + 278 + /// Fetch the "/.well-known/oauth-authorization-server" metadata for a 279 + /// resource. 280 + async fn authorization_meta<R: IntoAuthorizationServerUrl>( 281 + &self, 282 + resource: R, 283 + ) -> Result<AuthorizationMetadata, reqwest::Error> { 284 + let url = resource.into_authorization_server_url(); 285 + let meta = self.http.get(url).send().await?.json().await?; 286 + 287 + // @TODO Check meta.issuer corresponds to the fetched URL. 288 + 289 + Ok(meta) 290 + } 291 + 292 + fn client_assertion(&self, audience: impl AsRef<str>) -> Option<ClientAssertion> { 293 + if let Some(key_pair) = &self.assertion_key { 294 + let client_assertion = self.compute_client_assertion(audience, key_pair); 295 + return Some(ClientAssertion { 296 + client_assertion_type: ClientAssertionType::JwtBearer, 297 + client_assertion, 298 + }); 299 + } 300 + None 301 + } 302 + 303 + fn compute_client_assertion( 304 + &self, 305 + audience: impl AsRef<str>, 306 + key_pair: &EcdsaKeyPair, 307 + ) -> String { 308 + use crate::jwk; 309 + use crate::jwt; 310 + 311 + assert_eq!(key_pair.algorithm(), &ECDSA_P256_SHA256_FIXED_SIGNING); 312 + let jwk::JsonWebKey { key_id, .. } = key_pair 313 + .try_into() 314 + .expect("ECDSA-P256-SHA256 should be a supported key type"); 315 + 316 + jwt::encode_and_sign( 317 + serde_json::json!({ 318 + "typ": jwt::Type::Jwt, 319 + "alg": jwt::Algorithm::ES256, 320 + "kid": key_id 321 + }), 322 + serde_json::json!({ 323 + "iss": &self.metadata.client_id, 324 + "sub": &self.metadata.client_id, 325 + "aud": audience.as_ref(), 326 + "jti": jwt::generate_jti(), 327 + "iat": time::OffsetDateTime::now_utc().unix_timestamp(), 328 + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 60 329 + }), 330 + key_pair, 331 + ) 332 + .expect("Client assertion should be OK") 333 + } 334 + 335 + #[tracing::instrument(skip(self))] 336 + async fn store_dpop_nonce(&self, authorization_server: &str, nonce: &str) { 337 + tracing::warn!("unimplemented"); 338 + } 339 + 340 + #[tracing::instrument(skip(self))] 341 + async fn get_dpop_nonce(&self, authorization_server: &str) -> Option<String> { 342 + tracing::warn!("unimplemented"); 343 + None 344 + } 345 + } 346 + 347 + #[derive(Debug, serde::Serialize)] 348 + pub struct ClientAssertion { 349 + pub client_assertion_type: ClientAssertionType, 350 + pub client_assertion: String, 351 + } 352 + 353 + #[derive(Debug)] 354 + pub struct PushedAuthorizationClient<'client> { 355 + pub authorization_meta: AuthorizationMetadata, 356 + pub hint: Option<String>, 357 + 358 + client: &'client Client, 359 + pkce: Option<ProofKeyCodeExchange>, 360 + dpop_key_pair: EcdsaKeyPair, 361 + } 362 + 363 + impl PushedAuthorizationClient<'_> { 364 + /// Push the authorization request to the authorization server. 365 + /// 366 + /// # Errors 367 + /// 368 + /// Returns an error if the request cannot be constructed or if the 369 + /// authorization returns an error response. 370 + pub fn push_authorization<S: AsRef<str> + Send, Scope: Serialize + Send>( 371 + self, 372 + state: &S, 373 + redirect_uri: &Url, 374 + scope: &[Scope], 375 + ) -> impl Future<Output = Result<PushedAuthorization, Exn<PushedAuthorizationError>>> { 376 + let request = self.build_request(state.as_ref(), redirect_uri, scope); 377 + async { 378 + let mut nonce = self 379 + .client 380 + .get_dpop_nonce(&self.authorization_meta.issuer) 381 + .await; 382 + 383 + let PushedAuthorizationResponse { 384 + request_uri, 385 + expires_in, 386 + } = self 387 + .client 388 + .http 389 + .execute_with_dpop(request, &mut nonce, &self.dpop_key_pair) 390 + .await 391 + .or_raise(|| PushedAuthorizationError::new(""))?; 392 + 393 + if let Some(nonce) = nonce { 394 + self.client 395 + .store_dpop_nonce(&self.authorization_meta.issuer, &nonce) 396 + .await; 397 + } 398 + 399 + let expires = OffsetDateTime::now_utc() + Duration::from_secs(expires_in); 400 + 401 + Ok(PushedAuthorization { 402 + request_uri, 403 + expires, 404 + authorization_meta: self.authorization_meta, 405 + dpop_key_pair: self.dpop_key_pair, 406 + pkce: self.pkce, 407 + }) 408 + } 409 + } 410 + 411 + fn build_request<Scope: Serialize>( 412 + &self, 413 + state: &str, 414 + redirect_uri: &Url, 415 + scope: &[Scope], 416 + ) -> Request { 417 + #[derive(Serialize)] 418 + struct PushedAuthorizationRequest<'a, S: Serialize> { 419 + pub state: &'a str, 420 + pub client_id: &'a Url, 421 + pub response_type: ResponseType, 422 + pub redirect_uri: &'a Url, 423 + #[serde(with = "crate::serde::vec_as_spaced_string")] 424 + pub scope: &'a [S], 425 + pub login_hint: Option<&'a str>, 426 + #[serde(flatten)] 427 + pub client_assertion: Option<ClientAssertion>, 428 + #[serde(flatten)] 429 + pub code_challenge: Option<pkce::CodeChallenge>, 430 + } 431 + 432 + let audience = &self.authorization_meta.issuer; 433 + let request_body = PushedAuthorizationRequest { 434 + state, 435 + client_id: &self.client.metadata.client_id, 436 + response_type: ResponseType::Code, 437 + redirect_uri, 438 + scope, 439 + login_hint: self.hint.as_deref(), 440 + code_challenge: self.pkce.as_ref().map(Into::into), 441 + client_assertion: self.client.client_assertion(audience), 442 + }; 443 + 444 + self.client 445 + .http 446 + .post( 447 + self.authorization_meta 448 + .pushed_authorization_request_endpoint 449 + .clone(), 450 + ) 451 + .form(&request_body) 452 + .build() 453 + .expect("PushAuthorizationRequest should be serialized infallibly") 454 + } 455 + } 456 + 457 + #[derive(Debug)] 458 + pub struct PushedAuthorization { 459 + /// Request URI string. 460 + pub request_uri: String, 461 + 462 + /// Timestamp the request URI expires. 463 + pub expires: OffsetDateTime, 464 + 465 + /// Authorization server metadata we're authorizing with. 466 + pub authorization_meta: AuthorizationMetadata, 467 + 468 + /// DPoP key-pair for the authorization session. 469 + pub dpop_key_pair: EcdsaKeyPair, 470 + 471 + /// Proof Key for Code Exchange for the authorization session. 472 + pub pkce: Option<ProofKeyCodeExchange>, 473 + } 474 + 475 + impl PushedAuthorization { 476 + #[must_use] 477 + pub fn authorization_endpoint(&self, client_id: &Url) -> Url { 478 + let mut url = self.authorization_meta.authorization_endpoint.clone(); 479 + { 480 + let mut query = url.query_pairs_mut(); 481 + query.append_pair("request_uri", &self.request_uri); 482 + query.append_pair("client_id", client_id.as_str()); 483 + } 484 + url 485 + } 486 + } 487 + 488 + fn coalesce_to_url(s: &str) -> Option<Url> { 489 + if let Ok(url) = Url::parse(s) { 490 + return Some(url); 491 + } 492 + if let Ok(url) = format!("https://{s}").parse() { 493 + return Some(url); 494 + } 495 + if let Some(idx) = s.find("://") { 496 + let (_, s) = s.split_at(idx); 497 + if let Ok(url) = format!("https{s}").parse() { 498 + return Some(url); 499 + } 500 + } 501 + None 502 + } 503 + 504 + #[derive(Debug, thiserror::Error)] 505 + #[error("{0}")] 506 + pub struct Error(Cow<'static, str>); 507 + 508 + impl Error { 509 + fn new(message: impl Into<Cow<'static, str>>) -> Self { 510 + Self(message.into()) 511 + } 512 + } 513 + 514 + #[derive(Debug, thiserror::Error)] 515 + #[error("{0}")] 516 + pub struct PushedAuthorizationError(Cow<'static, str>); 517 + 518 + impl PushedAuthorizationError { 519 + fn new(message: impl Into<Cow<'static, str>>) -> Self { 520 + Self(message.into()) 521 + } 522 + }
+269
crates/gordian-auth/src/dpop.rs
··· 1 + use std::borrow::Cow; 2 + 3 + use aws_lc_rs::signature::EcdsaKeyPair; 4 + use exn::Exn; 5 + use exn::OptionExt as _; 6 + use exn::ResultExt as _; 7 + use reqwest::StatusCode; 8 + use reqwest::header::HeaderName; 9 + use reqwest::header::HeaderValue; 10 + use serde::Deserialize; 11 + use serde::Serialize; 12 + use serde::de::DeserializeOwned; 13 + use url::Url; 14 + 15 + use crate::HttpClient; 16 + use crate::Method; 17 + use crate::Request; 18 + use crate::jwk; 19 + use crate::jwt; 20 + use crate::types; 21 + 22 + /// Header used to Demonstrate Proof of Possession to the server. 23 + pub const DPOP: HeaderName = HeaderName::from_static("dpop"); 24 + 25 + /// Header used to convey the the servers nonce value to the client. 26 + pub const DPOP_NONCE: HeaderName = HeaderName::from_static("dpop-nonce"); 27 + 28 + /// DPoP JWT types 29 + #[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 30 + pub enum Type { 31 + #[default] 32 + #[serde(rename = "dpop+jwt")] 33 + DpopJwt, 34 + } 35 + 36 + /// DPoP JWT Header 37 + #[derive(Debug, Deserialize, Serialize)] 38 + pub struct Header { 39 + pub typ: Type, 40 + 41 + /// Signing-key algorithm. 42 + pub alg: jwt::Algorithm, 43 + 44 + pub jwk: jwk::JsonWebKey, 45 + } 46 + 47 + impl From<jwk::JsonWebKey> for Header { 48 + /// Create a DPoP header from a JWK. 49 + fn from(jwk: jwk::JsonWebKey) -> Self { 50 + Self { 51 + typ: Type::DpopJwt, 52 + alg: jwk.algorithm(), 53 + jwk, 54 + } 55 + } 56 + } 57 + 58 + /// DPoP Proof 59 + /// 60 + /// See: <https://datatracker.ietf.org/doc/html/rfc9449#name-dpop-proof-jwts> 61 + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 62 + pub struct Proof<'a> { 63 + /// Unique identifier for the DPoP proof JWT. 64 + /// 65 + /// Should contain at least 96 bits of randomness. 66 + #[serde(borrow)] 67 + pub jti: Cow<'a, str>, 68 + 69 + /// The HTTP method of the request to which the JWT is attached. 70 + #[serde(with = "method")] 71 + pub htm: Cow<'a, Method>, 72 + 73 + /// The HTTP target URI of the request to which the JWT is attached. 74 + pub htu: Cow<'a, Url>, 75 + 76 + /// Creation timestamp of the JWT as a UNIX epoch. 77 + pub iat: i64, 78 + 79 + /// base64url encoded hash of the access token if the DPoP proof is used in 80 + /// conjunction with the presentation of an access token. 81 + #[serde(skip_serializing_if = "Option::is_none")] 82 + pub ath: Option<Cow<'a, str>>, 83 + 84 + /// A recent nonce provided via the DPoP-Nonce HTTP header. 85 + /// 86 + /// Only present if the authentication server or resource server provided a 87 + /// DPoP-Nonce. 88 + #[serde(skip_serializing_if = "Option::is_none")] 89 + pub nonce: Option<Cow<'a, str>>, 90 + } 91 + 92 + impl<'a> Proof<'a> { 93 + pub fn from_request(request: &'a reqwest::Request, nonce: Option<&'a str>) -> Self { 94 + Self { 95 + jti: Cow::Owned(jwt::generate_jti()), 96 + htm: Cow::Borrowed(request.method()), 97 + htu: Cow::Borrowed(request.url()), 98 + iat: time::OffsetDateTime::now_utc().unix_timestamp(), 99 + ath: None, 100 + nonce: nonce.map(Cow::Borrowed), 101 + } 102 + } 103 + } 104 + 105 + impl Proof<'_> { 106 + /// Encode the proof and sign using `key_pair`. 107 + /// 108 + /// # Errors 109 + /// 110 + /// Returns an error if: 111 + /// 112 + /// - The proof cannot be serialized as JSON. This is only likely to occur 113 + /// if [`Proof::nonce`] is not a serializable header value. 114 + /// 115 + /// - The ECDSA signature fails. 116 + /// 117 + /// # Panics 118 + /// 119 + /// Panics if `key_pair` cannot be represented as a JWK. 120 + pub fn encode_and_sign(&self, key_pair: &EcdsaKeyPair) -> Result<String, jwt::EncodeError> { 121 + let jwk: jwk::JsonWebKey = key_pair.try_into().unwrap(); 122 + let header: Header = jwk.into(); 123 + jwt::encode_and_sign(header, self, key_pair) 124 + } 125 + } 126 + 127 + #[derive(Debug, thiserror::Error)] 128 + #[error("Unable to DPoP for request")] 129 + pub struct ProofError; 130 + 131 + /// Add proof of possession to a [`Request`]. 132 + /// 133 + /// # Errors 134 + /// 135 + /// Returns an error if the DPoP cannot be encoded and signed, or if the DPoP 136 + /// cannot be encoded as a [`HeaderValue`]. 137 + pub fn prove( 138 + mut request: Request, 139 + nonce: Option<&str>, 140 + key_pair: &EcdsaKeyPair, 141 + ) -> Result<Request, Exn<ProofError>> { 142 + let claims = Proof::from_request(&request, nonce); 143 + let proof = claims.encode_and_sign(key_pair).or_raise(|| ProofError)?; 144 + 145 + request 146 + .headers_mut() 147 + .insert(DPOP, HeaderValue::from_str(&proof).or_raise(|| ProofError)?); 148 + 149 + Ok(request) 150 + } 151 + 152 + #[tracing::instrument(skip(http))] 153 + async fn dpop_request_inner<T: DeserializeOwned>( 154 + http: &HttpClient, 155 + request: Request, 156 + nonce: &mut Option<String>, 157 + key_pair: &EcdsaKeyPair, 158 + ) -> Result<Option<T>, Exn<ProofError>> { 159 + let err = || ProofError; 160 + 161 + let request = prove(request, nonce.as_deref(), key_pair)?; 162 + let mut response = http.execute(request).await.or_raise(err)?; 163 + 164 + tracing::info!(?response); 165 + 166 + // Take the DPoP-Nonce header and store it. 167 + if let Some(new_nonce) = response.headers_mut().get(DPOP_NONCE) { 168 + let new_nonce = new_nonce.to_str().or_raise(err)?; 169 + *nonce = Some(new_nonce.to_string()); 170 + } 171 + 172 + let status = response.status(); 173 + if status.is_success() { 174 + let response = response.json().await.or_raise(err)?; 175 + return Ok(Some(response)); 176 + } 177 + 178 + if status.is_client_error() { 179 + let error_response: types::AuthorizationError = response.json().await.or_raise(err)?; 180 + if status == StatusCode::BAD_REQUEST && error_response.error == "use_dpop_nonce" { 181 + return Ok(None); 182 + } 183 + 184 + tracing::error!(?status, ?error_response, "error from authorization server"); 185 + exn::bail!(err()); 186 + } 187 + 188 + exn::bail!(ProofError); 189 + } 190 + 191 + async fn execute<T: DeserializeOwned>( 192 + http: &HttpClient, 193 + request: Request, 194 + nonce: &mut Option<String>, 195 + key_pair: &EcdsaKeyPair, 196 + ) -> Result<T, Exn<ProofError>> { 197 + let first_request = request.try_clone().ok_or_raise(|| ProofError)?; 198 + let second_request = request; 199 + 200 + let result = dpop_request_inner(http, first_request, nonce, key_pair).await?; 201 + if let Some(response) = result { 202 + return Ok(response); 203 + } 204 + 205 + let result = dpop_request_inner(http, second_request, nonce, key_pair).await?; 206 + if let Some(response) = result { 207 + return Ok(response); 208 + } 209 + 210 + exn::bail!(ProofError); 211 + } 212 + 213 + pub trait HttpClientExt { 214 + /// Execute the request with DPoP. 215 + /// 216 + /// Requires `request` to be cloneable. See [`Request::try_clone()`] 217 + fn execute_with_dpop<T: DeserializeOwned>( 218 + &self, 219 + request: Request, 220 + nonce: &mut Option<String>, 221 + key_pair: &EcdsaKeyPair, 222 + ) -> impl Future<Output = Result<T, Exn<ProofError>>>; 223 + } 224 + 225 + impl HttpClientExt for HttpClient { 226 + fn execute_with_dpop<T: DeserializeOwned>( 227 + &self, 228 + request: Request, 229 + nonce: &mut Option<String>, 230 + key_pair: &EcdsaKeyPair, 231 + ) -> impl Future<Output = Result<T, Exn<ProofError>>> { 232 + execute(self, request, nonce, key_pair) 233 + } 234 + } 235 + 236 + mod method { 237 + //! Use this module in combination with serde's [`#[with]`][with] attribute. 238 + //! 239 + //! [with]: https://serde.rs/field-attrs.html#with 240 + 241 + use std::borrow::Cow; 242 + 243 + use serde::Deserialize; 244 + use serde::Deserializer; 245 + use serde::Serializer; 246 + use serde::de::Error; 247 + 248 + use crate::Method; 249 + 250 + #[allow(clippy::missing_errors_doc)] 251 + pub fn deserialize<'de: 'a, 'a, D>(deserializer: D) -> Result<Cow<'a, Method>, D::Error> 252 + where 253 + D: Deserializer<'de>, 254 + { 255 + let method = <&str as Deserialize>::deserialize(deserializer)? 256 + .parse() 257 + .map_err(Error::custom)?; 258 + 259 + Ok(Cow::Owned(method)) 260 + } 261 + 262 + #[allow(clippy::missing_errors_doc)] 263 + pub fn serialize<S>(method: &Method, serializer: S) -> Result<S::Ok, S::Error> 264 + where 265 + S: Serializer, 266 + { 267 + serializer.serialize_str(method.as_str()) 268 + } 269 + }
+35
crates/gordian-auth/src/error.rs
··· 1 + use std::borrow::Cow; 2 + 3 + /// Replicates [`aws_lc_rc::errors::Unspecified`]. 4 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 5 + pub struct Unspecified; 6 + 7 + impl core::fmt::Display for Unspecified { 8 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 9 + f.write_str("Unspecified") 10 + } 11 + } 12 + 13 + impl core::error::Error for Unspecified {} 14 + 15 + impl From<aws_lc_rs::error::Unspecified> for Unspecified { 16 + fn from(_: aws_lc_rs::error::Unspecified) -> Self { 17 + Self 18 + } 19 + } 20 + 21 + #[derive(Debug, thiserror::Error)] 22 + #[error("{0}")] 23 + pub struct ParseError(Cow<'static, str>); 24 + 25 + impl ParseError { 26 + pub(crate) fn new(message: impl Into<Cow<'static, str>>) -> Self { 27 + Self(message.into()) 28 + } 29 + } 30 + 31 + impl From<data_encoding::DecodeError> for ParseError { 32 + fn from(value: data_encoding::DecodeError) -> Self { 33 + Self(value.to_string().into()) 34 + } 35 + }
+256
crates/gordian-auth/src/jwk.rs
··· 1 + use aws_lc_rs::digest::SHA256; 2 + use aws_lc_rs::digest::digest; 3 + use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED; 4 + use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED_SIGNING; 5 + use aws_lc_rs::signature::ECDSA_P384_SHA384_FIXED; 6 + use aws_lc_rs::signature::ECDSA_P384_SHA384_FIXED_SIGNING; 7 + use aws_lc_rs::signature::ECDSA_P521_SHA512_FIXED; 8 + use aws_lc_rs::signature::ECDSA_P521_SHA512_FIXED_SIGNING; 9 + use aws_lc_rs::signature::EcdsaKeyPair; 10 + use aws_lc_rs::signature::EcdsaSigningAlgorithm; 11 + use aws_lc_rs::signature::KeyPair; 12 + use aws_lc_rs::signature::ParsedPublicKey; 13 + use data_encoding::BASE64URL_NOPAD; 14 + use exn::Exn; 15 + use exn::ResultExt; 16 + use serde::Deserialize; 17 + use serde::Serialize; 18 + 19 + use crate::IntoVerificationKey; 20 + use crate::KeyRejected; 21 + use crate::jwt; 22 + 23 + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 24 + pub enum JsonWebKeyUsage { 25 + #[serde(rename = "sig")] 26 + Signature, 27 + #[serde(rename = "enc")] 28 + Encryption, 29 + } 30 + 31 + #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 32 + pub enum EllipticCurve { 33 + #[default] 34 + #[serde(rename = "P-256")] 35 + P256, 36 + #[serde(rename = "P-384")] 37 + P384, 38 + #[serde(rename = "P-521")] 39 + P521, 40 + } 41 + 42 + #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 43 + #[serde(tag = "kty")] 44 + pub enum JsonWebKeyData { 45 + EC { 46 + #[serde(default, rename = "crv")] 47 + curve: EllipticCurve, 48 + #[serde(with = "crate::serde::base64")] 49 + x: Vec<u8>, 50 + #[serde(with = "crate::serde::base64")] 51 + y: Vec<u8>, 52 + }, 53 + // We only support elliptic curve web keys, but here's what the rest 54 + // would look like: 55 + // 56 + // RSA { 57 + // #[serde(with = "crate::serde::base64")] 58 + // n: Vec<u8>, 59 + // #[serde(with = "crate::serde::base64")] 60 + // e: Vec<u8>, 61 + // }, 62 + // #[serde(rename = "oct")] 63 + // Oct { 64 + // #[serde(with = "crate::serde::base64")] 65 + // k: Vec<u8>, 66 + // }, 67 + } 68 + 69 + impl JsonWebKeyData { 70 + #[must_use] 71 + pub const fn algorithm(&self) -> jwt::Algorithm { 72 + use jwt::Algorithm; 73 + 74 + match self { 75 + Self::EC { 76 + curve: EllipticCurve::P256, 77 + x: _, 78 + y: _, 79 + } => Algorithm::ES256, 80 + Self::EC { 81 + curve: EllipticCurve::P384, 82 + x: _, 83 + y: _, 84 + } => Algorithm::ES384, 85 + Self::EC { 86 + curve: EllipticCurve::P521, 87 + x: _, 88 + y: _, 89 + } => Algorithm::ES512, 90 + } 91 + } 92 + } 93 + 94 + #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 95 + pub struct JsonWebKey { 96 + #[serde(rename = "kid")] 97 + pub key_id: String, 98 + 99 + #[serde(rename = "use", skip_serializing_if = "Option::is_none")] 100 + pub public_key_use: Option<JsonWebKeyUsage>, 101 + 102 + #[serde(flatten)] 103 + pub key: JsonWebKeyData, 104 + } 105 + 106 + impl JsonWebKey { 107 + #[must_use] 108 + pub const fn algorithm(&self) -> jwt::Algorithm { 109 + self.key.algorithm() 110 + } 111 + } 112 + 113 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 114 + pub struct JsonWebKeySet { 115 + pub keys: Vec<JsonWebKey>, 116 + } 117 + 118 + impl TryFrom<&EcdsaKeyPair> for JsonWebKeyData { 119 + type Error = KeyRejected; 120 + 121 + fn try_from(value: &EcdsaKeyPair) -> Result<Self, Self::Error> { 122 + // See: <https://docs.rs/jsonwebtoken-ic/latest/src/jsonwebtoken/jwk.rs.html#460> 123 + 124 + const MAP: &[(&EcdsaSigningAlgorithm, EllipticCurve, usize)] = &[ 125 + (&ECDSA_P256_SHA256_FIXED_SIGNING, EllipticCurve::P256, 32), 126 + (&ECDSA_P384_SHA384_FIXED_SIGNING, EllipticCurve::P384, 48), 127 + (&ECDSA_P521_SHA512_FIXED_SIGNING, EllipticCurve::P521, 64), 128 + ]; 129 + 130 + let (_, curve, split) = MAP 131 + .iter() 132 + .find(|(algorithm, _, _)| algorithm == &value.algorithm()) 133 + .ok_or_else(|| KeyRejected::new("Unsupported ECDSA key"))?; 134 + 135 + let public_bytes = value.public_key().as_ref(); 136 + assert_eq!(public_bytes[0], 4); 137 + 138 + let (x, y) = public_bytes[1..].split_at(*split); 139 + Ok(Self::EC { 140 + curve: *curve, 141 + x: x.to_vec(), 142 + y: y.to_vec(), 143 + }) 144 + } 145 + } 146 + 147 + impl TryFrom<&EcdsaKeyPair> for JsonWebKey { 148 + type Error = KeyRejected; 149 + 150 + fn try_from(value: &EcdsaKeyPair) -> Result<Self, Self::Error> { 151 + let pub_bytes = value.public_key().as_ref(); 152 + let digest = digest(&SHA256, pub_bytes); 153 + let key_id = BASE64URL_NOPAD.encode(&digest.as_ref()[..8]); 154 + 155 + Ok(Self { 156 + key_id, 157 + public_key_use: None, 158 + key: value.try_into()?, 159 + }) 160 + } 161 + } 162 + 163 + impl IntoVerificationKey for JsonWebKey { 164 + type Output = ParsedPublicKey; 165 + 166 + fn to_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 167 + match &self.key { 168 + JsonWebKeyData::EC { curve, x, y } => { 169 + let algorithm = match curve { 170 + EllipticCurve::P256 => &ECDSA_P256_SHA256_FIXED, 171 + EllipticCurve::P384 => &ECDSA_P384_SHA384_FIXED, 172 + EllipticCurve::P521 => &ECDSA_P521_SHA512_FIXED, 173 + }; 174 + 175 + let mut key_material = Vec::with_capacity(1 + x.len() + y.len()); 176 + key_material.push(4); 177 + key_material.extend_from_slice(x); 178 + key_material.extend_from_slice(y); 179 + 180 + let key = ParsedPublicKey::new(algorithm, key_material) 181 + .or_raise(|| KeyRejected::new("Failed to parse key material"))?; 182 + 183 + Ok(key) 184 + } 185 + } 186 + } 187 + } 188 + 189 + #[test] 190 + fn can_parse_ec_jwk() { 191 + let jwk: JsonWebKey = serde_json::from_str( 192 + r#" 193 + { 194 + "kty": "EC", 195 + "crv": "P-256", 196 + "x": "4VyTu0GCzH0jfMstxDz4QxSujD_kS3KeO7yn_HcTeI8", 197 + "y": "q0JNRUAzhp3VyIMC92V5yOYMfoL6F7VkG25CGGyp9NA", 198 + "kid": "1746979806" 199 + } 200 + "#, 201 + ) 202 + .unwrap(); 203 + 204 + assert_eq!(jwk.key_id, "1746979806"); 205 + } 206 + 207 + #[test] 208 + fn can_generate_jwk_from_keypair() { 209 + let key_pair = EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING).unwrap(); 210 + let jwk_data: JsonWebKeyData = (&key_pair).try_into().unwrap(); 211 + 212 + assert!(matches!( 213 + jwk_data, 214 + JsonWebKeyData::EC { 215 + curve: EllipticCurve::P256, 216 + x: _, 217 + y: _ 218 + } 219 + )); 220 + } 221 + 222 + #[test] 223 + fn can_roundtrip_jwk_from_keypair() { 224 + use aws_lc_rs::rand::SystemRandom; 225 + 226 + let key_pair = EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING).unwrap(); 227 + let jwk_data: JsonWebKeyData = (&key_pair).try_into().unwrap(); 228 + 229 + let message = "bluesky engineers are over employed"; 230 + let sig = key_pair 231 + .sign(&SystemRandom::new(), message.as_bytes()) 232 + .unwrap(); 233 + 234 + assert!(matches!( 235 + jwk_data, 236 + JsonWebKeyData::EC { 237 + curve: EllipticCurve::P256, 238 + x: _, 239 + y: _ 240 + } 241 + )); 242 + 243 + let ser = serde_json::to_string(&JsonWebKey { 244 + key_id: "some_id".to_string(), 245 + public_key_use: None, 246 + key: jwk_data, 247 + }) 248 + .unwrap(); 249 + 250 + eprintln!("{ser}"); 251 + 252 + let jwk: JsonWebKey = serde_json::from_str(&ser).unwrap(); 253 + let key = jwk.to_verification_key().unwrap(); 254 + 255 + key.verify_sig(message.as_bytes(), sig.as_ref()).unwrap(); 256 + }
+101 -14
crates/gordian-auth/src/jwt.rs
··· 1 1 use core::fmt; 2 2 3 + use aws_lc_rs::rand::SystemRandom; 4 + use aws_lc_rs::signature::EcdsaKeyPair; 3 5 use data_encoding::BASE64URL_NOPAD as Encoding; 4 - use gordian_types::{Nsid, DidBuf}; 5 - use serde::{Deserialize, Serialize, de::DeserializeOwned}; 6 + use gordian_types::DidBuf; 7 + use gordian_types::Nsid; 8 + use serde::Deserialize; 9 + use serde::Serialize; 10 + use serde::de::DeserializeOwned; 6 11 7 - use crate::verification_key::{Unspecified, VerificationKey}; 12 + use crate::error::Unspecified; 13 + use crate::jwk::JsonWebKey; 14 + use crate::verification_key::VerificationKey; 8 15 9 - #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 16 + #[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 10 17 pub enum Type { 11 - JWT, 18 + #[default] 19 + #[serde(rename = "JWT")] 20 + Jwt, 21 + #[serde(rename = "dpop+jwt")] 22 + DpopJwt, 12 23 } 13 24 14 25 /// Signature algorithm. 15 26 /// 16 27 /// See: <https://atproto.com/specs/xrpc#inter-service-authentication-jwt> 17 - #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] 28 + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 18 29 #[non_exhaustive] 19 30 pub enum Algorithm { 20 31 ES256K, 32 + #[default] 21 33 ES256, 22 34 ES384, 23 35 ES512, ··· 49 37 Ed25519, 50 38 } 51 39 52 - #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 40 + #[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 53 41 pub struct Header { 54 42 pub typ: Type, 55 43 ··· 58 46 59 47 #[serde(skip_serializing_if = "Option::is_none")] 60 48 pub crv: Option<Curve>, 49 + 50 + pub jwk: Option<JsonWebKey>, 61 51 } 62 52 63 53 impl Header { 64 54 #[must_use] 65 55 pub const fn new(alg: Algorithm, crv: Option<Curve>) -> Self { 66 56 Self { 67 - typ: Type::JWT, 57 + typ: Type::Jwt, 68 58 alg, 69 59 crv, 60 + jwk: None, 70 61 } 71 62 } 72 63 } ··· 108 93 } 109 94 110 95 impl Token<Claims> { 111 - #[inline] 96 + /// Deserialize [`Self`] from `token` after verifying the `token`'s 97 + /// signature with `public_key`. 98 + /// 99 + /// # Errors 100 + /// 101 + /// Returns an error if the token cannot be deserialized or if the signature 102 + /// verification fails. 112 103 pub fn decode( 113 104 token: impl AsRef<[u8]>, 114 105 public_key: &dyn VerificationKey, ··· 122 101 decode(token, public_key) 123 102 } 124 103 125 - #[inline] 104 + /// Deserialize [`Self`] from `token` without verifying the `token`'s 105 + /// signature. 106 + /// 107 + /// # Errors 108 + /// 109 + /// Returns an error if the token is in the wrong format, cannot be decoded, 110 + /// or if deserialization fails. 126 111 pub fn decode_unverified(token: impl AsRef<[u8]>) -> Result<Self, Error> { 127 112 decode_unverified(token) 128 113 } ··· 175 148 176 149 /// Get the deserialized [`Token`] without verifying the `token`'s signature. 177 150 /// 151 + /// # Errors 152 + /// 153 + /// Returns an error if the token is in the wrong format, cannot be decoded, or 154 + /// if deserialization fails. 178 155 pub fn decode_unverified<C: DeserializeOwned + Serialize>( 179 156 token: impl AsRef<[u8]>, 180 157 ) -> Result<Token<C>, Error> { ··· 190 159 Ok(Token { header, claims }) 191 160 } 192 161 193 - /// Verify the JWT signature using `verification_key` and return the deserialized [`Token`]. 162 + /// Verify the JWT signature using `verification_key` and return the 163 + /// deserialized [`Token`]. 194 164 /// 165 + /// # Errors 166 + /// 167 + /// Returns an error if the token cannot be deserialized or if the signature 168 + /// verification fails. 195 169 pub fn decode<C: DeserializeOwned + Serialize>( 196 170 token: impl AsRef<[u8]>, 197 171 verification_key: &dyn VerificationKey, ··· 224 188 Ok(Token { header, claims }) 225 189 } 226 190 191 + #[derive(Debug, thiserror::Error)] 192 + pub enum EncodeError { 193 + #[error("Unable to serialize claims: {0}")] 194 + SerializationFailed(#[from] serde_json::Error), 195 + #[error("Unable to sign JWT")] 196 + Signing(#[from] aws_lc_rs::error::Unspecified), 197 + } 198 + 199 + /// Encodes a JWT from `header` and `claims`, then signs the token with 200 + /// `key_pair`. 201 + /// 202 + /// # Errors 203 + /// 204 + /// Returns an error if either `header` or `claims` cannot be serialized as 205 + /// JSON, or if the signing operation fails. 206 + #[allow(clippy::missing_panics_doc)] 207 + pub fn encode_and_sign<H: Serialize, C: Serialize>( 208 + header: H, 209 + claims: C, 210 + key_pair: &EcdsaKeyPair, 211 + ) -> Result<String, EncodeError> { 212 + use data_encoding::BASE64URL_NOPAD as Encoding; 213 + 214 + let mut token = String::new(); 215 + token.push_str( 216 + &Encoding.encode( 217 + serde_json::to_string(&header) 218 + .expect("JWT header should be serializable as JSON") 219 + .as_bytes(), 220 + ), 221 + ); 222 + token.push('.'); 223 + token.push_str(&Encoding.encode(serde_json::to_string(&claims)?.as_bytes())); 224 + 225 + let signature = key_pair.sign(&SystemRandom::new(), token.as_bytes())?; 226 + 227 + token.push('.'); 228 + token.push_str(&Encoding.encode(signature.as_ref())); 229 + Ok(token) 230 + } 231 + 232 + /// Generate random bytes encoded to base64. 233 + #[must_use] 234 + pub fn generate_jti() -> String { 235 + let jti: [u8; 16] = rand::random(); 236 + data_encoding::BASE64URL_NOPAD.encode(&jti) 237 + } 238 + 227 239 #[cfg(test)] 228 240 mod tests { 229 241 use gordian_identity::VerificationMethod; 230 242 use gordian_types::Did; 231 243 232 - use super::{Algorithm, Error, Token, Type}; 244 + use super::Algorithm; 245 + use super::Error; 246 + use super::Token; 247 + use super::Type; 233 248 234 249 #[test] 235 250 fn can_split_token() { ··· 302 215 fn can_decode_token() { 303 216 let Token { header, claims } = Token::decode_unverified(TOKEN).unwrap(); 304 217 305 - assert_eq!(header.typ, Type::JWT); 218 + assert_eq!(header.typ, Type::Jwt); 306 219 assert_eq!(header.alg, Algorithm::ES256K); 307 220 assert_eq!( 308 221 claims.aud.as_ref(), ··· 334 247 335 248 let Token { header, claims } = Token::decode(TOKEN, &vm).unwrap(); 336 249 337 - assert_eq!(header.typ, Type::JWT); 250 + assert_eq!(header.typ, Type::Jwt); 338 251 assert_eq!(header.alg, Algorithm::ES256K); 339 252 assert_eq!( 340 253 claims.aud.as_ref(),
+18 -7
crates/gordian-auth/src/lib.rs
··· 1 + pub mod client; 2 + pub mod dpop; 3 + pub mod error; 4 + pub mod jwk; 1 5 pub mod jwt; 6 + pub mod pkce; 2 7 pub mod resources; 8 + pub mod supported; 9 + pub mod types; 10 + 11 + pub(crate) mod serde; 12 + 3 13 mod verification_key; 14 + pub use verification_key::IntoVerificationKey; 15 + pub use verification_key::KeyRejected; 16 + pub use verification_key::MultibaseKey; 17 + pub use verification_key::OpenSshKey; 18 + pub use verification_key::VerificationKey; 4 19 5 - pub(crate) mod support_set; 6 - pub(crate) mod types; 7 - pub(crate) mod validated_url; 8 - 9 - pub use verification_key::{ 10 - IntoVerificationKey, KeyRejected, OpenSshKey, Unspecified, VerificationKey, 11 - }; 20 + pub type HttpClient = reqwest::Client; 21 + pub type Request = reqwest::Request; 22 + pub type Method = reqwest::Method;
+254
crates/gordian-auth/src/pkce.rs
··· 1 + use core::fmt; 2 + 3 + use aws_lc_rs::constant_time; 4 + use aws_lc_rs::digest::SHA256; 5 + use aws_lc_rs::digest::digest; 6 + use aws_lc_rs::rand::SecureRandom; 7 + use serde::Deserialize; 8 + use serde::Serialize; 9 + 10 + use crate::error::ParseError; 11 + use crate::error::Unspecified; 12 + use crate::serde::base64::ENCODING; 13 + 14 + /// Default length in bytes for a randomly generated [`CodeVerifier`] 15 + pub const DEFAULT_LEN: usize = 64; 16 + 17 + pub const MIN_VERIFIER_LEN: usize = 32; 18 + 19 + /// Code challenge method for use with Proof Key for Code Exchange. 20 + #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 21 + pub enum CodeChallengeMethod { 22 + #[serde(rename = "plain")] 23 + Plain, 24 + #[default] 25 + S256, 26 + } 27 + 28 + #[derive(Debug, thiserror::Error)] 29 + #[error("invalid_grant")] 30 + pub struct InvalidGrant; 31 + 32 + /// Verifier for Proof Key for Code Exchange. 33 + #[derive(Clone)] 34 + #[repr(transparent)] 35 + pub struct CodeVerifier(Vec<u8>); 36 + 37 + impl<'de> serde::Deserialize<'de> for CodeVerifier { 38 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 39 + where 40 + D: serde::Deserializer<'de>, 41 + { 42 + let encoded = <&str as Deserialize>::deserialize(deserializer)?; 43 + let bytes = ENCODING 44 + .decode(encoded.as_bytes()) 45 + .map_err(serde::de::Error::custom)?; 46 + let validated = validate_length(bytes).map_err(serde::de::Error::custom)?; 47 + Ok(Self(validated)) 48 + } 49 + } 50 + 51 + impl serde::Serialize for CodeVerifier { 52 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 53 + where 54 + S: serde::Serializer, 55 + { 56 + let encoded = self.encode(); 57 + serializer.serialize_str(&encoded) 58 + } 59 + } 60 + 61 + #[derive(Debug, thiserror::Error)] 62 + #[error("Code verifier is shorted than {MIN_VERIFIER_LEN} bytes")] 63 + pub struct InsufficientEntropy; 64 + 65 + fn validate_length<B>(bytes: B) -> Result<B, InsufficientEntropy> 66 + where 67 + B: AsRef<[u8]>, 68 + { 69 + if bytes.as_ref().len() >= MIN_VERIFIER_LEN { 70 + Ok(bytes) 71 + } else { 72 + Err(InsufficientEntropy) 73 + } 74 + } 75 + 76 + impl CodeVerifier { 77 + /// Create a new `CodeVerifier` of `len` randomly generated bytes. 78 + /// 79 + /// # Errors 80 + /// 81 + /// Returns an error if [`SecureRandom::fill()`] fails. 82 + pub fn new(rng: &dyn SecureRandom, len: usize) -> Result<Self, Unspecified> { 83 + let mut bytes = vec![0u8; len]; 84 + rng.fill(bytes.as_mut_slice())?; 85 + Ok(Self(bytes)) 86 + } 87 + 88 + /// Compute the code challenge using `method`. 89 + #[must_use] 90 + pub fn code_challenge(&self, method: CodeChallengeMethod) -> String { 91 + let encoded = self.encode(); 92 + match method { 93 + CodeChallengeMethod::Plain => ENCODING.encode(encoded.as_bytes()), 94 + CodeChallengeMethod::S256 => { 95 + ENCODING.encode(digest(&SHA256, encoded.as_bytes()).as_ref()) 96 + } 97 + } 98 + } 99 + 100 + /// Verify `challenge` was generated by this verifier use `method`. 101 + /// 102 + /// # Errors 103 + /// 104 + /// Returns an [`InvalidGrant`] error if the challenge could not have been 105 + /// generated by this verifier. 106 + pub fn verify<C>(&self, method: CodeChallengeMethod, challenge: C) -> Result<(), InvalidGrant> 107 + where 108 + C: AsRef<[u8]>, 109 + { 110 + constant_time::verify_slices_are_equal( 111 + self.code_challenge(method).as_bytes(), 112 + challenge.as_ref(), 113 + ) 114 + .map_err(|_| InvalidGrant) 115 + } 116 + 117 + fn encode(&self) -> String { 118 + ENCODING.encode(&self.0) 119 + } 120 + } 121 + 122 + impl fmt::Debug for CodeVerifier { 123 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 124 + f.debug_struct("CodeVerifier") 125 + .field("len", &self.0.len()) 126 + .finish_non_exhaustive() 127 + } 128 + } 129 + 130 + impl std::str::FromStr for CodeVerifier { 131 + type Err = ParseError; 132 + 133 + fn from_str(s: &str) -> Result<Self, Self::Err> { 134 + let bytes = ENCODING.decode(s.as_bytes())?; 135 + let bytes = validate_length(bytes).map_err(|error| ParseError::new(error.to_string()))?; 136 + Ok(Self(bytes)) 137 + } 138 + } 139 + 140 + impl Default for CodeVerifier { 141 + /// Create a randomly generated code verifier of the default length. 142 + /// 143 + /// # Panics 144 + /// 145 + /// Panics if the random number generation fails. 146 + fn default() -> Self { 147 + let mut bytes = vec![0u8; DEFAULT_LEN]; 148 + aws_lc_rs::rand::fill(bytes.as_mut_slice()).expect("failed to fill random bytes"); 149 + Self(bytes) 150 + } 151 + } 152 + 153 + /// Proof Key for Code Exchange 154 + /// 155 + /// See: [RFC7636](https://www.rfc-editor.org/rfc/rfc7636) 156 + #[derive(Debug)] 157 + pub struct ProofKeyCodeExchange { 158 + /// Method used to generate the code challenge. 159 + pub code_challenge_method: CodeChallengeMethod, 160 + 161 + /// Code verifier secret. 162 + pub code_verifier: CodeVerifier, 163 + } 164 + 165 + impl ProofKeyCodeExchange { 166 + /// Compute the code challenge. 167 + #[must_use] 168 + pub fn code_challenge(&self) -> String { 169 + self.code_verifier 170 + .code_challenge(self.code_challenge_method) 171 + } 172 + 173 + /// Verify `challenge` was generated by this verifier. 174 + /// 175 + /// # Errors 176 + /// 177 + /// Returns an [`InvalidGrant`] error if the challenge could not have been 178 + /// generated by this verifier. 179 + pub fn verify<C>(&self, challenge: C) -> Result<(), InvalidGrant> 180 + where 181 + C: AsRef<[u8]>, 182 + { 183 + self.code_verifier 184 + .verify(self.code_challenge_method, challenge) 185 + } 186 + } 187 + 188 + impl Default for ProofKeyCodeExchange { 189 + /// Create a Proof Key for Code Exchange with randomly generated code 190 + /// verifier of default length and the default code challenge method 191 + /// (currently 'S256'). 192 + /// 193 + /// # Panics 194 + /// 195 + /// Panics if the random number generation fails. 196 + fn default() -> Self { 197 + Self { 198 + code_challenge_method: CodeChallengeMethod::default(), 199 + code_verifier: CodeVerifier::default(), 200 + } 201 + } 202 + } 203 + 204 + #[derive(Debug, Serialize)] 205 + pub struct CodeChallenge { 206 + pub code_challenge_method: CodeChallengeMethod, 207 + pub code_challenge: String, 208 + } 209 + 210 + impl From<&ProofKeyCodeExchange> for CodeChallenge { 211 + fn from(value: &ProofKeyCodeExchange) -> Self { 212 + Self { 213 + code_challenge_method: value.code_challenge_method, 214 + code_challenge: value.code_challenge(), 215 + } 216 + } 217 + } 218 + 219 + #[test] 220 + fn can_generate_and_verify() { 221 + let verifier = CodeVerifier::default(); 222 + let challenge = verifier.code_challenge(CodeChallengeMethod::S256); 223 + verifier 224 + .verify(CodeChallengeMethod::S256, challenge) 225 + .expect("challenge generated by verifier should be valid"); 226 + } 227 + 228 + #[test] 229 + fn can_serialize_and_deserialize_verifier() { 230 + #[derive(Default, Deserialize, Serialize)] 231 + struct Transport { 232 + code_verifier: CodeVerifier, 233 + } 234 + 235 + let (serialized, challenge) = { 236 + let original = Transport::default(); 237 + let challenge = original 238 + .code_verifier 239 + .code_challenge(CodeChallengeMethod::S256); 240 + 241 + ( 242 + serde_json::to_string(&original).expect("code verifier should be serializabled"), 243 + challenge, 244 + ) 245 + }; 246 + 247 + let deserialized: Transport = 248 + serde_json::from_str(&serialized).expect("code verifier should be deserializable"); 249 + 250 + deserialized 251 + .code_verifier 252 + .verify(CodeChallengeMethod::S256, challenge) 253 + .expect("challenge generated by verifier should be valid"); 254 + }
+197 -55
crates/gordian-auth/src/resources.rs
··· 1 - use serde::{Deserialize, Serialize}; 1 + use std::borrow::Cow; 2 + use std::collections::HashSet; 2 3 3 - use crate::{ 4 - support_set::SupportSet, types::CodeChallengeMethod, types::GrantType, types::Scope, 5 - types::SigningAlgorithm, validated_url::ValidatedUrl, 6 - }; 4 + use serde::Deserialize; 5 + use serde::Serialize; 6 + use url::Url; 7 + 8 + use crate::jwk::JsonWebKey; 9 + use crate::pkce; 10 + use crate::supported::Supported; 11 + use crate::types::ApplicationType; 12 + use crate::types::GrantType; 13 + use crate::types::ResponseType; 14 + use crate::types::SigningAlgorithm; 15 + use crate::types::TokenEndpointAuthMethod; 16 + 17 + pub type OpaqueString = Box<str>; 7 18 8 19 #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 9 20 #[serde(rename_all = "snake_case")] ··· 28 17 /// 29 18 /// See: <https://atproto.com/specs/oauth#server-metadata> 30 19 /// See: <https://datatracker.ietf.org/doc/rfc9728/> 31 - /// 32 20 #[derive(Debug, Deserialize, Serialize)] 33 21 #[serde(rename_all = "snake_case")] 34 - pub struct OauthProtectedResource { 22 + pub struct ProtectedResource { 35 23 /// Protected resource's identifier. 36 - pub resource: ValidatedUrl, 24 + pub resource: OpaqueString, 37 25 38 26 #[serde(default)] 39 - pub authorization_servers: Vec<ValidatedUrl>, 27 + pub authorization_servers: Vec<Url>, 40 28 41 29 #[serde(default)] 42 - pub scopes_supported: SupportSet<Scope>, 30 + pub scopes_supported: Vec<String>, 43 31 44 32 #[serde(default)] 45 - pub bearer_methods_supported: SupportSet<BearerMethod>, 33 + pub bearer_methods_supported: Supported<BearerMethod>, 46 34 47 35 #[serde(default)] 48 - pub resource_documentation: Option<ValidatedUrl>, 36 + pub resource_documentation: Option<Url>, 37 + } 38 + 39 + pub trait IntoProtectedResourceUrl { 40 + /// Convert `self` into a URL for the protected resource metadata document 41 + /// located at "/.well-known/oauth-protected-resource". 42 + fn into_protected_resource_url(self) -> Url; 43 + } 44 + 45 + impl IntoProtectedResourceUrl for &gordian_identity::Service { 46 + fn into_protected_resource_url(self) -> Url { 47 + let mut url = self.service_endpoint.clone(); 48 + url.set_path("/.well-known/oauth-protected-resource"); 49 + url 50 + } 51 + } 52 + 53 + impl IntoProtectedResourceUrl for &Url { 54 + fn into_protected_resource_url(self) -> Url { 55 + let mut url = self.clone(); 56 + url.set_path("/.well-known/oauth-protected-resource"); 57 + url 58 + } 59 + } 60 + 61 + impl IntoProtectedResourceUrl for Url { 62 + // Optimized impl for owned [`Url`]. 63 + fn into_protected_resource_url(mut self) -> Url { 64 + self.set_path("/.well-known/oauth-protected-resource"); 65 + self 66 + } 49 67 } 50 68 51 69 /// Authorization Server Metadata. 52 70 /// 53 - /// Published by an Authorization Server or PDS at "/.well-known/oauth-authorization-server". 71 + /// Published by an Authorization Server or PDS at 72 + /// "/.well-known/oauth-authorization-server". 54 73 /// 55 74 /// See: <https://atproto.com/specs/oauth#server-metadata> 56 75 /// See: <https://datatracker.ietf.org/doc/html/rfc8414> 57 - /// 58 76 #[derive(Debug, Deserialize, Serialize)] 59 77 #[serde(rename_all = "snake_case")] 60 - pub struct OauthAuthorizationServer { 61 - pub issuer: ValidatedUrl, 78 + #[allow(clippy::struct_excessive_bools)] 79 + pub struct AuthorizationMetadata { 80 + pub issuer: OpaqueString, 62 81 63 82 pub request_parameter_supported: bool, 64 83 ··· 96 55 97 56 pub require_request_uri_registration: bool, 98 57 99 - pub scopes_supported: SupportSet<Scope>, 58 + pub scopes_supported: HashSet<String>, 100 59 101 - pub subject_types_supported: Vec<String>, 60 + pub subject_types_supported: HashSet<String>, 102 61 103 - pub response_types_supported: Vec<String>, 62 + pub response_types_supported: HashSet<String>, 104 63 105 - pub response_modes_supported: Vec<String>, 64 + pub response_modes_supported: HashSet<String>, 106 65 107 - pub grant_types_supported: SupportSet<GrantType>, 66 + pub grant_types_supported: Supported<GrantType>, 108 67 109 - pub code_challenge_methods_supported: SupportSet<CodeChallengeMethod>, 68 + pub code_challenge_methods_supported: Supported<pkce::CodeChallengeMethod>, 110 69 111 70 pub ui_locales_supported: Vec<String>, 112 71 113 72 pub display_values_supported: Vec<String>, 114 73 115 - pub request_object_signing_alg_values_supported: SupportSet<SigningAlgorithm>, 74 + pub request_object_signing_alg_values_supported: Supported<SigningAlgorithm>, 116 75 117 76 pub authorization_response_iss_parameter_supported: bool, 118 77 119 - pub request_object_encryption_alg_values_supported: Vec<String>, 78 + pub request_object_encryption_alg_values_supported: HashSet<String>, 120 79 121 - pub request_object_encryption_enc_values_supported: Vec<String>, 80 + pub request_object_encryption_enc_values_supported: HashSet<String>, 122 81 123 - pub jwks_uri: Option<ValidatedUrl>, 82 + pub jwks_uri: Option<String>, 124 83 125 - pub authorization_endpoint: ValidatedUrl, 84 + pub authorization_endpoint: Url, 126 85 127 - pub token_endpoint: ValidatedUrl, 86 + pub token_endpoint: Url, 128 87 129 - pub token_endpoint_auth_methods_supported: Vec<String>, 88 + pub token_endpoint_auth_methods_supported: Supported<TokenEndpointAuthMethod>, 130 89 131 - /// Must include `SigningAlgorithm::ES256`. 132 - pub token_endpoint_auth_signing_alg_values_supported: SupportSet<SigningAlgorithm>, 90 + pub token_endpoint_auth_signing_alg_values_supported: Supported<SigningAlgorithm>, 133 91 134 - pub revocation_endpoint: ValidatedUrl, 92 + pub revocation_endpoint: Url, 135 93 136 - pub introspection_endpoint: ValidatedUrl, 94 + pub introspection_endpoint: Option<Url>, 137 95 138 - /// PAR endpoint URL. Required. 139 - pub pushed_authorization_request_endpoint: ValidatedUrl, 96 + pub pushed_authorization_request_endpoint: Url, 140 97 141 98 pub require_pushed_authorization_requests: bool, 142 99 143 - /// Must include `SigningAlgorithm::ES256`. 144 - pub dpop_signing_alg_values_supported: SupportSet<SigningAlgorithm>, 100 + pub dpop_signing_alg_values_supported: Supported<SigningAlgorithm>, 145 101 146 - /// Must be `true`. Required. 147 102 pub client_id_metadata_document_supported: bool, 148 103 } 149 104 150 - #[cfg(test)] 151 - mod tests { 152 - use super::OauthProtectedResource; 105 + pub trait IntoAuthorizationServerUrl { 106 + /// Convert `self` into a URL for the authoriztion server metadata document 107 + /// located at "/.well-known/oauth-authorization-server". 108 + fn into_authorization_server_url(self) -> Url; 109 + } 153 110 154 - #[test] 155 - fn parse_protected_resource() { 156 - let sample = r#"{"resource":"https://porcini.us-east.host.bsky.network","authorization_servers":["https://bsky.social"],"scopes_supported":[],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}"#; 157 - let parsed: OauthProtectedResource = serde_json::from_str(sample).unwrap(); 158 - assert_eq!( 159 - parsed.resource.as_str(), 160 - "https://porcini.us-east.host.bsky.network/" 161 - ); 111 + impl IntoAuthorizationServerUrl for &Url { 112 + fn into_authorization_server_url(self) -> Url { 113 + let mut url = self.clone(); 114 + url.set_path("/.well-known/oauth-authorization-server"); 115 + url 162 116 } 117 + } 163 118 164 - #[test] 165 - fn parse_authorization_server() { 166 - const SAMPLE: &str = r#" 167 - { 119 + impl IntoAuthorizationServerUrl for Url { 120 + // Optimized impl for owned [`Url`]. 121 + fn into_authorization_server_url(mut self) -> Url { 122 + self.set_path("/.well-known/oauth-authorization-server"); 123 + self 124 + } 125 + } 126 + 127 + #[derive(Clone, Debug, Deserialize, Serialize)] 128 + pub struct ClientMetadata { 129 + /// Full URL used to fetch the client metadata JSON. 130 + pub client_id: Url, 131 + 132 + pub application_type: ApplicationType, 133 + 134 + #[serde(skip_serializing_if = "Vec::is_empty")] 135 + pub grant_types: Vec<GrantType>, 136 + 137 + /// Any scope values which _might_ be requested by this client. The 138 + /// "atproto" scope is required. 139 + #[serde(with = "crate::serde::vec_as_spaced_string")] 140 + pub scope: Vec<Cow<'static, str>>, 141 + 142 + #[serde(skip_serializing_if = "Vec::is_empty")] 143 + pub response_types: Vec<ResponseType>, 144 + 145 + /// Fully-qualified redirect/callback url. 146 + #[serde(skip_serializing_if = "Vec::is_empty")] 147 + pub redirect_uris: Vec<Url>, 148 + 149 + #[serde(skip_serializing_if = "Option::is_none")] 150 + pub token_endpoint_auth_method: Option<TokenEndpointAuthMethod>, 151 + 152 + #[serde(skip_serializing_if = "Option::is_none")] 153 + pub token_endpoint_auth_signing_alg: Option<SigningAlgorithm>, 154 + 155 + pub dpop_bound_access_tokens: bool, 156 + 157 + #[serde(skip_serializing_if = "Vec::is_empty")] 158 + pub jwks: Vec<JsonWebKey>, 159 + 160 + #[serde(skip_serializing_if = "Option::is_none")] 161 + pub jwks_uri: Option<Url>, 162 + 163 + /// Human-readable name of the client. 164 + #[serde(skip_serializing_if = "Option::is_none")] 165 + pub client_name: Option<String>, 166 + 167 + /// Homepage URL for the client. 168 + /// 169 + /// If provided, must have same hostname as [`client_id`]. 170 + #[serde(skip_serializing_if = "Option::is_none")] 171 + pub client_uri: Option<Url>, 172 + 173 + /// HTTP URL to client logo. 174 + #[serde(skip_serializing_if = "Option::is_none")] 175 + pub logo_uri: Option<String>, 176 + 177 + /// HTTP URL to human-readable terms of service for the client. 178 + #[serde(skip_serializing_if = "Option::is_none")] 179 + pub tos_uri: Option<String>, 180 + 181 + /// HTTP URL to human-readable privacy policy for the client. 182 + #[serde(skip_serializing_if = "Option::is_none")] 183 + pub policy_uri: Option<String>, 184 + } 185 + 186 + impl From<Url> for ClientMetadata { 187 + fn from(client_id: Url) -> Self { 188 + Self { 189 + client_id, 190 + application_type: ApplicationType::default(), 191 + grant_types: vec![GrantType::AuthorizationCode], 192 + scope: vec!["atproto".into()], 193 + response_types: vec![ResponseType::default()], 194 + redirect_uris: Vec::new(), 195 + token_endpoint_auth_method: None, 196 + token_endpoint_auth_signing_alg: None, 197 + dpop_bound_access_tokens: true, 198 + jwks: Vec::new(), 199 + jwks_uri: None, 200 + client_name: None, 201 + client_uri: None, 202 + logo_uri: None, 203 + tos_uri: None, 204 + policy_uri: None, 205 + } 206 + } 207 + } 208 + 209 + #[test] 210 + fn can_parse_protected_resource() { 211 + let sample = r#"{ 212 + "resource": "https://porcini.us-east.host.bsky.network", 213 + "authorization_servers": ["https://bsky.social"], 214 + "scopes_supported": [], 215 + "bearer_methods_supported": ["header"], 216 + "resource_documentation": "https://atproto.com" 217 + }"#; 218 + let parsed: ProtectedResource = serde_json::from_str(sample).unwrap(); 219 + assert_eq!( 220 + parsed.resource.as_ref(), 221 + "https://porcini.us-east.host.bsky.network" 222 + ); 223 + } 224 + 225 + #[test] 226 + fn can_parse_authorization_server() { 227 + const SAMPLE: &str = r#" 228 + { 168 229 "issuer": "https://bsky.social", 169 230 "request_parameter_supported": true, 170 231 "request_uri_parameter_supported": true, ··· 358 215 } 359 216 "#; 360 217 361 - let _: super::OauthAuthorizationServer = serde_json::from_str(SAMPLE).unwrap(); 362 - } 218 + let _: AuthorizationMetadata = serde_json::from_str(SAMPLE).unwrap(); 363 219 }
+95
crates/gordian-auth/src/serde.rs
··· 1 + pub mod base64 { 2 + //! Serialize and deserialize a `Vec<u8>` as an URL-safe base64 string. 3 + //! 4 + //! Use this module in combination with serde's [`#[with]`][with] attribute. 5 + //! 6 + //! [with]: https://serde.rs/field-attrs.html#with 7 + 8 + pub use data_encoding::BASE64URL_NOPAD as ENCODING; 9 + use serde::Deserialize; 10 + use serde::Deserializer; 11 + use serde::Serializer; 12 + 13 + #[allow(clippy::missing_errors_doc)] 14 + pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> 15 + where 16 + D: Deserializer<'de>, 17 + { 18 + let encoded = <&str as Deserialize>::deserialize(deserializer)?; 19 + let bytes = ENCODING 20 + .decode(encoded.as_bytes()) 21 + .map_err(serde::de::Error::custom)?; 22 + 23 + Ok(bytes) 24 + } 25 + 26 + #[allow(clippy::missing_errors_doc)] 27 + pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> 28 + where 29 + S: Serializer, 30 + { 31 + let encoded = ENCODING.encode(bytes); 32 + serializer.serialize_str(&encoded) 33 + } 34 + } 35 + 36 + pub mod vec_as_spaced_string { 37 + //! Serialize and deserialize sequence of items as a space-separated string. 38 + //! 39 + //! Use this module in combination with serde's [`#[with]`][with] attribute. 40 + //! 41 + //! [with]: https://serde.rs/field-attrs.html#with 42 + 43 + use std::borrow::Cow; 44 + 45 + use serde::Deserialize; 46 + use serde::Deserializer; 47 + use serde::Serialize; 48 + use serde::Serializer; 49 + 50 + #[allow(clippy::missing_errors_doc)] 51 + pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Cow<'static, str>>, D::Error> 52 + where 53 + D: Deserializer<'de>, 54 + { 55 + let values = <&str as Deserialize>::deserialize(deserializer)?; 56 + Ok(values 57 + .split_whitespace() 58 + .map(|value| Cow::Owned(value.to_string())) 59 + .collect()) 60 + } 61 + 62 + #[allow(clippy::missing_errors_doc)] 63 + pub fn serialize<S, T>(values: &[T], serializer: S) -> Result<S::Ok, S::Error> 64 + where 65 + S: Serializer, 66 + T: Serialize, 67 + { 68 + let mut buffer = String::new(); 69 + 70 + for value in values { 71 + let ser = serde_json::to_string(&value).map_err(serde::ser::Error::custom)?; 72 + buffer.push_str(ser.trim_matches('"')); 73 + buffer.push(' '); 74 + } 75 + 76 + serializer.serialize_str(buffer.trim_end()) 77 + } 78 + 79 + #[test] 80 + fn can_serialize_spaced_str() { 81 + #[derive(Serialize)] 82 + struct Test<'a> { 83 + #[serde(with = "self")] 84 + scopes: &'a [&'static str], 85 + } 86 + 87 + let scopes = ["atproto", "transition:generic"]; 88 + let s = serde_json::to_string(&Test { 89 + scopes: scopes.as_slice(), 90 + }) 91 + .unwrap(); 92 + 93 + assert_eq!(s, r#"{"scopes":"atproto transition:generic"}"#); 94 + } 95 + }
-163
crates/gordian-auth/src/support_set.rs
··· 1 - use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeSeq as _}; 2 - use std::{ 3 - collections::HashSet, 4 - hash::{BuildHasher, RandomState}, 5 - marker::PhantomData, 6 - ops::{Deref, DerefMut}, 7 - }; 8 - 9 - /// Deserializes a sequence while ignoring elements that cannot be deserialized. 10 - /// 11 - /// # Example 12 - /// 13 - /// ```rust,ignore 14 - /// use oauth::support_set::SupportSet; 15 - /// use serde::Deserialize; 16 - /// 17 - /// /// Signature algorithms our program supports. 18 - /// #[derive(Deserialize, Hash, PartialEq, Eq)] 19 - /// enum Algorithms { 20 - /// ES256K, 21 - /// ES256, 22 - /// } 23 - /// 24 - /// // Response from some API. 25 - /// let response = r#"["ES256", "RS256", "RS384", "RS512"]"#; 26 - /// let matched: SupportSet<Algorithms> = serde_json::from_str(response).unwrap(); 27 - /// 28 - /// // The response only contains one algorithm we recognise. 29 - /// assert_eq!(matched.len(), 1); 30 - /// assert!(matched.contains(&Algorithms::ES256)); 31 - /// ``` 32 - /// 33 - #[derive(Debug)] 34 - pub struct SupportSet<T, S = RandomState>(pub HashSet<T, S>); 35 - 36 - impl<T, S> SupportSet<T, S> 37 - where 38 - S: BuildHasher + Default, 39 - { 40 - pub fn new() -> Self { 41 - Self(HashSet::with_hasher(Default::default())) 42 - } 43 - } 44 - 45 - impl<T> Default for SupportSet<T> { 46 - #[inline] 47 - fn default() -> Self { 48 - Self(HashSet::new()) 49 - } 50 - } 51 - 52 - impl<T> Deref for SupportSet<T> { 53 - type Target = HashSet<T>; 54 - #[inline] 55 - fn deref(&self) -> &Self::Target { 56 - &self.0 57 - } 58 - } 59 - 60 - impl<T> DerefMut for SupportSet<T> { 61 - #[inline] 62 - fn deref_mut(&mut self) -> &mut Self::Target { 63 - &mut self.0 64 - } 65 - } 66 - 67 - impl<T, S> FromIterator<T> for SupportSet<T, S> 68 - where 69 - T: Eq + std::hash::Hash, 70 - S: BuildHasher + Default, 71 - { 72 - #[inline] 73 - fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self { 74 - let mut set = HashSet::with_hasher(Default::default()); 75 - set.extend(iter); 76 - Self(set) 77 - } 78 - } 79 - 80 - impl<'de, T> Deserialize<'de> for SupportSet<T> 81 - where 82 - T: Deserialize<'de> + std::hash::Hash + Eq, 83 - { 84 - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 85 - where 86 - D: serde::Deserializer<'de>, 87 - { 88 - struct RecognisedSet<T>(PhantomData<T>); 89 - 90 - impl<'de, T> Visitor<'de> for RecognisedSet<T> 91 - where 92 - T: Deserialize<'de> + std::hash::Hash + Eq, 93 - { 94 - type Value = HashSet<T>; 95 - 96 - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 97 - formatter.write_str("something") 98 - } 99 - 100 - fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> 101 - where 102 - A: serde::de::SeqAccess<'de>, 103 - { 104 - let mut set = HashSet::new(); 105 - loop { 106 - let Ok(maybe_element) = seq.next_element() else { 107 - continue; 108 - }; 109 - let Some(element) = maybe_element else { 110 - break; 111 - }; 112 - set.insert(element); 113 - } 114 - Ok(set) 115 - } 116 - } 117 - 118 - deserializer 119 - .deserialize_seq(RecognisedSet(PhantomData)) 120 - .map(Self) 121 - } 122 - } 123 - 124 - impl<T> Serialize for SupportSet<T> 125 - where 126 - T: Serialize, 127 - { 128 - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 129 - where 130 - S: serde::Serializer, 131 - { 132 - let mut seq = serializer.serialize_seq(Some(self.len()))?; 133 - for element in self.iter() { 134 - seq.serialize_element(element)?; 135 - } 136 - seq.end() 137 - } 138 - } 139 - 140 - #[cfg(test)] 141 - mod tests { 142 - use super::SupportSet; 143 - use serde::Deserialize; 144 - 145 - #[derive(Deserialize, Hash, PartialEq, Eq)] 146 - #[serde(rename_all = "lowercase")] 147 - enum SupportedValues { 148 - Elephants, 149 - Bananas, 150 - Aardvarks, 151 - } 152 - 153 - #[test] 154 - fn ignore_unsupported_values() { 155 - let sample = r#"["elephants", "pears", "bananas", "goose"]"#; 156 - let set: SupportSet<SupportedValues> = serde_json::from_str(sample).unwrap(); 157 - 158 - assert_eq!(set.len(), 2); 159 - assert!(set.contains(&SupportedValues::Elephants)); 160 - assert!(set.contains(&SupportedValues::Bananas)); 161 - assert!(!set.contains(&SupportedValues::Aardvarks)); 162 - } 163 - }
+222
crates/gordian-auth/src/supported.rs
··· 1 + use core::fmt; 2 + use core::ops; 3 + use std::collections::HashSet; 4 + use std::hash::BuildHasher; 5 + use std::hash::Hash; 6 + use std::hash::RandomState; 7 + use std::marker::PhantomData; 8 + 9 + use serde::Deserialize; 10 + use serde::Serialize; 11 + use serde::de::Visitor; 12 + use serde::ser::SerializeSeq; 13 + 14 + /// A wrapper for [`HashSet`] which discards unknown values when deserialized 15 + /// via serde. 16 + /// 17 + /// NOTE: This will not successfully round-trip when re-serialized. 18 + /// 19 + /// # Example 20 + /// 21 + /// ```rust 22 + /// use gordian_auth::supported::Supported; 23 + /// use serde::Deserialize; 24 + /// 25 + /// // Algorithms supported by some API, including algorithms we don't support. 26 + /// let response = r#"["P256", "RS256", "ES256", "ES384", "ES512"]"#; 27 + /// 28 + /// /// Algorithms our program supports. 29 + /// #[derive(Deserialize, Hash, PartialEq, Eq)] 30 + /// enum Algorithm { 31 + /// ES256, 32 + /// ES256K, 33 + /// } 34 + /// 35 + /// let matched: Supported<Algorithm> = serde_json::from_str(response).unwrap(); 36 + /// 37 + /// // The algorithm we support will be in the set. 38 + /// assert!(matched.contains(&Algorithm::ES256)); 39 + /// 40 + /// // Everything was discarded. 41 + /// assert_eq!(matched.len(), 1); 42 + /// ``` 43 + pub struct Supported<T, S = RandomState>(HashSet<T, S>); 44 + 45 + impl<T> Supported<T, RandomState> { 46 + /// Create an empty `Supported`. 47 + /// 48 + /// # Example 49 + /// 50 + /// ```rust 51 + /// use gordian_auth::supported::Supported; 52 + /// use gordian_auth::types::SigningAlgorithm; 53 + /// let set: Supported<SigningAlgorithm> = Supported::new(); 54 + /// ``` 55 + #[must_use] 56 + pub fn new() -> Self { 57 + Self(HashSet::new()) 58 + } 59 + 60 + /// Create an empty `Supported` with a least the specified `capacity`. 61 + /// 62 + /// # Example 63 + /// 64 + /// ```rust 65 + /// use gordian_auth::supported::Supported; 66 + /// use gordian_auth::types::SigningAlgorithm; 67 + /// let set: Supported<SigningAlgorithm> = Supported::with_capacity(42); 68 + /// assert!(set.capacity() >= 42); 69 + /// ``` 70 + #[must_use] 71 + pub fn with_capacity(capacity: usize) -> Self { 72 + Self(HashSet::with_capacity(capacity)) 73 + } 74 + } 75 + 76 + impl<T, S: BuildHasher> Supported<T, S> { 77 + pub const fn with_hasher(hasher: S) -> Self { 78 + Self(HashSet::with_hasher(hasher)) 79 + } 80 + 81 + pub fn with_capacity_and_hasher(capacity: usize, hasher: S) -> Self { 82 + Self(HashSet::with_capacity_and_hasher(capacity, hasher)) 83 + } 84 + } 85 + 86 + impl<T: fmt::Debug> fmt::Debug for Supported<T> { 87 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 88 + fmt::Debug::fmt(&self.0, f) 89 + } 90 + } 91 + 92 + impl<T> ops::Deref for Supported<T> { 93 + type Target = HashSet<T>; 94 + 95 + fn deref(&self) -> &Self::Target { 96 + &self.0 97 + } 98 + } 99 + 100 + impl<T> ops::DerefMut for Supported<T> { 101 + fn deref_mut(&mut self) -> &mut Self::Target { 102 + &mut self.0 103 + } 104 + } 105 + 106 + impl<T> Default for Supported<T> { 107 + fn default() -> Self { 108 + Self(HashSet::new()) 109 + } 110 + } 111 + 112 + impl<T, S> From<HashSet<T, S>> for Supported<T, S> { 113 + fn from(value: HashSet<T, S>) -> Self { 114 + Self(value) 115 + } 116 + } 117 + 118 + impl<T, S> From<Supported<T, S>> for HashSet<T, S> { 119 + fn from(value: Supported<T, S>) -> Self { 120 + value.0 121 + } 122 + } 123 + 124 + impl<'de, T> Deserialize<'de> for Supported<T> 125 + where 126 + T: Deserialize<'de> + Hash + Eq, 127 + { 128 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 129 + where 130 + D: serde::Deserializer<'de>, 131 + { 132 + struct RecognisedSet<T>(PhantomData<T>); 133 + 134 + impl<T> RecognisedSet<T> { 135 + const fn new() -> Self { 136 + Self(PhantomData) 137 + } 138 + } 139 + 140 + impl<'de, T> Visitor<'de> for RecognisedSet<T> 141 + where 142 + T: Deserialize<'de> + Hash + Eq, 143 + { 144 + type Value = Supported<T>; 145 + 146 + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 147 + formatter.write_str("sequence") 148 + } 149 + 150 + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> 151 + where 152 + A: serde::de::SeqAccess<'de>, 153 + { 154 + let mut set = HashSet::new(); 155 + loop { 156 + match seq.next_element() { 157 + Ok(None) => break, 158 + Ok(Some(value)) => _ = set.insert(value), 159 + Err(_) => {} 160 + } 161 + } 162 + Ok(Supported(set)) 163 + } 164 + } 165 + 166 + deserializer.deserialize_seq(RecognisedSet::new()) 167 + } 168 + } 169 + 170 + impl<T> Serialize for Supported<T> 171 + where 172 + T: Serialize, 173 + { 174 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 175 + where 176 + S: serde::Serializer, 177 + { 178 + let mut seq = serializer.serialize_seq(Some(self.len()))?; 179 + for element in self.iter() { 180 + seq.serialize_element(element)?; 181 + } 182 + seq.end() 183 + } 184 + } 185 + 186 + impl<I: Hash + Eq> FromIterator<I> for Supported<I> { 187 + fn from_iter<T: IntoIterator<Item = I>>(iter: T) -> Self { 188 + Self(HashSet::from_iter(iter)) 189 + } 190 + } 191 + 192 + #[cfg(test)] 193 + mod tests { 194 + use serde::Deserialize; 195 + 196 + use super::Supported; 197 + 198 + #[derive(Deserialize, Hash, PartialEq, Eq)] 199 + enum Algorithm { 200 + ES256, 201 + } 202 + 203 + #[derive(Deserialize)] 204 + struct Resource { 205 + signing_algorithms: Supported<Algorithm>, 206 + } 207 + 208 + #[test] 209 + fn can_deserialize() { 210 + let resource: Resource = 211 + serde_json::from_str(r#"{"signing_algorithms":["RS256", "PS256", "ES256", "ES384"]}"#) 212 + .unwrap(); 213 + 214 + assert!(resource.signing_algorithms.contains(&Algorithm::ES256)); 215 + } 216 + 217 + #[test] 218 + fn construct_from_iter() { 219 + let sup = Supported::from_iter([Algorithm::ES256]); 220 + assert!(sup.contains(&Algorithm::ES256)); 221 + } 222 + }
+152 -55
crates/gordian-auth/src/types.rs
··· 1 - use crate::support_set::SupportSet; 2 - use serde::{Deserialize, Serialize}; 1 + use std::borrow::Cow; 2 + 3 + use gordian_types::DidBuf; 4 + use serde::Deserialize; 5 + use serde::Serialize; 3 6 use url::Url; 4 7 5 - #[derive(Debug, Default, Deserialize, Serialize)] 8 + use crate::pkce; 9 + 10 + #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 6 11 #[serde(rename_all = "snake_case")] 7 12 pub enum ApplicationType { 8 13 #[default] ··· 15 10 Native, 16 11 } 17 12 18 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 19 - #[serde(rename_all = "snake_case")] 20 - pub enum Scope { 21 - Atproto, 22 - #[serde(rename = "transition:generic")] 23 - TransitionGeneric, 24 - #[serde(rename = "transition:email")] 25 - TransitionEmail, 26 - #[serde(rename = "transition:chat.bsky")] 27 - TransitionChatBSky, 28 - } 29 - 30 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 13 + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 31 14 #[serde(rename_all = "snake_case")] 32 15 pub enum GrantType { 33 16 AuthorizationCode, 34 17 RefreshToken, 35 18 } 36 19 37 - #[derive(Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 20 + #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 38 21 #[serde(rename_all = "UPPERCASE")] 39 22 pub enum SigningAlgorithm { 23 + // RS256, 24 + // RS384, 25 + // RS512, 26 + // PS256, 27 + // PS384, 28 + // PS512, 40 29 #[default] 41 30 ES256, 42 31 ES256K, 32 + ES384, 33 + ES512, 43 34 } 44 35 45 - #[derive(Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 46 - #[serde(rename_all = "lowercase")] 47 - pub enum CodeChallengeMethod { 36 + #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 37 + #[serde(rename_all = "snake_case")] 38 + pub enum TokenEndpointAuthMethod { 39 + PrivateKeyJwt, 48 40 #[default] 49 - S256, 41 + None, 42 + } 43 + 44 + #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)] 45 + #[serde(rename_all = "snake_case")] 46 + pub enum ResponseType { 47 + #[default] 48 + Code, 50 49 } 51 50 52 51 #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 53 - #[serde(rename_all = "snake_case")] 54 - pub struct Jwk { 55 - // 52 + pub enum ClientAssertionType { 53 + #[serde(rename = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")] 54 + JwtBearer, 56 55 } 57 56 57 + /// Error response from API requests. 58 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize, thiserror::Error)] 59 + #[error("{error}: {description}")] 60 + pub struct AuthorizationError { 61 + pub error: String, 62 + #[serde(default, rename = "error_description")] 63 + pub description: String, 64 + #[serde(rename = "error_uri")] 65 + pub uri: Option<String>, 66 + } 67 + 68 + /// Query parameters passed to the oauth callback route when authentication has 69 + /// been successful. 70 + #[derive(Clone, Debug, Deserialize, Serialize)] 71 + pub struct CallbackSuccess { 72 + /// Opaque string identifying the oauth session. 73 + pub state: Box<str>, 74 + 75 + /// Authorization issuer. 76 + /// 77 + /// For Bluesky's OAuth this is the URL of the authorization server. 78 + #[serde(rename = "iss")] 79 + pub issuer: Box<str>, 80 + 81 + /// Challenge code generated by the authorization server. 82 + pub code: String, 83 + } 84 + 85 + /// Query parameters passed to the oauth callback route when authentication has 86 + /// been unsuccessful. 58 87 #[derive(Debug, Deserialize, Serialize)] 59 - pub struct ClientMetadata { 60 - pub client_id: Url, 61 - pub client_name: Option<String>, 62 - #[serde(default)] 63 - pub application_type: ApplicationType, 64 - pub scope: SupportSet<Scope>, 65 - pub grant_types: SupportSet<GrantType>, 66 - pub jwks: Vec<Jwk>, 67 - dpop_bound_access_tokens: bool, 88 + pub struct CallbackError { 89 + /// Opaque string identifying the oauth session. 90 + pub state: Box<str>, 91 + 92 + /// Authorization issuer. 93 + /// 94 + /// For Bluesky's OAuth this is the URL of the authorization server. 95 + #[serde(rename = "iss")] 96 + pub issuer: Box<str>, 97 + 98 + #[serde(flatten)] 99 + pub error: AuthorizationError, 68 100 } 69 101 70 - impl ClientMetadata { 71 - #[allow(unused)] 72 - pub fn new(client_id: Url) -> Self { 73 - Self { 74 - client_id, 75 - client_name: None, 76 - application_type: Default::default(), 77 - scope: SupportSet::from_iter([Scope::Atproto]), 78 - grant_types: SupportSet::from_iter([ 79 - GrantType::AuthorizationCode, 80 - GrantType::RefreshToken, 81 - ]), 82 - jwks: vec![], 83 - dpop_bound_access_tokens: true, 102 + /// OAuth callback query parameters. 103 + #[derive(Debug, Deserialize, Serialize)] 104 + #[serde(untagged)] 105 + pub enum Callback { 106 + Success(CallbackSuccess), 107 + Error(CallbackError), 108 + } 109 + 110 + impl Callback { 111 + /// Return the state for the callback request. 112 + #[must_use] 113 + pub const fn state(&self) -> &str { 114 + match self { 115 + Self::Success(CallbackSuccess { 116 + state, 117 + issuer: _, 118 + code: _, 119 + }) 120 + | Self::Error(CallbackError { 121 + state, 122 + issuer: _, 123 + error: _, 124 + }) => state, 84 125 } 85 126 } 86 127 87 - #[allow(unused)] 88 - pub const fn set_application_type(mut self, application_type: ApplicationType) -> Self { 89 - self.application_type = application_type; 90 - self 128 + /// Return the issuer for the callback request. 129 + #[must_use] 130 + pub const fn issuer(&self) -> &str { 131 + match self { 132 + Self::Success(CallbackSuccess { 133 + state: _, 134 + issuer, 135 + code: _, 136 + }) 137 + | Self::Error(CallbackError { 138 + state: _, 139 + issuer, 140 + error: _, 141 + }) => issuer, 142 + } 91 143 } 92 144 } 93 145 94 - #[allow(unused)] 95 - #[derive(Debug, Deserialize, Serialize)] 96 - pub struct Callback { 146 + #[derive(Debug, serde::Serialize)] 147 + pub struct PushedAuthorizationRequest { 97 148 pub state: String, 98 - pub iss: Url, 149 + pub client_id: Url, 150 + pub client_assertion_type: ClientAssertionType, 151 + pub client_assertion: String, 152 + pub response_type: ResponseType, 153 + pub code_challenge_method: pkce::CodeChallengeMethod, 154 + pub code_challenge: String, 155 + pub redirect_uri: Url, 156 + #[serde(with = "crate::serde::vec_as_spaced_string")] 157 + pub scope: Vec<Cow<'static, str>>, 158 + pub login_hint: Option<String>, 159 + } 160 + 161 + #[derive(Debug, Deserialize, Serialize)] 162 + pub struct PushedAuthorizationResponse { 163 + pub request_uri: String, 164 + pub expires_in: u64, 165 + } 166 + 167 + #[derive(Debug, Deserialize, Serialize)] 168 + pub struct TokenRequest { 169 + pub redirect_uri: Url, 170 + pub grant_type: GrantType, 171 + /// Passed to the oauth callback. 99 172 pub code: String, 173 + pub code_verifier: pkce::CodeVerifier, 174 + pub client_id: Url, 175 + pub client_assertion: String, 176 + pub client_assertion_type: Cow<'static, str>, 177 + } 178 + 179 + #[derive(Debug, Deserialize, Serialize)] 180 + pub struct TokenResponse { 181 + pub access_token: String, 182 + pub token_type: String, 183 + pub refresh_token: Option<String>, 184 + pub scope: String, 185 + pub expires_in: i64, 186 + pub sub: DidBuf, 100 187 }
-55
crates/gordian-auth/src/validated_url.rs
··· 1 - use serde::{Deserialize, Serialize}; 2 - use std::convert::Infallible; 3 - use url::Url; 4 - 5 - #[derive(Hash, PartialEq, Eq, Deserialize, Serialize)] 6 - #[serde(try_from = "Url")] 7 - pub struct ValidatedUrl(String); 8 - 9 - impl ValidatedUrl { 10 - pub fn new(input: &str) -> Result<Self, url::ParseError> { 11 - let url = Url::parse(input)?; 12 - #[allow(deprecated)] 13 - Ok(Self(url.into())) 14 - } 15 - 16 - pub fn as_url(&self) -> Url { 17 - Url::parse(&self.0).expect("Url should have already been successfully parsed") 18 - } 19 - } 20 - 21 - impl std::fmt::Debug for ValidatedUrl { 22 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 - write!(f, "{:?}", self.0) 24 - } 25 - } 26 - 27 - impl std::fmt::Display for ValidatedUrl { 28 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 - f.write_str(&self.0) 30 - } 31 - } 32 - 33 - impl std::ops::Deref for ValidatedUrl { 34 - type Target = String; 35 - 36 - fn deref(&self) -> &Self::Target { 37 - &self.0 38 - } 39 - } 40 - 41 - impl From<ValidatedUrl> for Url { 42 - #[inline] 43 - fn from(value: ValidatedUrl) -> Self { 44 - value.as_url() 45 - } 46 - } 47 - 48 - impl TryFrom<Url> for ValidatedUrl { 49 - type Error = Infallible; 50 - 51 - #[inline] 52 - fn try_from(value: Url) -> Result<Self, Self::Error> { 53 - Ok(Self(value.into())) 54 - } 55 - }
+88 -37
crates/gordian-auth/src/verification_key.rs
··· 1 1 use exn::Exn; 2 2 3 3 pub trait VerificationKey: Send { 4 - /// Use the verification key to verify that `signature` is a valid signature of 5 - /// `message`. 4 + /// Use the verification key to verify that `signature` is a valid signature 5 + /// of `message`. 6 + /// 7 + /// # Errors 8 + /// 9 + /// Returns [`Unspecified`] if the signature does not match. 6 10 fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified>; 7 11 8 12 fn algorithm(&self) -> Option<&Algorithm> { ··· 17 13 pub trait IntoVerificationKey { 18 14 type Output: VerificationKey; 19 15 20 - fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>>; 16 + /// Convert `self` into a type implementing [`VerificationKey`]. 17 + /// 18 + /// # Errors 19 + /// 20 + /// Returns an error if the key cannot be converted. 21 + fn to_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>>; 21 22 } 22 23 23 24 impl VerificationKey for Box<dyn VerificationKey> { ··· 37 28 K: VerificationKey, 38 29 { 39 30 fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified> { 40 - let key = self.into_verification_key().map_err(|_| Unspecified)?; 31 + let key = self.to_verification_key().map_err(|_| Unspecified)?; 41 32 key.verify_sig(message, signature) 42 33 } 43 34 } 44 - 45 - /// Replicates [`aws_lc_rc::errors::Unspecified`]. 46 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 47 - pub struct Unspecified; 48 - 49 - impl core::fmt::Display for Unspecified { 50 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 51 - f.write_str("Unspecified") 52 - } 53 - } 54 - 55 - impl core::error::Error for Unspecified {} 56 35 57 36 #[derive(Clone, Debug, PartialEq, Eq)] 58 37 pub struct KeyRejected(std::borrow::Cow<'static, str>); ··· 59 62 60 63 impl core::error::Error for KeyRejected {} 61 64 65 + mod impl_multibase_key { 66 + use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED; 67 + use aws_lc_rs::signature::ECDSA_P256K1_SHA256_FIXED; 68 + use aws_lc_rs::signature::ParsedPublicKey; 69 + use exn::ResultExt as _; 70 + 71 + use super::IntoVerificationKey; 72 + use super::KeyRejected; 73 + 74 + pub struct MultibaseKey<T>(pub T); 75 + 76 + impl<T> IntoVerificationKey for MultibaseKey<T> 77 + where 78 + T: AsRef<str>, 79 + { 80 + type Output = ParsedPublicKey; 81 + 82 + fn to_verification_key(&self) -> Result<Self::Output, exn::Exn<super::KeyRejected>> { 83 + let make_error = 84 + || KeyRejected::new("Failed to decode key from VerificationMethod::Multikey"); 85 + 86 + let (_, key_data) = multibase::decode(self.0.as_ref()).or_raise(make_error)?; 87 + 88 + let (alg, key_data) = match key_data.split_at_checked(2) { 89 + Some(([0xe7, 0x01], key_data)) => (&ECDSA_P256K1_SHA256_FIXED, key_data), 90 + Some(([0x80, 0x24], key_data)) => (&ECDSA_P256_SHA256_FIXED, key_data), 91 + _ => { 92 + exn::bail!(KeyRejected::new( 93 + "unsupported multicodec prefix in verification method", 94 + )) 95 + } 96 + }; 97 + 98 + ParsedPublicKey::new(alg, key_data).or_raise(make_error) 99 + } 100 + } 101 + } 102 + 103 + pub use impl_multibase_key::MultibaseKey; 104 + 62 105 mod impl_verification_method { 63 106 //! Implement [`IntoVerificationKey`] for [`identity::VerificationMethod`]. 64 107 65 - use aws_lc_rs::signature::{ 66 - ECDSA_P256_SHA256_FIXED, ECDSA_P256K1_SHA256_FIXED, ParsedPublicKey, 67 - }; 68 - use exn::{Exn, ResultExt}; 108 + use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED; 109 + use aws_lc_rs::signature::ECDSA_P256K1_SHA256_FIXED; 110 + use aws_lc_rs::signature::ParsedPublicKey; 111 + use exn::Exn; 112 + use exn::ResultExt; 69 113 114 + use super::IntoVerificationKey; 115 + use super::KeyRejected; 116 + use super::Unspecified; 117 + use super::VerificationKey; 70 118 use crate::jwt::Algorithm; 71 - 72 - use super::{IntoVerificationKey, KeyRejected, Unspecified, VerificationKey}; 73 119 74 120 /// A verfication key derived from an Atmosphere verification method. 75 121 #[derive(Debug)] 76 122 pub struct VerificationMethodKey { 77 123 parsed_public_key: ParsedPublicKey, 78 124 algorithm: Algorithm, 125 + } 126 + 127 + impl AsRef<[u8]> for VerificationMethodKey { 128 + fn as_ref(&self) -> &[u8] { 129 + self.parsed_public_key.as_ref() 130 + } 79 131 } 80 132 81 133 impl VerificationKey for VerificationMethodKey { ··· 142 96 impl IntoVerificationKey for gordian_identity::VerificationMethod { 143 97 type Output = VerificationMethodKey; 144 98 145 - fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 99 + fn to_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 146 100 match self { 147 101 Self::Multikey { 148 102 id: _, ··· 205 159 use core::fmt; 206 160 use std::borrow::Cow; 207 161 208 - use aws_lc_rs::{ 209 - encoding::AsDer, 210 - rsa::{PublicEncryptingKey, PublicKeyComponents}, 211 - signature::{self, ParsedPublicKey, VerificationAlgorithm}, 212 - }; 213 - use exn::{Exn, OptionExt, ResultExt}; 162 + use aws_lc_rs::encoding::AsDer; 163 + use aws_lc_rs::rsa::PublicEncryptingKey; 164 + use aws_lc_rs::rsa::PublicKeyComponents; 165 + use aws_lc_rs::signature::ParsedPublicKey; 166 + use aws_lc_rs::signature::VerificationAlgorithm; 167 + use aws_lc_rs::signature::{self}; 168 + use exn::Exn; 169 + use exn::OptionExt; 170 + use exn::ResultExt; 214 171 215 - use super::{IntoVerificationKey, KeyRejected, VerificationKey}; 172 + use super::IntoVerificationKey; 173 + use super::KeyRejected; 174 + use super::VerificationKey; 216 175 217 176 impl VerificationKey for ParsedPublicKey { 218 177 fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), super::Unspecified> { ··· 233 182 { 234 183 type Output = ParsedPublicKey; 235 184 236 - fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 185 + fn to_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 237 186 use data_encoding::BASE64 as Encoding; 238 187 239 188 let make_error = || KeyRejected::new("Failed to parse openssh public key"); ··· 329 278 "ecdsa-sha2-nistp256" => &signature::ECDSA_P256_SHA256_FIXED, 330 279 "ecdsa-sha2-nistp384" => &signature::ECDSA_P384_SHA384_FIXED, 331 280 "ecdsa-sha2-nistp521" => &signature::ECDSA_P521_SHA512_FIXED, 332 - "rsa-sha2-256" => &signature::RSA_PKCS1_2048_8192_SHA256, 281 + "rsa-sha2-256" | "ssh-rsa" => &signature::RSA_PKCS1_2048_8192_SHA256, 333 282 "rsa-sha2-512" => &signature::RSA_PKCS1_2048_8192_SHA512, 334 - "ssh-rsa" => &signature::RSA_PKCS1_2048_8192_SHA256, 335 283 "ssh-ed25519" => &signature::ED25519, 336 284 _ => unreachable!("key decoding should have filtered unknown key types"), 337 285 }; ··· 358 308 .ok_or_raise(|| KeyRejected::new("Unexpected end of input")) 359 309 } 360 310 311 + #[allow(clippy::needless_pass_by_value)] 361 312 fn require_match<T: PartialEq + fmt::Debug>(a: T, b: T) -> Result<(), Exn<KeyRejected>> { 362 313 exn::ensure!(a == b, KeyRejected::new(format!("{a:?} != {b:?}"))); 363 314 Ok(()) ··· 367 316 368 317 pub use impl_openssh_keys::OpenSshKey; 369 318 319 + use crate::error::Unspecified; 370 320 use crate::jwt::Algorithm; 371 321 372 322 #[cfg(test)] 373 323 mod tests { 374 324 use aws_lc_rs::signature::ParsedPublicKey; 375 325 376 - use crate::verification_key::impl_openssh_keys::OpenSshKey; 377 - 378 326 use super::*; 327 + use crate::verification_key::impl_openssh_keys::OpenSshKey; 379 328 380 329 #[test] 381 330 fn can_parse_multikey() { ··· 387 336 public_key_multibase: "zQ3shNWn4uG62Nv3dkggV5dGiN7bHK2w2tX2QxtKpVCvDK4Ff".to_string(), 388 337 }; 389 338 390 - let _ = verification_method.into_verification_key().unwrap(); 339 + let _ = verification_method.to_verification_key().unwrap(); 391 340 } 392 341 393 342 #[test] ··· 410 359 const RSA: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCkooYf+stdQbID0A81kPhUxiIcos0WUXpuxb9Kbfonz+U9HMF5lsHjdi+ocXD33ch9eEXNX9F0wSaPFcImpp4Pz7BKOVIAJ1tp61OTU4SB0z8XAB5omX7EmxdJXYjqBjHuZ3R3FArlU3jwN2OuRWr/A1sC7NY26fzueRHsuzwMNQTM6NsUPXgL6KElsJX7XRuTHY3NLERQKSdyCuDqMlN0GTKtG/ZLcnmil6sccquBjNUekF4skrezBko2UxP9lpg1Qu//iRJUwloQqJwNUZYsbieZ92zGRTia0B/bN5ihQDl7cMuk744AhPAxWlhVl0TVo9Q+BRBdXPOxElNdRj08Y03EJC3LGLBro2TElOLqYDRVpX78mGyUF8mtC+cid98Hdn2sMRoepD2ozkjELmqj5LlAqIy19cZ1x4+VnaS9RRN+3aCrxPTuhK2o1ZH/tVuN+Lu8LDySVnXxFQ4Iy3Uh2Q5IVqQFt+wugQ2dHi6GK7EjGkcEyWFsDjYFoMPgNzc= tjh@macbook-air.local"; 411 360 412 361 for sample_key in [ED25519_PUB, ECDSA256_PUB, ECDSA384_PUB, RSA] { 413 - let _ = OpenSshKey(sample_key).into_verification_key().unwrap(); 362 + let _ = OpenSshKey(sample_key).to_verification_key().unwrap(); 414 363 } 415 364 } 416 365 }
+4 -2
crates/gordian-cred/src/commands/auth.rs
··· 1 - use core::{error, fmt}; 1 + use core::error; 2 + use core::fmt; 2 3 3 4 use exn::Exn; 4 5 use exn::ResultExt as _; 5 - use owo_colors::{OwoColorize, Stream}; 6 + use owo_colors::OwoColorize; 7 + use owo_colors::Stream; 6 8 7 9 #[derive(clap::Args)] 8 10 pub struct Auth {
+20 -11
crates/gordian-cred/src/commands/git_credential.rs
··· 1 - use core::{error, fmt}; 2 - use data_encoding::{BASE32HEX_NOPAD, BASE64URL_NOPAD}; 3 - use exn::{Exn, ResultExt as _}; 4 - use gordian_auth::jwt::{Algorithm, Curve, Header}; 1 + use core::error; 2 + use core::fmt; 3 + use std::borrow::Cow; 4 + use std::collections::HashSet; 5 + use std::path::Path; 6 + 7 + use data_encoding::BASE32HEX_NOPAD; 8 + use data_encoding::BASE64URL_NOPAD; 9 + use exn::Exn; 10 + use exn::ResultExt as _; 11 + use gordian_auth::jwt::Algorithm; 12 + use gordian_auth::jwt::Curve; 13 + use gordian_auth::jwt::Header; 5 14 use gordian_types::DidBuf; 6 - use owo_colors::{OwoColorize, Stream::Stderr}; 7 - use ssh_agent_client_rs::{Client, Identity}; 8 - use ssh_key::public::{EcdsaPublicKey, KeyData}; 9 - use std::{borrow::Cow, collections::HashSet, path::Path}; 15 + use owo_colors::OwoColorize; 16 + use owo_colors::Stream::Stderr; 17 + use ssh_agent_client_rs::Client; 18 + use ssh_agent_client_rs::Identity; 19 + use ssh_key::public::EcdsaPublicKey; 20 + use ssh_key::public::KeyData; 10 21 use time::OffsetDateTime; 11 22 use tokio::runtime::Builder; 12 23 ··· 175 164 176 165 // // We found a key, construct our JWT claims. 177 166 let iss = account_did.clone(); 178 - let aud = format!("did:web:{knot}") 179 - .parse::<DidBuf>() 180 - .or_raise(err)?; 167 + let aud = format!("did:web:{knot}").parse::<DidBuf>().or_raise(err)?; 181 168 let iat = OffsetDateTime::now_utc().unix_timestamp(); 182 169 let exp = iat + 45; 183 170 let jti: [u8; 16] = rand::random();
+7 -3
crates/gordian-cred/src/commands/git_setup.rs
··· 1 - use core::{error, fmt}; 2 - use std::{borrow::Cow, process::Command}; 1 + use core::error; 2 + use core::fmt; 3 + use std::borrow::Cow; 4 + use std::process::Command; 3 5 4 - use exn::{Exn, OptionExt, ResultExt}; 6 + use exn::Exn; 7 + use exn::OptionExt; 8 + use exn::ResultExt; 5 9 6 10 /// Install the credential helper for use with git 7 11 #[derive(Debug, clap::Args)]
+12 -8
crates/gordian-cred/src/config.rs
··· 1 - use core::{error, fmt}; 1 + use core::error; 2 + use core::fmt; 3 + use std::borrow::Cow; 4 + use std::collections::HashMap; 5 + use std::collections::HashSet; 6 + use std::path::PathBuf; 7 + 2 8 use directories::ProjectDirs; 3 - use exn::{Exn, OptionExt, ResultExt}; 9 + use exn::Exn; 10 + use exn::OptionExt; 11 + use exn::ResultExt; 4 12 use gordian_types::DidBuf; 5 - use serde::{Deserialize, Serialize}; 6 - use std::{ 7 - borrow::Cow, 8 - collections::{HashMap, HashSet}, 9 - path::PathBuf, 10 - }; 13 + use serde::Deserialize; 14 + use serde::Serialize; 11 15 12 16 #[derive(Debug, Deserialize, Serialize, Hash, PartialEq, Eq)] 13 17 pub struct PublicKey {
+13 -5
crates/gordian-cred/src/main.rs
··· 1 - use core::{error, fmt}; 1 + use core::error; 2 + use core::fmt; 2 3 3 4 mod commands; 4 5 mod config; 5 6 6 - use clap::{Parser, Subcommand}; 7 - use commands::{Auth, Generate, GitCredential, Install}; 8 - use exn::{Exn, ResultExt as _}; 9 - use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _}; 7 + use clap::Parser; 8 + use clap::Subcommand; 9 + use commands::Auth; 10 + use commands::Generate; 11 + use commands::GitCredential; 12 + use commands::Install; 13 + use exn::Exn; 14 + use exn::ResultExt as _; 15 + use tracing_subscriber::EnvFilter; 16 + use tracing_subscriber::layer::SubscriberExt as _; 17 + use tracing_subscriber::util::SubscriberInitExt as _; 10 18 11 19 pub trait RunCommand { 12 20 type Error;
+2 -1
crates/gordian-identity/src/bin/resolve.rs
··· 1 - use identity::{HttpClient, Resolver}; 1 + use gordian_identity::HttpClient; 2 + use gordian_identity::Resolver; 2 3 3 4 #[tokio::main(flavor = "current_thread")] 4 5 async fn main() {
+4 -3
crates/gordian-identity/src/document.rs
··· 1 1 use gordian_types::did::DidBuf; 2 - use serde::{Deserialize, Serialize}; 2 + use serde::Deserialize; 3 + use serde::Serialize; 3 4 use url::Url; 4 5 5 6 #[derive(Clone, Debug, Deserialize, Serialize)] ··· 24 23 } 25 24 26 25 impl Service { 27 - /// Create a [`Service`] definition for an `ATproto` PDS from `service_endpoint`. 26 + /// Create a [`Service`] definition for an `ATproto` PDS from 27 + /// `service_endpoint`. 28 28 #[must_use] 29 29 pub fn atproto_pds(service_endpoint: Url) -> Self { 30 30 Self { ··· 57 55 /// # Panics 58 56 /// 59 57 /// Panics if `handle` is not a valid Athmosphere handle 60 - /// 61 58 pub fn new(id: &str, handle: &str) -> Result<Self, gordian_types::did::Error> { 62 59 let id = id.parse()?; 63 60 Ok(Self {
+14 -16
crates/gordian-identity/src/lib.rs
··· 4 4 use core::fmt; 5 5 use std::sync::Arc; 6 6 7 - use futures_util::{FutureExt as _, future::BoxFuture}; 8 - use gordian_types::did::DidBuf; 9 - 10 - pub use document::{DidDocument, Service, VerificationMethod}; 7 + pub use document::DidDocument; 8 + pub use document::Service; 9 + pub use document::VerificationMethod; 10 + use futures_util::FutureExt as _; 11 + use futures_util::future::BoxFuture; 11 12 pub use gordian_types::did::Did; 13 + use gordian_types::did::DidBuf; 12 14 13 15 pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory"; 14 16 ··· 19 17 20 18 pub trait ResolveIdentity: fmt::Debug + Sync { 21 19 /// Resolve a handle or a DID to a DID and DID document. 22 - /// 23 20 fn resolve<'s: 'a, 'a>( 24 21 &'s self, 25 22 ident: &'a str, ··· 59 58 60 59 /// Resolve a handle to a DID. 61 60 /// 62 - /// Implementors are not required to bi-directionally confirm the resolution. 61 + /// Implementors are not required to bi-directionally confirm the 62 + /// resolution. 63 63 /// 64 64 /// [`ResolveIdentity::resolve`] should be preferred. 65 65 /// ··· 69 67 /// # Errors 70 68 /// 71 69 /// Returns an error `handle` cannot be resolved to a DID. 72 - /// 73 70 fn resolve_handle<'s: 'h, 'h>( 74 71 &'s self, 75 72 handle: &'h str, ··· 76 75 77 76 /// Resolve a DID to DID document. 78 77 /// 79 - /// Implementors are not required to bi-directionally confirm the resolution. 78 + /// Implementors are not required to bi-directionally confirm the 79 + /// resolution. 80 80 /// 81 81 /// [`ResolveIdentity::resolve`] should be preferred. 82 82 /// ··· 86 84 /// # Errors 87 85 /// 88 86 /// Returns an error if `did` cannot be resolved to a DID document. 89 - /// 90 87 fn resolve_did<'s: 'd, 'd>( 91 88 &'s self, 92 89 did: &'d Did, ··· 95 94 /// specified DID. 96 95 /// 97 96 /// This will have no effect on system-level caches, like DNS caches. 98 - /// 99 97 fn invalidate_did<'s: 'd, 'd>(&'s self, _: &'d Did) -> BoxFuture<'d, ()> { 100 98 async {}.boxed() 101 99 } ··· 145 145 /// 146 146 /// # Errors 147 147 /// 148 - /// Returns a error if `ident` cannot be resolved. This included bi-directional resolution 149 - /// errors. 150 - /// 148 + /// Returns a error if `ident` cannot be resolved. This included 149 + /// bi-directional resolution errors. 151 150 pub async fn resolve(&self, ident: &str) -> Result<(DidBuf, DidDocument), ResolveError> { 152 151 let ident = ident.trim_start_matches('@'); 153 152 self.inner.resolve(ident).await ··· 157 158 /// # Errors 158 159 /// 159 160 /// Returns a error if `handle` cannot be resolved to a DID. 160 - /// 161 161 #[inline] 162 162 pub async fn resolve_handle(&self, handle: &str) -> Result<DidBuf, ResolveError> { 163 163 let handle = handle.trim_start_matches('@'); ··· 168 170 /// # Errors 169 171 /// 170 172 /// Returns a error if `did` cannot be resolved to a DID document. 171 - /// 172 173 #[inline] 173 174 pub async fn resolve_did(&self, did: &Did) -> Result<DidDocument, ResolveError> { 174 175 self.inner.resolve_did(did).await ··· 264 267 265 268 #[must_use] 266 269 pub fn build_with(self, http: HttpClient) -> Resolver { 270 + use std::sync::Arc; 271 + 267 272 use resolvers::direct::DirectResolver; 268 273 use resolvers::memcache::MemcacheResolver; 269 - use std::sync::Arc; 270 274 271 275 let inner: Arc<dyn ResolveIdentity + Send + Sync + 'static> = match self.backend { 272 276 ResolverBackend::Direct => Arc::new(
+12 -7
crates/gordian-identity/src/resolvers/direct.rs
··· 1 1 use std::borrow::Cow; 2 2 3 - use futures_util::{FutureExt as _, future::BoxFuture}; 3 + use futures_util::FutureExt as _; 4 + use futures_util::future::BoxFuture; 4 5 use gordian_types::did::DidBuf; 6 + use hickory_resolver::ResolveError as DnsResolveError; 7 + use hickory_resolver::Resolver as DnsClient; 8 + use hickory_resolver::TokioResolver; 9 + use hickory_resolver::name_server::ConnectionProvider; 5 10 use hickory_resolver::name_server::TokioConnectionProvider; 6 - use hickory_resolver::{ 7 - ResolveError as DnsResolveError, Resolver as DnsClient, TokioResolver, 8 - name_server::ConnectionProvider, 9 - }; 10 11 use tokio::time::Instant; 11 12 12 - use crate::{DEFAULT_PLC_DIRECTORY, Did, DidDocument, HttpClient, ResolveError, ResolveIdentity}; 13 + use crate::DEFAULT_PLC_DIRECTORY; 14 + use crate::Did; 15 + use crate::DidDocument; 16 + use crate::HttpClient; 17 + use crate::ResolveError; 18 + use crate::ResolveIdentity; 13 19 14 20 pub struct DirectResolver<'plc, R: ConnectionProvider> { 15 21 plc: Cow<'plc, str>, ··· 137 131 /// # Panics 138 132 /// 139 133 /// Panics if the dns resolver cannot be initialised. 140 - /// 141 134 #[must_use] 142 135 pub fn build_with(self, http: HttpClient) -> DirectResolver<'plc, TokioConnectionProvider> { 143 136 DirectResolver {
+11 -4
crates/gordian-identity/src/resolvers/memcache.rs
··· 1 - use std::{sync::Arc, time::Duration}; 1 + use std::sync::Arc; 2 + use std::time::Duration; 2 3 3 - use futures_util::{FutureExt as _, TryFutureExt as _, future::BoxFuture}; 4 + use futures_util::FutureExt as _; 5 + use futures_util::TryFutureExt as _; 6 + use futures_util::future::BoxFuture; 4 7 use gordian_types::DidBuf; 5 - use moka::future::{Cache, CacheBuilder}; 8 + use moka::future::Cache; 9 + use moka::future::CacheBuilder; 6 10 7 - use crate::{Did, DidDocument, ResolveError, ResolveIdentity}; 11 + use crate::Did; 12 + use crate::DidDocument; 13 + use crate::ResolveError; 14 + use crate::ResolveIdentity; 8 15 9 16 const DEFAULT_DID_CACHE_CAP: u64 = 1024; 10 17 const DEFAULT_DOC_CACHE_CAP: u64 = 1024;
+14 -16
crates/gordian-jetstream/src/client.rs
··· 1 - use crate::{ 2 - Nsid, 3 - de::Event, 4 - metrics::{Metrics, MetricsData}, 5 - subscriber_options::SubscriberOptions, 6 - task::JetstreamTaskError, 7 - }; 1 + use std::sync::Arc; 2 + use std::sync::Mutex; 3 + 8 4 use bytes::Bytes; 9 5 use gordian_types::DidBuf; 10 - use std::sync::{Arc, Mutex}; 11 6 use tokio::sync::oneshot; 12 - use tokio_util::sync::{CancellationToken, DropGuard}; 7 + use tokio_util::sync::CancellationToken; 8 + use tokio_util::sync::DropGuard; 9 + 10 + use crate::Nsid; 11 + use crate::de::Event; 12 + use crate::metrics::Metrics; 13 + use crate::metrics::MetricsData; 14 + use crate::subscriber_options::SubscriberOptions; 15 + use crate::task::JetstreamTaskError; 13 16 14 17 #[derive(Debug)] 15 18 pub struct JetstreamClient { ··· 47 44 /// # Panics 48 45 /// 49 46 /// Panics if the [`Mutex`] for the client options has been poisoned. 50 - /// 51 47 pub async fn add_did(&self, did: impl Into<DidBuf>) -> Result<(), JetstreamClientError> { 52 48 if self.options.lock().unwrap().add_did(did.into())? { 53 49 // The DID is new to the client, notify the task to update. ··· 64 62 /// # Panics 65 63 /// 66 64 /// Panics if the [`Mutex`] for the client options has been poisoned. 67 - /// 68 65 pub async fn remove_did(&self, did: impl Into<DidBuf>) -> Result<(), JetstreamClientError> { 69 66 if self.options.lock().unwrap().remove_did(&did.into()) { 70 67 self.update_task().await?; ··· 80 79 /// # Panics 81 80 /// 82 81 /// Panics if the [`Mutex`] for the client options has been poisoned. 83 - /// 84 82 pub async fn add_collection( 85 83 &self, 86 84 collection: impl Into<Box<Nsid>>, ··· 105 105 /// # Panics 106 106 /// 107 107 /// Panics if the [`Mutex`] for the client options has been poisoned. 108 - /// 109 108 pub async fn remove_collection( 110 109 &self, 111 110 collection: impl Into<Box<Nsid>>, ··· 130 131 /// # Errors 131 132 /// 132 133 /// Returns an error if the Jetstream task is no longer active. 133 - /// 134 134 pub async fn shutdown(self) -> Result<(), JetstreamClientError> { 135 135 let (command, complete) = ClientCommand::shutdown(); 136 136 self.client_tx.send(command)?; ··· 227 229 Some(JetstreamEvent::new(bytes)) 228 230 } 229 231 230 - /// Consume the Jetstream receiver and return the wrapped flume channel receiver. 232 + /// Consume the Jetstream receiver and return the wrapped flume channel 233 + /// receiver. 231 234 #[must_use] 232 235 pub fn to_inner(self) -> flume::Receiver<Bytes> { 233 236 self.event_rx ··· 262 263 /// 263 264 /// Returns an error if the event cannot be deserialized from it JSON 264 265 /// representation. 265 - /// 266 266 pub fn deserialize(&'a self) -> Result<Event<'a>, serde_json::Error> { 267 267 let value = serde_json::from_slice(&self.bytes)?; 268 268 Ok(value)
+19 -16
crates/gordian-jetstream/src/client_config.rs
··· 1 - use std::{ 2 - borrow::Cow, 3 - sync::{Arc, Mutex}, 4 - }; 1 + use std::borrow::Cow; 2 + use std::sync::Arc; 3 + use std::sync::Mutex; 5 4 6 5 use futures_util::FutureExt as _; 7 6 use tokio_util::sync::CancellationToken; 8 7 9 - use crate::{ 10 - JetstreamClient, JetstreamReceiver, PUBLIC_JETSTREAM_US_EAST1, PUBLIC_JETSTREAM_US_EAST2, 11 - PUBLIC_JETSTREAM_US_WEST1, PUBLIC_JETSTREAM_US_WEST2, client_options::ClientOptions, 12 - metrics::Metrics, subscriber_options::SubscriberOptions, task::JetstreamTask, 13 - }; 8 + use crate::JetstreamClient; 9 + use crate::JetstreamReceiver; 10 + use crate::PUBLIC_JETSTREAM_US_EAST1; 11 + use crate::PUBLIC_JETSTREAM_US_EAST2; 12 + use crate::PUBLIC_JETSTREAM_US_WEST1; 13 + use crate::PUBLIC_JETSTREAM_US_WEST2; 14 + use crate::client_options::ClientOptions; 15 + use crate::metrics::Metrics; 16 + use crate::subscriber_options::SubscriberOptions; 17 + use crate::task::JetstreamTask; 14 18 15 19 #[derive(Clone, Debug, Default)] 16 20 #[must_use] ··· 24 20 } 25 21 26 22 impl JetstreamConfig { 27 - /// Create default a [`JetstreamConfig`] connecting to jetstream{1,2}.us-east.bsky.network 28 - /// instances. 23 + /// Create default a [`JetstreamConfig`] connecting to 24 + /// jetstream{1,2}.us-east.bsky.network instances. 29 25 pub fn us_east() -> Self { 30 26 Self { 31 27 client_options: ClientOptions { ··· 39 35 } 40 36 } 41 37 42 - /// Create default a [`JetstreamConfig`] connecting to jetstream{1,2}.us-west.bsky.network 43 - /// instances. 38 + /// Create default a [`JetstreamConfig`] connecting to 39 + /// jetstream{1,2}.us-west.bsky.network instances. 44 40 pub fn us_west() -> Self { 45 41 Self { 46 42 client_options: ClientOptions { ··· 90 86 /// 91 87 /// # Panics 92 88 /// 93 - /// Panics if a collection is not a valid NSID or if the maximum number of filters is 94 - /// exceeded. 95 - /// 89 + /// Panics if a collection is not a valid NSID or if the maximum number of 90 + /// filters is exceeded. 96 91 pub fn with_collections<'a>(mut self, collections: impl IntoIterator<Item = &'a str>) -> Self { 97 92 for collection in collections { 98 93 let collection = collection
+2 -1
crates/gordian-jetstream/src/client_options.rs
··· 1 - use std::{borrow::Cow, time::Duration}; 1 + use std::borrow::Cow; 2 + use std::time::Duration; 2 3 3 4 #[derive(Clone, Debug)] 4 5 pub struct ClientOptions {
+3 -1
crates/gordian-jetstream/src/de.rs
··· 1 1 use gordian_types::Did; 2 - use serde::{Deserialize, Serialize, de::Visitor}; 2 + use serde::Deserialize; 3 + use serde::Serialize; 4 + use serde::de::Visitor; 3 5 use serde_json::value::RawValue; 4 6 use time::OffsetDateTime; 5 7
+13 -3
crates/gordian-jetstream/src/lib.rs
··· 7 7 pub mod metrics; 8 8 pub mod subscriber_options; 9 9 10 - pub use client::{JetstreamClient, JetstreamClientError, JetstreamEvent, JetstreamReceiver}; 11 - pub use de::{AccountStatus, Commit, CommitEvent, Delete, Event, Identity, InnerAccount}; 12 - pub use gordian_types::{Did, Nsid}; 10 + pub use client::JetstreamClient; 11 + pub use client::JetstreamClientError; 12 + pub use client::JetstreamEvent; 13 + pub use client::JetstreamReceiver; 14 + pub use de::AccountStatus; 15 + pub use de::Commit; 16 + pub use de::CommitEvent; 17 + pub use de::Delete; 18 + pub use de::Event; 19 + pub use de::Identity; 20 + pub use de::InnerAccount; 21 + pub use gordian_types::Did; 22 + pub use gordian_types::Nsid; 13 23 pub use serde_json::Value; 14 24 15 25 pub const PUBLIC_JETSTREAM_US_EAST1: &str = "wss://jetstream1.us-east.bsky.network";
+7 -6
crates/gordian-jetstream/src/main.rs
··· 29 29 } 30 30 } 31 31 32 - use std::{ 33 - fs::File, 34 - io::Write, 35 - time::{Duration, SystemTime, UNIX_EPOCH}, 36 - }; 32 + use std::fs::File; 33 + use std::io::Write; 34 + use std::time::Duration; 35 + use std::time::SystemTime; 36 + use std::time::UNIX_EPOCH; 37 37 38 - use gordian_jetstream::{Event, client_config::JetstreamConfig}; 38 + use gordian_jetstream::Event; 39 + use gordian_jetstream::client_config::JetstreamConfig; 39 40 40 41 #[tokio::main(flavor = "current_thread")] 41 42 async fn main() {
+3 -2
crates/gordian-jetstream/src/metrics.rs
··· 1 - use std::sync::{Arc, Mutex, MutexGuard}; 1 + use std::sync::Arc; 2 + use std::sync::Mutex; 3 + use std::sync::MutexGuard; 2 4 3 5 /// Jetstream client metrics. 4 6 #[derive(Debug, Default)] ··· 44 42 /// # Panics 45 43 /// 46 44 /// Panics if the contained mutex has been poisoned 47 - /// 48 45 #[must_use] 49 46 pub fn export(&self) -> MetricsData { 50 47 self.inner.lock().unwrap().clone()
+29 -26
crates/gordian-jetstream/src/subscriber_options.rs
··· 1 1 use std::collections::HashSet; 2 2 3 3 use gordian_types::DidBuf; 4 - use serde::{Deserialize, Serialize}; 4 + use serde::Deserialize; 5 + use serde::Serialize; 5 6 6 - use crate::{Did, Nsid}; 7 + use crate::Did; 8 + use crate::Nsid; 7 9 8 10 pub const MAX_WANTED_COLLECTIONS: usize = 100; 9 11 ··· 16 14 17 15 /// Jetstream subscription options. 18 16 /// 19 - /// Can either be appended to the `/subscribe` URL on connection to the Jetstream instance 20 - /// or sent as an options update message after connection. 17 + /// Can either be appended to the `/subscribe` URL on connection to the 18 + /// Jetstream instance or sent as an options update message after connection. 21 19 /// 22 20 /// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#options-updates> 23 - /// 24 21 #[derive(Clone, Debug, Default, Deserialize, Serialize)] 25 22 #[serde(rename_all = "camelCase")] 26 23 pub struct SubscriberOptions { ··· 35 34 36 35 /// Maximum message size in bytes the subscriber wants to receive. 37 36 /// 38 - /// Zero means no limit, negative values are treated as zero by Jetstream, and 39 - /// will be normalized to zero when serialized. 37 + /// Zero means no limit, negative values are treated as zero by Jetstream, 38 + /// and will be normalized to zero when serialized. 40 39 #[serde(with = "max_message_size")] 41 40 pub max_message_size_bytes: i64, 42 41 ··· 46 45 impl SubscriberOptions { 47 46 /// Add a collection NSID to the subscription options. 48 47 /// 49 - /// Returns an error if the maximum number of subscribed collections has been reached; `Ok(true)` 50 - /// if the collection was newly added to the set, or `Ok(false)` if the colletion was already in the 51 - /// the set. 48 + /// Returns an error if the maximum number of subscribed collections has 49 + /// been reached; `Ok(true)` if the collection was newly added to the 50 + /// set, or `Ok(false)` if the colletion was already in the the set. 52 51 /// 53 52 /// # Errors 54 53 /// 55 - /// Returns an error if adding `collection` would cause [`SubscriberOptions`] to exceed the maximum 56 - /// number of subscribed collections. 57 - /// 54 + /// Returns an error if adding `collection` would cause 55 + /// [`SubscriberOptions`] to exceed the maximum number of subscribed 56 + /// collections. 58 57 pub fn add_collection(&mut self, collection: Box<Nsid>) -> Result<bool, Box<Nsid>> { 59 58 if self.wanted_collections.len() == MAX_WANTED_COLLECTIONS 60 59 && !self.wanted_collections.contains(&collection) ··· 71 70 72 71 /// Add a DID to the subscription options. 73 72 /// 74 - /// Returns an error if the maximum number of subscribed DIDs has been reached; `Ok(true)` 75 - /// if the DID was newly added to the set, or `Ok(false)` if the DID was already in the 76 - /// the set. 73 + /// Returns an error if the maximum number of subscribed DIDs has been 74 + /// reached; `Ok(true)` if the DID was newly added to the set, or 75 + /// `Ok(false)` if the DID was already in the the set. 77 76 /// 78 77 /// # Errors 79 78 /// 80 - /// Returns an error if adding `did` would cause [`SubscriberOptions`] to exceed the maximum 81 - /// number of subscribed DIDs. 82 - /// 79 + /// Returns an error if adding `did` would cause [`SubscriberOptions`] to 80 + /// exceed the maximum number of subscribed DIDs. 83 81 pub fn add_did(&mut self, did: DidBuf) -> Result<bool, DidBuf> { 84 82 if self.wanted_dids.len() == MAX_WANTED_DIDS && !self.wanted_dids.contains(&did) { 85 83 return Err(did); ··· 97 97 normalize_max_message_size(self.max_message_size_bytes) 98 98 } 99 99 100 - /// Construct the Jetstream subscribe URL, returning a tuple of the URL and a boolean 101 - /// indicating whether the client should send an options update message on connect. 100 + /// Construct the Jetstream subscribe URL, returning a tuple of the URL and 101 + /// a boolean indicating whether the client should send an options 102 + /// update message on connect. 102 103 #[must_use] 103 104 pub fn subscribe_url(&self, url: &url::Url) -> (url::Url, bool) { 104 105 let mut url = url.to_owned(); ··· 136 135 (url, false) 137 136 } 138 137 139 - /// Present the `SubscriberOptions` as a [`SubscriberSourcedMessage`] for serialization. 138 + /// Present the `SubscriberOptions` as a [`SubscriberSourcedMessage`] for 139 + /// serialization. 140 140 #[must_use] 141 141 pub fn as_subscriber_sourced_message(&self) -> SubscriberSourcedMessage<'_> { 142 142 SubscriberSourcedMessage::OptionsUpdate(self.into()) ··· 170 168 } 171 169 172 170 mod max_message_size { 173 - use serde::{Deserialize, Deserializer, Serializer}; 171 + use serde::Deserialize; 172 + use serde::Deserializer; 173 + use serde::Serializer; 174 174 175 175 pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error> 176 176 where ··· 198 194 /// Subscriber sourced message. 199 195 /// 200 196 /// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#subscriber-sourced-messages> 201 - /// 202 197 #[derive(Debug, Serialize)] 203 198 #[serde(tag = "type", content = "payload", rename_all = "snake_case")] 204 199 pub enum SubscriberSourcedMessage<'a> { ··· 210 207 /// # Panics 211 208 /// 212 209 /// Panics if [`SubscriberSourcedMessage`] cannot be serialized as JSON. 213 - /// 214 210 #[must_use] 215 211 pub fn to_json(&self) -> String { 216 212 serde_json::to_string(self).expect("SubscriberSourcedMessage should be serializable") ··· 245 243 mod tests { 246 244 use std::collections::HashSet; 247 245 248 - use gordian_types::{Did, Nsid}; 246 + use gordian_types::Did; 247 + use gordian_types::Nsid; 249 248 250 249 use super::SubscriberOptions; 251 250
+19 -15
crates/gordian-jetstream/src/task.rs
··· 1 - use crate::{ 2 - client::ClientCommand, client_options::ClientOptions, metrics::Metrics, 3 - subscriber_options::SubscriberOptions, 4 - }; 1 + use std::pin::Pin; 2 + use std::sync::Arc; 3 + use std::sync::Mutex; 4 + use std::time::Duration; 5 + 5 6 use bytes::Bytes; 6 - use futures_util::{SinkExt, StreamExt}; 7 + use futures_util::SinkExt; 8 + use futures_util::StreamExt; 7 9 use serde::Deserialize; 8 - use std::{ 9 - pin::Pin, 10 - sync::{Arc, Mutex}, 11 - time::Duration, 12 - }; 13 10 use tokio::time::timeout; 14 - use tokio_tungstenite::{ 15 - connect_async, 16 - tungstenite::{ClientRequestBuilder, Error as TungsteniteError, Message, http::Uri}, 17 - }; 11 + use tokio_tungstenite::connect_async; 12 + use tokio_tungstenite::tungstenite::ClientRequestBuilder; 13 + use tokio_tungstenite::tungstenite::Error as TungsteniteError; 14 + use tokio_tungstenite::tungstenite::Message; 15 + use tokio_tungstenite::tungstenite::http::Uri; 18 16 use tokio_util::sync::CancellationToken; 19 17 use url::Url; 18 + 19 + use crate::client::ClientCommand; 20 + use crate::client_options::ClientOptions; 21 + use crate::metrics::Metrics; 22 + use crate::subscriber_options::SubscriberOptions; 20 23 21 24 #[cfg(feature = "zstd")] 22 25 const ZSTD_DICTIONARY: &[u8] = include_bytes!("dictionary"); ··· 281 278 } 282 279 #[cfg(feature = "zstd")] 283 280 Message::Binary(compressed_payload) => { 284 - use bytes::Buf as _; 285 281 use std::io::Read as _; 282 + 283 + use bytes::Buf as _; 286 284 287 285 let compressed_bytes = compressed_payload.len(); 288 286 let mut payload = Vec::with_capacity(compressed_payload.len());
+6 -1
crates/gordian-knot/Cargo.toml
··· 24 24 tracing.workspace = true 25 25 url.workspace = true 26 26 27 - aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] } 27 + aws-lc-rs.workspace = true 28 28 axum = { workspace = true, features = ["ws"] } 29 29 axum-extra = { version = "0.12.1", features = ["async-read-body"] } 30 30 bytes = "1.10.1" ··· 49 49 tower-http = { version = "0.6.6", features = ["decompression-gzip", "request-id", "trace", "tracing", "util"] } 50 50 tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } 51 51 clap_complete = "4.5.65" 52 + maud = { version = "0.27.0", features = ["axum"] } 53 + exn.workspace = true 54 + 55 + [build-dependencies] 56 + anyhow.workspace = true 52 57 53 58 [dev-dependencies] 54 59 gordian-pds = { workspace = true }
+40
crates/gordian-knot/build.rs
··· 1 + use std::env; 2 + use std::error::Error; 3 + use std::path::Path; 4 + use std::process; 5 + 6 + use anyhow::Context as _; 7 + 8 + fn main() -> Result<(), Box<dyn Error>> { 9 + println!("cargo::rerun-if-changed=build.rs"); 10 + build_tailwind()?; 11 + Ok(()) 12 + } 13 + 14 + fn build_tailwind() -> Result<(), Box<dyn Error>> { 15 + println!("cargo::rerun-if-changed=src/public"); 16 + println!("cargo::rerun-if-changed=tailwind.css"); 17 + 18 + let output = Path::new(&env::var("OUT_DIR")?).join("style.css"); 19 + 20 + assert!( 21 + process::Command::new("npx") 22 + .args([ 23 + "@tailwindcss/cli", 24 + // "--minify", 25 + "--input", 26 + "tailwind.css", 27 + "--output" 28 + ]) 29 + .arg(&output) 30 + .spawn() 31 + .context("Failed to spawn npx")? 32 + .wait() 33 + .context("Failed to run npx")? 34 + .success() 35 + ); 36 + 37 + println!("cargo::rustc-env=TAILWIND_STYLESHEET={}", output.display()); 38 + 39 + Ok(()) 40 + }
+5
crates/gordian-knot/package.json
··· 1 + { 2 + "dependencies": { 3 + "@tailwindcss/cli": "^4.1.18" 4 + } 5 + }
+2 -1
crates/gordian-knot/src/cli.rs
··· 2 2 pub mod hook; 3 3 pub mod serve; 4 4 5 - use clap::{Parser, Subcommand}; 5 + use clap::Parser; 6 + use clap::Subcommand; 6 7 7 8 pub trait RunCommand { 8 9 type Error;
+2 -1
crates/gordian-knot/src/cli/generate.rs
··· 1 - use clap::{Args, CommandFactory as _}; 1 + use clap::Args; 2 + use clap::CommandFactory as _; 2 3 use clap_complete::Shell; 3 4 4 5 /// Generate shell completions.
+8 -7
crates/gordian-knot/src/cli/hook.rs
··· 1 1 use core::fmt; 2 - use std::{ 3 - collections::HashMap, 4 - env, 5 - io::{self, Write as _}, 6 - path, 7 - }; 2 + use std::collections::HashMap; 3 + use std::env; 4 + use std::io::Write as _; 5 + use std::io::{self}; 6 + use std::path; 8 7 9 - use axum::http::{HeaderMap, HeaderName, HeaderValue}; 8 + use axum::http::HeaderMap; 9 + use axum::http::HeaderName; 10 + use axum::http::HeaderValue; 10 11 use bytes::Bytes; 11 12 use gordian_types::DidBuf; 12 13 use reqwest::header::InvalidHeaderName;
+33 -20
crates/gordian-knot/src/cli/serve.rs
··· 1 - use std::{env, ffi, io, net::ToSocketAddrs as _, path, time::Duration}; 1 + use std::env; 2 + use std::ffi; 3 + use std::io; 4 + use std::net::ToSocketAddrs as _; 5 + use std::path; 6 + use std::time::Duration; 2 7 3 8 use anyhow::Context as _; 4 - use axum::http::{Request, Response}; 5 - use clap::{ArgAction, Args, ValueEnum, ValueHint}; 9 + use axum::http::Request; 10 + use axum::http::Response; 11 + use clap::ArgAction; 12 + use clap::Args; 13 + use clap::ValueEnum; 14 + use clap::ValueHint; 6 15 use futures_util::FutureExt as _; 7 16 use gix::bstr::BString; 8 17 use gordian_identity::HttpClient; 9 - use gordian_knot::{ 10 - model::{ 11 - Knot, KnotState, 12 - config::{self, KnotConfiguration}, 13 - }, 14 - services::database::DataStore, 15 - }; 18 + use gordian_knot::model::Knot; 19 + use gordian_knot::model::KnotState; 20 + use gordian_knot::model::config::KnotConfiguration; 21 + use gordian_knot::model::config::{self}; 22 + use gordian_knot::services::database::DataStore; 16 23 use gordian_types::DidBuf; 17 - use tokio::{net::TcpListener, signal, task::JoinSet}; 24 + use tokio::net::TcpListener; 25 + use tokio::signal; 26 + use tokio::task::JoinSet; 18 27 use tokio_util::sync::CancellationToken; 19 28 use tower::ServiceBuilder; 20 - use tower_http::{ 21 - ServiceBuilderExt as _, 22 - decompression::RequestDecompressionLayer, 23 - request_id::{MakeRequestUuid, RequestId}, 24 - trace::{MakeSpan, OnResponse, TraceLayer}, 25 - }; 26 - use tracing::{Span, field::Empty}; 29 + use tower_http::ServiceBuilderExt as _; 30 + use tower_http::decompression::RequestDecompressionLayer; 31 + use tower_http::request_id::MakeRequestUuid; 32 + use tower_http::request_id::RequestId; 33 + use tower_http::trace::MakeSpan; 34 + use tower_http::trace::OnResponse; 35 + use tower_http::trace::TraceLayer; 36 + use tracing::Span; 37 + use tracing::field::Empty; 27 38 use url::Url; 28 39 29 40 const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); ··· 89 78 #[arg(default_value = "service-auth,public-key")] 90 79 pub auth_methods: Vec<AuthenticationMethods>, 91 80 92 - /// Require git pushes to be signed by a public key from a 'sh.tangled.publicKey'. 81 + /// Require git pushes to be signed by a public key from a 82 + /// 'sh.tangled.publicKey'. 93 83 /// 94 84 /// See: <https://git-scm.com/docs/git-push#Documentation/git-push.txt---signed> 95 85 #[arg(long, action = ArgAction::Set, require_equals = true)] ··· 249 237 } 250 238 251 239 let database = { 252 - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 240 + use sqlx::sqlite::SqliteConnectOptions; 241 + use sqlx::sqlite::SqlitePoolOptions; 253 242 254 243 let pool = { 255 244 let connect_options = SqliteConnectOptions::new()
+10 -8
crates/gordian-knot/src/extractors.rs
··· 1 1 mod git_protocol; 2 - pub use git_protocol::{GitProtocol, GitProtocolRejection}; 3 - 4 2 use core::fmt; 5 3 6 - use axum::{ 7 - extract::{FromRequestParts, OptionalFromRequestParts}, 8 - http::{HeaderMap, StatusCode}, 9 - response::IntoResponse, 10 - }; 4 + use axum::extract::FromRequestParts; 5 + use axum::extract::OptionalFromRequestParts; 6 + use axum::http::HeaderMap; 7 + use axum::http::StatusCode; 8 + use axum::response::IntoResponse; 9 + pub use git_protocol::GitProtocol; 10 + pub use git_protocol::GitProtocolRejection; 11 11 use reqwest::header::IF_NONE_MATCH; 12 12 13 13 pub mod request_id { 14 14 use std::ffi::OsStr; 15 15 16 - use axum::{extract::OptionalFromRequestParts, http::StatusCode, response::IntoResponse}; 16 + use axum::extract::OptionalFromRequestParts; 17 + use axum::http::StatusCode; 18 + use axum::response::IntoResponse; 17 19 18 20 pub struct RequestId(pub String); 19 21
+3 -1
crates/gordian-knot/src/extractors/git_protocol.rs
··· 1 1 use std::ops; 2 2 3 - use axum::{extract::OptionalFromRequestParts, http::request::Parts, response::IntoResponse}; 3 + use axum::extract::OptionalFromRequestParts; 4 + use axum::http::request::Parts; 5 + use axum::response::IntoResponse; 4 6 use reqwest::header::ToStrError; 5 7 6 8 /// Extract the "Git-Protocol" header from a request.
+6 -3
crates/gordian-knot/src/hooks.rs
··· 1 - use std::{env, fs, io, path}; 1 + use std::env; 2 + use std::fs; 3 + use std::io; 4 + use std::path; 2 5 3 6 use crate::cli::hook::HookName; 4 7 5 - /// Install the knot-global git-hooks in `path`. If `path` does not exist it will be created. 8 + /// Install the knot-global git-hooks in `path`. If `path` does not exist it 9 + /// will be created. 6 10 /// 7 11 /// # Panics 8 12 /// 9 13 /// Panics if the path to the currently running executable is not utf8. 10 - /// 11 14 #[cfg(unix)] 12 15 pub fn install_global_hooks<P: AsRef<path::Path>>(path: P) -> io::Result<()> { 13 16 use std::os::unix::fs::PermissionsExt as _;
+1 -2
crates/gordian-knot/src/lib.rs
··· 7 7 pub mod services; 8 8 pub mod sync; 9 9 pub mod types; 10 + pub mod ui; 10 11 11 12 #[cfg(test)] 12 13 pub(crate) mod mock; 13 14 #[cfg(test)] 14 15 mod tests; 15 - 16 - mod macros; 17 16 18 17 pub use gordian_lexicon as lexicon; 19 18 pub use model::Knot;
-9
crates/gordian-knot/src/macros.rs
··· 1 - #[macro_export] 2 - macro_rules! release_or_debug { 3 - (const $val:ident: $t:ty = $rel:expr, $dbg:expr) => { 4 - #[cfg(not(debug_assertions))] 5 - const $val: $t = $rel; 6 - #[cfg(debug_assertions)] 7 - const $val: $t = $dbg; 8 - }; 9 - }
+3 -1
crates/gordian-knot/src/main.rs
··· 2 2 mod hooks; 3 3 4 4 use tracing::level_filters::LevelFilter; 5 - use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _}; 5 + use tracing_subscriber::EnvFilter; 6 + use tracing_subscriber::layer::SubscriberExt as _; 7 + use tracing_subscriber::util::SubscriberInitExt as _; 6 8 7 9 #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] 8 10 #[global_allocator]
+4 -5
crates/gordian-knot/src/mock.rs
··· 1 - use crate::{ 2 - model::{Knot, config::KnotConfiguration}, 3 - services::database::DataStore, 4 - }; 5 1 use gordian_identity::Resolver; 2 + pub use gordian_pds::Pds; 6 3 use gordian_types::DidBuf; 7 4 8 - pub use gordian_pds::Pds; 5 + use crate::model::Knot; 6 + use crate::model::config::KnotConfiguration; 7 + use crate::services::database::DataStore; 9 8 10 9 pub async fn setup(owner_did: &str, instance_name: &str) -> (tempfile::TempDir, Pds, Knot) { 11 10 let base = tempfile::tempdir().expect("temporary directory");
+10 -11
crates/gordian-knot/src/model.rs
··· 6 6 pub mod repository; 7 7 8 8 use core::ops; 9 - use std::{borrow::Cow, net::SocketAddr, sync::Arc}; 9 + use std::borrow::Cow; 10 + use std::net::SocketAddr; 11 + use std::sync::Arc; 10 12 11 13 use axum::extract::FromRef; 12 14 use futures_util::future::BoxFuture; 13 15 use gordian_auth::jwt; 14 - use gordian_identity::{HttpClient, Resolver}; 16 + use gordian_identity::HttpClient; 17 + use gordian_identity::Resolver; 15 18 use gordian_lexicon::sh_tangled::knot::Member; 16 19 use gordian_types::Tid; 20 + pub use knot_state::KnotState; 17 21 use time::OffsetDateTime; 18 22 19 - use crate::{ 20 - model::config::KnotConfiguration, 21 - services::{ 22 - authorization::{AuthorizationClaimsStore, AuthorizationClaimsStoreError}, 23 - database::DataStore, 24 - }, 25 - }; 26 - 27 - pub use knot_state::KnotState; 23 + use crate::model::config::KnotConfiguration; 24 + use crate::services::authorization::AuthorizationClaimsStore; 25 + use crate::services::authorization::AuthorizationClaimsStoreError; 26 + use crate::services::database::DataStore; 28 27 29 28 #[derive(Debug, Clone)] 30 29 #[repr(transparent)]
+6 -5
crates/gordian-knot/src/model/config.rs
··· 1 1 //! Knot configuration. 2 + use std::path::Path; 3 + use std::path::PathBuf; 4 + use std::time::Duration; 5 + 2 6 use gix::bstr::BString; 3 - use gordian_types::{Did, DidBuf}; 7 + use gordian_types::Did; 8 + use gordian_types::DidBuf; 4 9 use rustc_hash::FxHashSet; 5 - use std::{ 6 - path::{Path, PathBuf}, 7 - time::Duration, 8 - }; 9 10 use url::Url; 10 11 11 12 pub const DEFAULT_READMES: &[&[u8]] = &[
+10 -4
crates/gordian-knot/src/model/convert.rs
··· 1 - use crate::{public::xrpc::XrpcError, types::sh_tangled::repo::tags}; 1 + use std::borrow::Cow; 2 + use std::collections::HashMap; 3 + 2 4 use data_encoding::BASE64URL; 3 5 use gix::bstr::ByteSlice; 4 - use gordian_lexicon::sh_tangled::repo::{refs, tree}; 6 + use gordian_lexicon::sh_tangled::repo::refs; 7 + use gordian_lexicon::sh_tangled::repo::tree; 5 8 use reqwest::StatusCode; 6 - use std::{borrow::Cow, collections::HashMap}; 7 - use time::{OffsetDateTime, error::ComponentRange}; 9 + use time::OffsetDateTime; 10 + use time::error::ComponentRange; 11 + 12 + use crate::public::xrpc::XrpcError; 13 + use crate::types::sh_tangled::repo::tags; 8 14 9 15 #[derive(Debug, thiserror::Error)] 10 16 pub enum ConversionError {
+47 -41
crates/gordian-knot/src/model/knot_state.rs
··· 1 - use std::{ 2 - collections::HashMap, 3 - io::{self, ErrorKind}, 4 - net::SocketAddr, 5 - ops, 6 - path::PathBuf, 7 - process::Stdio, 8 - sync::{Arc, Mutex}, 9 - time::Duration, 10 - }; 1 + use std::collections::HashMap; 2 + use std::io::ErrorKind; 3 + use std::io::{self}; 4 + use std::net::SocketAddr; 5 + use std::ops; 6 + use std::path::PathBuf; 7 + use std::process::Stdio; 8 + use std::sync::Arc; 9 + use std::sync::Mutex; 10 + use std::time::Duration; 11 11 12 - use futures_util::{FutureExt, future::BoxFuture}; 12 + use futures_util::FutureExt; 13 + use futures_util::future::BoxFuture; 13 14 use gordian_auth::jwt; 14 - use gordian_identity::{HttpClient, Resolver}; 15 - use gordian_lexicon::{ 16 - com::atproto::repo::list_records::Record, 17 - sh_tangled::{git::RefUpdate, repo::Repo}, 18 - }; 19 - use gordian_types::{Did, aturi::AtUri}; 20 - use moka::future::{Cache, CacheBuilder}; 21 - use rayon::{ThreadPool, ThreadPoolBuilder}; 15 + use gordian_identity::HttpClient; 16 + use gordian_identity::Resolver; 17 + use gordian_lexicon::com::atproto::repo::list_records::Record; 18 + use gordian_lexicon::sh_tangled::git::RefUpdate; 19 + use gordian_lexicon::sh_tangled::repo::Repo; 20 + use gordian_types::Did; 21 + use gordian_types::aturi::AtUri; 22 + use moka::future::Cache; 23 + use moka::future::CacheBuilder; 24 + use rayon::ThreadPool; 25 + use rayon::ThreadPoolBuilder; 22 26 use serde::Serialize; 23 27 use time::OffsetDateTime; 24 28 use tokio::process::Command; 25 29 use url::Url; 26 30 27 - use crate::{ 28 - release_or_debug, 29 - services::{ 30 - atrepo, 31 - authorization::{AuthorizationClaimsStore, AuthorizationClaimsStoreError}, 32 - database::{DataStore, DataStoreError}, 33 - }, 34 - types::{ 35 - RecordKey, 36 - repository_key::RepositoryKey, 37 - repository_path::{self, RepositoryPath}, 38 - }, 39 - }; 40 - 41 31 use super::config::KnotConfiguration; 32 + use crate::services::atrepo; 33 + use crate::services::authorization::AuthorizationClaimsStore; 34 + use crate::services::authorization::AuthorizationClaimsStoreError; 35 + use crate::services::database::DataStore; 36 + use crate::services::database::DataStoreError; 37 + use crate::types::RecordKey; 38 + use crate::types::repository_key::RepositoryKey; 39 + use crate::types::repository_path::RepositoryPath; 40 + use crate::types::repository_path::{self}; 42 41 43 42 /// Default number of ({handle,did},{name,rkey}) -> (did,rkey) mappings to 44 43 /// keep in cache. ··· 66 67 resolver: Resolver, 67 68 68 69 /// Reqwest client. 69 - /// 70 70 // @TODO Wrap this so prevent requests to sensitive/private endpoints. 71 71 http: HttpClient, 72 72 ··· 182 184 ) 183 185 } 184 186 185 - /// Resolve a repository path ({handle,did},{rkey,name}) to a repository key (did, rkey). 186 - /// 187 + /// Resolve a repository path ({handle,did},{rkey,name}) to a repository key 188 + /// (did, rkey). 187 189 pub async fn resolve_repo_key( 188 190 &self, 189 191 repo_path: &RepositoryPath, ··· 249 251 } 250 252 251 253 pub async fn can_push(&self, repo: &RepositoryKey, did: &Did) -> bool { 252 - use crate::services::rbac::{Action, Policy, PolicyResult, RepositoryPushPolicy}; 254 + use crate::services::rbac::Action; 255 + use crate::services::rbac::Policy; 256 + use crate::services::rbac::PolicyResult; 257 + use crate::services::rbac::RepositoryPushPolicy; 253 258 let policy = RepositoryPushPolicy; 254 259 let result = policy 255 260 .evaluate_access(&did, &Action::RepositoryPush, repo, self) ··· 278 277 279 278 // We're going to receive the jetstream event and the xrpc request. 280 279 // 281 - // If the other is already in progress, wait here. The database insert should return 282 - // Ok(false), and repository creation will be skipped. 280 + // If the other is already in progress, wait here. The database insert should 281 + // return Ok(false), and repository creation will be skipped. 283 282 let _guard = self.get_repo_mutex(&repo_key).lock_owned().await; 284 283 285 284 let mut tx = self.database().begin().await?; ··· 348 347 source: &str, 349 348 ) -> anyhow::Result<()> { 350 349 // Release build: only clone over https; Debug builds: try https then http. 351 - release_or_debug!(const CLONE_SCHEMES: &[&str] = &["https"], &["https", "http"]); 350 + const CLONE_SCHEMES: &[&str] = if cfg!(debug_assertions) { 351 + &["https", "http"] 352 + } else { 353 + &["https"] 354 + }; 352 355 353 356 let path = self.path_for_repository(repo_key); 354 357 tracing::debug!(?path, "forking into"); ··· 447 442 Ok(()) 448 443 } 449 444 450 - /// Get or generate a new nonce seed for signed pushes to the specified repository. 445 + /// Get or generate a new nonce seed for signed pushes to the specified 446 + /// repository. 451 447 pub fn generate_push_seed(&self, repository: &RepositoryKey) -> Box<str> { 452 448 const PUSH_SEED_NONCE_LEN: usize = 16; 453 449
+17 -14
crates/gordian-knot/src/model/nicediff.rs
··· 1 1 use std::borrow::Cow; 2 2 3 - use crate::types::sh_tangled::repo::diff::{ 4 - Commit, Diff, Line, Name, NiceDiff, Stat, TextFragment, 5 - }; 6 - use gix::{ 7 - Repository, 8 - diff::blob::{ 9 - Algorithm, UnifiedDiff, 10 - pipeline::{Mode, WorktreeRoots}, 11 - unified_diff::{ConsumeHunk, ContextSize, DiffLineKind}, 12 - }, 13 - object::tree::diff::Action, 14 - }; 3 + use gix::Repository; 4 + use gix::diff::blob::Algorithm; 5 + use gix::diff::blob::UnifiedDiff; 6 + use gix::diff::blob::pipeline::Mode; 7 + use gix::diff::blob::pipeline::WorktreeRoots; 8 + use gix::diff::blob::unified_diff::ConsumeHunk; 9 + use gix::diff::blob::unified_diff::ContextSize; 10 + use gix::diff::blob::unified_diff::DiffLineKind; 11 + use gix::object::tree::diff::Action; 12 + 13 + use crate::types::sh_tangled::repo::diff::Commit; 14 + use crate::types::sh_tangled::repo::diff::Diff; 15 + use crate::types::sh_tangled::repo::diff::Line; 16 + use crate::types::sh_tangled::repo::diff::Name; 17 + use crate::types::sh_tangled::repo::diff::NiceDiff; 18 + use crate::types::sh_tangled::repo::diff::Stat; 19 + use crate::types::sh_tangled::repo::diff::TextFragment; 15 20 16 21 #[derive(Debug, thiserror::Error)] 17 22 enum Error { ··· 110 105 } 111 106 112 107 /// Produce a unified diff from the first parent of the specified commit. 113 - /// 114 108 pub fn unified_diff_from_parent(commit: gix::Commit<'_>) -> anyhow::Result<NiceDiff> { 115 109 let current_tree = commit.tree()?; 116 110 let parent_tree = match commit.parent_ids().next() { ··· 146 142 } 147 143 148 144 /// Produce a unified diff between two trees. 149 - /// 150 145 pub fn unified_diff<'r>( 151 146 repo: &'r Repository, 152 147 this_tree: &gix::Tree<'r>,
+51 -40
crates/gordian-knot/src/model/repository.rs
··· 1 1 mod merge_check; 2 2 3 - use core::{error, fmt}; 4 - use std::{ 5 - collections::{BTreeMap, HashSet, VecDeque}, 6 - io, ops, 7 - path::{Path, PathBuf}, 8 - process::{Command, Stdio}, 9 - }; 3 + use core::error; 4 + use core::fmt; 5 + use std::collections::BTreeMap; 6 + use std::collections::HashSet; 7 + use std::collections::VecDeque; 8 + use std::io; 9 + use std::ops; 10 + use std::path::Path; 11 + use std::path::PathBuf; 12 + use std::process::Command; 13 + use std::process::Stdio; 10 14 11 - use axum::{ 12 - Json, 13 - extract::{FromRef, FromRequestParts}, 14 - }; 15 - use gix::{ 16 - ObjectId, 17 - bstr::{BString, ByteSlice}, 18 - submodule::config::Branch, 19 - }; 20 - use gordian_lexicon::sh_tangled::repo::{ 21 - blob::Submodule, branch, get_default_branch, languages, refs, tree, 22 - }; 15 + use axum::Json; 16 + use axum::extract::FromRef; 17 + use axum::extract::FromRequestParts; 18 + use gix::ObjectId; 19 + use gix::bstr::BString; 20 + use gix::bstr::ByteSlice; 21 + use gix::submodule::config::Branch; 22 + use gordian_lexicon::sh_tangled::repo::blob::Submodule; 23 + use gordian_lexicon::sh_tangled::repo::branch; 24 + use gordian_lexicon::sh_tangled::repo::get_default_branch; 25 + use gordian_lexicon::sh_tangled::repo::languages; 26 + use gordian_lexicon::sh_tangled::repo::refs; 27 + use gordian_lexicon::sh_tangled::repo::tree; 23 28 use rustc_hash::FxHashSet; 24 29 use serde::Deserialize; 25 30 26 - use crate::{ 27 - command::{SetOptionArg as _, SetOptionEnv as _}, 28 - model::{convert, errors, nicediff}, 29 - public::xrpc::{XrpcError, XrpcQuery, XrpcResponse, XrpcResult}, 30 - types::{ 31 - repository_key::RepositoryKey, 32 - repository_path::RepositoryPath, 33 - sh_tangled::repo::{branches, compare, diff, log, tags}, 34 - }, 35 - }; 36 - 37 31 use super::Knot; 32 + use crate::command::SetOptionArg as _; 33 + use crate::command::SetOptionEnv as _; 34 + use crate::model::convert; 35 + use crate::model::errors; 36 + use crate::model::nicediff; 37 + use crate::public::xrpc::XrpcError; 38 + use crate::public::xrpc::XrpcQuery; 39 + use crate::public::xrpc::XrpcResponse; 40 + use crate::public::xrpc::XrpcResult; 41 + use crate::types::repository_key::RepositoryKey; 42 + use crate::types::repository_path::RepositoryPath; 43 + use crate::types::sh_tangled::repo::branches; 44 + use crate::types::sh_tangled::repo::compare; 45 + use crate::types::sh_tangled::repo::diff; 46 + use crate::types::sh_tangled::repo::log; 47 + use crate::types::sh_tangled::repo::tags; 38 48 39 49 #[derive(Debug)] 40 50 pub struct TangledRepository { ··· 548 538 where 549 539 Knot: axum::extract::FromRef<S>, 550 540 { 551 - use crate::public::git::NotFound; 552 541 use axum::extract::Path; 542 + 543 + use crate::public::git::NotFound; 553 544 554 545 let knot = Knot::from_ref(state); 555 546 let Path(repo_path) = Path::<RepositoryPath>::from_request_parts(parts, &()).await?; ··· 571 560 } 572 561 573 562 impl TangledRepository { 574 - /// Initialise a [`Command`] for running git with the appropriate environment and working 575 - /// directory for the repository. 576 - /// 563 + /// Initialise a [`Command`] for running git with the appropriate 564 + /// environment and working directory for the repository. 577 565 pub fn git(&self) -> Command { 578 - use crate::private::{ENV_PRIVATE_ENDPOINTS, ENV_REPO_DID, ENV_REPO_RKEY}; 566 + use crate::private::ENV_PRIVATE_ENDPOINTS; 567 + use crate::private::ENV_REPO_DID; 568 + use crate::private::ENV_REPO_RKEY; 579 569 580 570 let mut command = Command::new("/usr/bin/git"); 581 571 command ··· 591 579 } 592 580 } 593 581 594 - /// A temporary detached worktree generated with a randomised name, and deleted when 595 - /// the object is dropped. 582 + /// A temporary detached worktree generated with a randomised name, and deleted 583 + /// when the object is dropped. 596 584 /// 597 585 #[derive(Debug)] 598 586 struct TempWorktree<'repo> { ··· 684 672 /// 685 673 /// # Errors 686 674 /// 687 - /// Returns an error if the git subprocess could not be spawned or exits with a non-zero 688 - /// exit code. 675 + /// Returns an error if the git subprocess could not be spawned or exits 676 + /// with a non-zero exit code. 689 677 /// 690 678 /// # Panics 691 679 /// 692 680 /// Panics if `repo` is not a bare repository. 693 - /// 694 681 fn build<'repo>(&self, repo: &'repo gix::Repository) -> io::Result<TempWorktree<'repo>> { 695 682 assert!(repo.is_bare(), "repository should be bare"); 696 683
+11 -9
crates/gordian-knot/src/model/repository/merge_check.rs
··· 1 - use std::{borrow::Cow, io::Write as _, process::Stdio}; 1 + use std::borrow::Cow; 2 + use std::io::Write as _; 3 + use std::process::Stdio; 2 4 3 5 use axum::Json; 4 - use gordian_lexicon::sh_tangled::repo::merge_check::{ConflictInfo, Output}; 6 + use gordian_lexicon::sh_tangled::repo::merge_check::ConflictInfo; 7 + use gordian_lexicon::sh_tangled::repo::merge_check::Output; 5 8 6 - use crate::{ 7 - model::{ 8 - errors, 9 - repository::{ResolveRevspec as _, ResolvedRevspec, TempWorktree}, 10 - }, 11 - public::xrpc::{XrpcError, XrpcResponse}, 12 - }; 9 + use crate::model::errors; 10 + use crate::model::repository::ResolveRevspec as _; 11 + use crate::model::repository::ResolvedRevspec; 12 + use crate::model::repository::TempWorktree; 13 + use crate::public::xrpc::XrpcError; 14 + use crate::public::xrpc::XrpcResponse; 13 15 14 16 impl super::TangledRepository { 15 17 pub fn merge_check(
+7
crates/gordian-knot/src/nsid.rs
··· 1 + //! NSIDs relevant to knot 2 + 1 3 use gordian_types::Nsid; 2 4 3 5 macro_rules! nsid { ··· 8 6 }; 9 7 } 10 8 9 + // Records 10 + // 11 11 pub const SH_TANGLED_KNOT_MEMBER: &Nsid = nsid!("sh.tangled.knot.member"); 12 12 pub const SH_TANGLED_PUBLICKEY: &Nsid = nsid!("sh.tangled.publicKey"); 13 13 pub const SH_TANGLED_REPO: &Nsid = nsid!("sh.tangled.repo"); 14 14 pub const SH_TANGLED_REPO_COLLABORATOR: &Nsid = nsid!("sh.tangled.repo.collaborator"); 15 + 16 + // Lexicon Methods 17 + // 15 18 pub const SH_TANGLED_REPO_CREATE: &Nsid = nsid!("sh.tangled.repo.create"); 16 19 pub const SH_TANGLED_REPO_DELETE: &Nsid = nsid!("sh.tangled.repo.delete"); 17 20 pub const SH_TANGLED_REPO_GITRECEIVEPACK: &Nsid = nsid!("sh.tangled.repo.gitReceivePack");
+41 -29
crates/gordian-knot/src/private.rs
··· 1 1 use core::fmt; 2 - use std::{borrow::Cow, io, process::Stdio, sync::Arc}; 2 + use std::borrow::Cow; 3 + use std::io; 4 + use std::process::Stdio; 5 + use std::sync::Arc; 3 6 4 - use axum::{ 5 - extract::{FromRequestParts, Path, State}, 6 - http::{HeaderMap, StatusCode, request::Parts}, 7 - response::IntoResponse, 8 - }; 9 - use gordian_lexicon::sh_tangled::git::{ 10 - CommitCount, CommitCountBreakdown, Language, LanguageBreakdown, Meta, RefUpdate, 11 - }; 7 + use axum::extract::FromRequestParts; 8 + use axum::extract::Path; 9 + use axum::extract::State; 10 + use axum::http::HeaderMap; 11 + use axum::http::StatusCode; 12 + use axum::http::request::Parts; 13 + use axum::response::IntoResponse; 14 + use gordian_lexicon::sh_tangled::git::CommitCount; 15 + use gordian_lexicon::sh_tangled::git::CommitCountBreakdown; 16 + use gordian_lexicon::sh_tangled::git::Language; 17 + use gordian_lexicon::sh_tangled::git::LanguageBreakdown; 18 + use gordian_lexicon::sh_tangled::git::Meta; 19 + use gordian_lexicon::sh_tangled::git::RefUpdate; 12 20 use gordian_types::DidBuf; 13 - use serde::{Deserialize, Serialize}; 21 + use serde::Deserialize; 22 + use serde::Serialize; 14 23 use time::OffsetDateTime; 15 24 use tokio_rayon::AsyncThreadPool as _; 16 25 17 - use crate::{ 18 - model::{ 19 - Knot, errors, 20 - knot_state::Event, 21 - repository::{RepositoryStatsExt as _, TangledRepository}, 22 - }, 23 - public::xrpc::XrpcError, 24 - types::{push_certificate::PushCertificate, repository_key::RepositoryKey}, 25 - }; 26 + use crate::model::Knot; 27 + use crate::model::errors; 28 + use crate::model::knot_state::Event; 29 + use crate::model::repository::RepositoryStatsExt as _; 30 + use crate::model::repository::TangledRepository; 31 + use crate::public::xrpc::XrpcError; 32 + use crate::types::push_certificate::PushCertificate; 33 + use crate::types::repository_key::RepositoryKey; 26 34 27 - /// Environment variable containing one or more whitespace separated URLs for the internal API. 35 + /// Environment variable containing one or more whitespace separated URLs for 36 + /// the internal API. 28 37 /// 29 - /// By default, knot will serve the internal API on all the addresses resolved from `localhost` 30 - /// bound to a OS assigned port. 38 + /// By default, knot will serve the internal API on all the addresses resolved 39 + /// from `localhost` bound to a OS assigned port. 31 40 /// 32 41 /// # Example 33 42 /// 34 43 /// `"http://[::1]:44269/ http://127.0.0.1:36413/"` 35 - /// 36 44 pub const ENV_PRIVATE_ENDPOINTS: &str = "GORDIAN_PRIVATE_ENDPOINTS"; 37 45 38 - /// Environment variable containing the DID of the account that triggered the hook. 46 + /// Environment variable containing the DID of the account that triggered the 47 + /// hook. 39 48 pub const ENV_USER_DID: &str = "GORDIAN_USER_DID"; 40 49 41 - /// Environment variable containing the DID that owns the repository the hook has be triggered on. 50 + /// Environment variable containing the DID that owns the repository the hook 51 + /// has be triggered on. 42 52 pub const ENV_REPO_DID: &str = "GORDIAN_REPO_DID"; 43 53 44 - /// Environment variable containing the rkey of the repository the hook has be triggered on. 54 + /// Environment variable containing the rkey of the repository the hook has be 55 + /// triggered on. 45 56 pub const ENV_REPO_RKEY: &str = "GORDIAN_REPO_RKEY"; 46 57 47 - /// Prefix to add when converting an environment variable from the hook to a HTTP header. 58 + /// Prefix to add when converting an environment variable from the hook to a 59 + /// HTTP header. 48 60 pub const ENV_HEADER_PREFIX: &str = "X-Gordian"; 49 61 50 62 /// Build a new router for the internal API. ··· 198 186 ) -> Result<impl IntoResponse, XrpcError> { 199 187 let repo_key = RepositoryKey { owner, rkey }; 200 188 201 - // Our hook refers to the repository using DID and rkey, but Tangled needs DID and name, so we 202 - // need to lookup the repository's name in the database. 189 + // Our hook refers to the repository using DID and rkey, but Tangled needs DID 190 + // and name, so we need to lookup the repository's name in the database. 203 191 let (_, repo_name) = knot 204 192 .database() 205 193 .resolve_repository(&repo_key.owner, repo_key.rkey())
+76 -4
crates/gordian-knot/src/public.rs
··· 1 1 //! Public API for the knot server. 2 - //! 2 + 3 + use axum::extract::State; 4 + 5 + pub mod authorize; 3 6 pub mod events; 4 7 pub mod git; 5 8 pub mod xrpc; 6 9 7 - use crate::model::Knot; 10 + pub fn router() -> axum::Router<crate::Knot> { 11 + use axum::routing::get; 8 12 9 - pub fn router() -> axum::Router<Knot> { 10 13 axum::Router::new() 11 14 .without_v07_checks() 12 15 .nest("/xrpc", xrpc::router()) 13 16 .nest("/{owner}/{name}", git::router()) 14 - .route("/events", axum::routing::get(events::handler)) 17 + .route("/", get(index)) 18 + .route("/gordian.css", get(stylesheet)) 19 + .route("/events", get(events::handler)) 20 + .merge(authorize::router()) 21 + } 22 + 23 + async fn index(State(knot): State<crate::Knot>) -> maud::Markup { 24 + layout( 25 + maud::html! { title { "The Gordian Knot (server)" } }, 26 + maud::html! { 27 + div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" { 28 + (maud::PreEscaped("<!-- todo -->")) 29 + } 30 + }, 31 + foot(&knot), 32 + ) 33 + } 34 + 35 + async fn stylesheet() -> impl axum::response::IntoResponse { 36 + use axum::http::header::CONTENT_TYPE; 37 + 38 + ( 39 + [(CONTENT_TYPE, "text/css")], 40 + include_str!(env!("TAILWIND_STYLESHEET")), 41 + ) 42 + } 43 + 44 + fn layout(head: maud::Markup, body: maud::Markup, foot: maud::Markup) -> maud::Markup { 45 + maud::html! { 46 + (maud::DOCTYPE) 47 + html class="h-full bg-slate-100 dark:bg-slate-900" { 48 + head { 49 + meta 50 + charset="UTF-8"; 51 + meta 52 + name="viewport" 53 + content="width=device-width, initial-scale=1.0" ; 54 + script 55 + src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" 56 + integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" 57 + crossorigin="anonymous" { } 58 + link 59 + rel="stylesheet" 60 + href="/gordian.css" ; 61 + (head) 62 + } 63 + body class="h-full flex flex-col" { 64 + main class="flex-grow" { 65 + (body) 66 + } 67 + footer { 68 + (foot) 69 + } 70 + } 71 + } 72 + } 73 + } 74 + 75 + fn foot(knot: &crate::Knot) -> maud::Markup { 76 + maud::html! { 77 + div class="flex" { 78 + div class="flex-grow" { } 79 + div class="mt-3 p-3 text-sm text-slate-500" { 80 + "at://" 81 + a href={"https://tangled.org/" (knot.owner()) } { (knot.owner()) } 82 + "/sh.tangled.knot/" 83 + a href=(knot.base) class="knot-ident" { (knot.instance_ident()) } 84 + } 85 + } 86 + } 15 87 }
+757
crates/gordian-knot/src/public/authorize.rs
··· 1 + use std::collections::HashMap; 2 + use std::sync::Arc; 3 + use std::sync::Mutex; 4 + use std::sync::OnceLock; 5 + use std::time::Duration; 6 + 7 + use aws_lc_rs::digest::SHA256; 8 + use aws_lc_rs::signature::EcdsaKeyPair; 9 + use axum::Form; 10 + use axum::Json; 11 + use axum::extract::Query; 12 + use axum::extract::State; 13 + use axum::extract::WebSocketUpgrade; 14 + use axum::extract::ws::Message; 15 + use axum::extract::ws::WebSocket; 16 + use axum::http::HeaderMap; 17 + use axum::http::HeaderValue; 18 + use axum::http::StatusCode; 19 + use axum::http::header; 20 + use axum::response::IntoResponse; 21 + use axum::response::Response; 22 + use futures_util::SinkExt; 23 + use gordian_auth::IntoVerificationKey as _; 24 + use gordian_auth::MultibaseKey; 25 + use gordian_auth::client::Client; 26 + use gordian_auth::client::PushedAuthorization; 27 + use gordian_auth::jwk::JsonWebKeySet; 28 + use gordian_auth::resources::ClientMetadata; 29 + use gordian_auth::types::Callback; 30 + use gordian_auth::types::CallbackError; 31 + use gordian_auth::types::CallbackSuccess; 32 + use gordian_auth::types::GrantType; 33 + use gordian_auth::types::ResponseType; 34 + use gordian_auth::types::SigningAlgorithm; 35 + use gordian_auth::types::TokenEndpointAuthMethod; 36 + use gordian_types::DidBuf; 37 + use serde::Deserialize; 38 + use tokio::sync::broadcast; 39 + use url::Url; 40 + 41 + use crate::Knot; 42 + use crate::types::repository_key::RepositoryKey; 43 + 44 + const WELL_KNOWN_CLIENT_METADATA: &str = "/oauth-client-metadata.json"; 45 + const WELL_KNOWN_JWKS: &str = "/.well-known/jwks"; 46 + const WELL_KNOWN_KNOTSERVER: &str = "/.well-known/knotserver.json"; 47 + 48 + const PATH_AUTHORIZE: &str = "/oauth/login"; 49 + const PATH_AUTHORIZE_RESULT: &str = "/oauth/authorize-result"; 50 + const PATH_AUTHORIZE_EVENT: &str = "/oauth/authorize-event"; 51 + const PATH_AUTHORIZE_VALIDATE_REPO: &str = "/oauth/login/validate-repository"; 52 + const PATH_AUTHORIZE_VALIDATE_KEY: &str = "/oauth/login/validate-public-key"; 53 + 54 + /// Verification keys for the knot. Published at "/.well-known/jwks.json". 55 + /// 56 + /// For now these are generated on first use. 57 + static KNOT_KEY_PAIR: OnceLock<Arc<EcdsaKeyPair>> = OnceLock::new(); 58 + 59 + fn init_knot_key_pair() -> Arc<EcdsaKeyPair> { 60 + use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED_SIGNING; 61 + 62 + EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING) 63 + .expect("key-pair generation must succeed") 64 + .into() 65 + } 66 + 67 + enum AuthorizationRequestState { 68 + Pending(RepositoryKey, PushedAuthorization), 69 + Verifying, 70 + Complete, 71 + } 72 + 73 + /// Inflight OAuth requests. 74 + // @TODO Put these in the db! 75 + // 76 + static OAUTH_REQUESTS: OnceLock<Mutex<HashMap<Box<str>, AuthorizationRequestState>>> = 77 + OnceLock::new(); 78 + 79 + static AUTHORIZATIONS: OnceLock<Mutex<HashMap<Box<str>, broadcast::Sender<bool>>>> = 80 + OnceLock::new(); 81 + 82 + pub fn router() -> axum::Router<Knot> { 83 + use axum::routing::get; 84 + use axum::routing::post; 85 + 86 + axum::Router::new() 87 + .without_v07_checks() 88 + .route(WELL_KNOWN_CLIENT_METADATA, get(client_metadata)) 89 + .route(WELL_KNOWN_JWKS, get(jwks)) 90 + .route(WELL_KNOWN_KNOTSERVER, get(knotserver_meta)) 91 + .route(PATH_AUTHORIZE_RESULT, get(callback)) 92 + .route(PATH_AUTHORIZE, get(login).post(authorize_post)) 93 + .route(PATH_AUTHORIZE_EVENT, get(authorize_event)) 94 + .route(PATH_AUTHORIZE_VALIDATE_REPO, post(validate_repo_fragment)) 95 + .route(PATH_AUTHORIZE_VALIDATE_KEY, post(validate_key_fragment)) 96 + } 97 + 98 + async fn client_metadata(State(knot): State<Knot>) -> Json<ClientMetadata> { 99 + Json(raw_client_metadata(&knot)) 100 + } 101 + 102 + fn raw_client_metadata(knot: &Knot) -> ClientMetadata { 103 + ClientMetadata { 104 + redirect_uris: vec![redirect_uri(knot.base.clone())], 105 + grant_types: vec![GrantType::AuthorizationCode], 106 + token_endpoint_auth_method: Some(TokenEndpointAuthMethod::PrivateKeyJwt), 107 + token_endpoint_auth_signing_alg: Some(SigningAlgorithm::ES256), 108 + response_types: vec![ResponseType::Code], 109 + jwks_uri: Some(jwks_uri(knot.base.clone())), 110 + client_name: Some(knot.instance_ident().to_string()), 111 + client_uri: Some(knot.base.clone()), 112 + ..client_id(knot.base.clone()).into() 113 + } 114 + } 115 + 116 + async fn jwks() -> Json<JsonWebKeySet> { 117 + let key_pair = KNOT_KEY_PAIR.get_or_init(init_knot_key_pair); 118 + let jwk = key_pair 119 + .as_ref() 120 + .try_into() 121 + .expect("ECDSA key pair should serialize as a JWK"); 122 + 123 + Json(JsonWebKeySet { keys: vec![jwk] }) 124 + } 125 + 126 + #[derive(Debug, serde::Serialize)] 127 + pub struct KnotServerMetadata { 128 + pub owner: DidBuf, 129 + 130 + /// Base URL for the knot server. 131 + pub knotserver: Url, 132 + 133 + pub jwks_uri: Url, 134 + 135 + /// URL for user login/authorization. 136 + pub login_endpoint: Url, 137 + } 138 + 139 + async fn knotserver_meta(State(knot): State<Knot>) -> Json<KnotServerMetadata> { 140 + let knotserver = knot.base.clone(); 141 + 142 + let mut jwks_uri = knotserver.clone(); 143 + jwks_uri.set_path(WELL_KNOWN_JWKS); 144 + 145 + let mut login_endpoint = knotserver.clone(); 146 + login_endpoint.set_path(PATH_AUTHORIZE); 147 + 148 + Json(KnotServerMetadata { 149 + owner: knot.owner().to_owned(), 150 + knotserver, 151 + jwks_uri, 152 + login_endpoint, 153 + }) 154 + } 155 + 156 + fn normalise_str<S>(s: S) -> Option<S> 157 + where 158 + S: AsRef<str>, 159 + { 160 + match s.as_ref().trim() { 161 + "" => None, 162 + _ => Some(s), 163 + } 164 + } 165 + 166 + /// Parameters required to begin authorization. 167 + #[derive(Debug, serde::Deserialize)] 168 + pub struct AuthorizeParams { 169 + /// Login hint. Maybe a handle, DID, PDS url, or authorization server url. 170 + #[serde(default)] 171 + hint: String, 172 + 173 + /// Repository string. 174 + #[serde(default)] 175 + repo: String, 176 + 177 + /// Public key to authorize. 178 + #[serde(default)] 179 + key: String, 180 + } 181 + 182 + /// Render the authorization page. 183 + async fn render_login( 184 + knot: &Knot, 185 + hint: impl AsRef<str>, 186 + repo: impl AsRef<str>, 187 + key: impl AsRef<str>, 188 + error: Option<String>, 189 + ) -> maud::Markup { 190 + let hint = hint.as_ref(); 191 + let repo = repo.as_ref(); 192 + let key = key.as_ref(); 193 + 194 + let repo = render_repo_input(repo, validate_repo(&knot, repo).await); 195 + let key = render_key_input(key, validate_key(&knot, key).await); 196 + 197 + let atproto = || { 198 + maud::html! { 199 + a href="https://atproto.com" class="text-black dark:text-white" { "Atmosphere" } 200 + } 201 + }; 202 + 203 + super::layout( 204 + maud::html! { 205 + title { "Authorize" } 206 + }, 207 + maud::html! { 208 + div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" { 209 + div class="sm:mx-auto sm:w-full sm:max-w-md" { 210 + h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" { 211 + "Authorize a push key using your Atmosphere handle" 212 + } 213 + } 214 + form id="authorize-form" method="POST" class="space-y-6 mt-6" { 215 + // 216 + // First pane with repository specifier and key. 217 + // 218 + (crate::ui::section_pane(maud::html! { 219 + div class="mt-4" { (repo) } 220 + div class="mt-4" { (key) } 221 + })) 222 + // 223 + // Second pane with handle/login hint and submit button. 224 + // 225 + (crate::ui::section_pane(maud::html! { 226 + div { 227 + div { 228 + label for="hint" { "Atmosphere Handle" } 229 + input 230 + id="hint" 231 + name="hint" 232 + type="text" 233 + autocapitalize="off" 234 + autocorrect="off" 235 + autocomplete="username" 236 + value=(hint) 237 + class="mt-3" 238 + required[(true)] 239 + { } 240 + (crate::ui::script( 241 + r##" 242 + hint.addEventListener("input", (event) => { 243 + hint.setCustomValidity(""); 244 + hint.checkValidity(); 245 + }); 246 + 247 + hint.addEventListener("invalid", (event) => { 248 + hint.setCustomValidity("Please enter an Atmosphere handle, PDS, or authorization server"); 249 + }); 250 + "## 251 + )) 252 + } 253 + p class="mt-4 px-3 text-slate-500 text-sm" { 254 + "You will need an " 255 + (atproto()) 256 + " account with sufficient access rights to the knot and repository to authorize a push key." 257 + } 258 + button type="submit" class="mt-4" { "Sign in" } 259 + } 260 + @if let Some(error) = error { 261 + div class="mt-4 py-4 outline-2 outline-red-600 bg-slate-800/25 dark:bg-slate-700/50 rounded-xs" { 262 + h3 class="px-4 text-slate-900 dark:text-white text-sm" { "Authorization failed" } 263 + p class="mt-3 px-4 text-slate-800 dark:text-white text-sm" { 264 + (error) 265 + } 266 + } 267 + } 268 + })) 269 + } 270 + } 271 + }, 272 + super::foot(knot), 273 + ) 274 + } 275 + 276 + /// Render the repository input with any validation errors. 277 + fn render_repo_input<T>(value: &str, validation: Option<Result<T, String>>) -> maud::Markup { 278 + maud::html! { 279 + div hx-target="this" hx-swap="outerHTML" { 280 + label for="repo" { "Repository" } 281 + div class="relative" { 282 + input 283 + id="repo" 284 + name="repo" 285 + type="text" 286 + value=(value) 287 + aria-invalid=(validation.as_ref().is_some_and(|res| res.is_err())) 288 + aria-describedby="repo-error" 289 + hx-post=(PATH_AUTHORIZE_VALIDATE_REPO) 290 + class="mt-3" 291 + required[(true)] 292 + { } 293 + @match &validation { 294 + Some(Ok(_)) => { 295 + div class="absolute top-3 right-3 text-lime-600" { 296 + (crate::ui::tick()) 297 + } 298 + } 299 + Some(Err(message)) => { 300 + p id="repo-error" class="mt-3 px-3 text-red-500 text-sm" { (message) } 301 + (crate::ui::script(r#"repo.setCustomValidity("Please enter a valid repository");"#)) 302 + } 303 + None => {} 304 + } 305 + } 306 + } 307 + } 308 + } 309 + 310 + fn render_key_input<T>(value: &str, validation: Option<Result<T, String>>) -> maud::Markup { 311 + maud::html! { 312 + div hx-target="this" hx-swap="outerHTML" { 313 + label for="key" { "Push Key" } 314 + div class="relative" { 315 + input 316 + id="key" 317 + name="key" 318 + type="text" 319 + value=(value) 320 + aria-invalid=(validation.as_ref().is_some_and(|res| res.is_err())) 321 + aria-describedby="key-description" 322 + hx-post=(PATH_AUTHORIZE_VALIDATE_KEY) 323 + class="mt-3" 324 + required[(true)] 325 + { } 326 + @match &validation { 327 + Some(Ok(_)) => { 328 + div class="absolute top-3 right-3 text-lime-600" { 329 + (crate::ui::tick()) 330 + } 331 + } 332 + Some(Err(message)) => { 333 + p id="key-error" class="mt-3 px-3 text-red-500 text-sm" { (message) } 334 + (crate::ui::script(r#"key.setCustomValidity("Please enter multibase-encoded ES256 or ES256K public key");"#)) 335 + } 336 + None => {} 337 + } 338 + } 339 + } 340 + } 341 + } 342 + 343 + #[derive(Deserialize)] 344 + struct LoginError { 345 + error: Option<String>, 346 + } 347 + 348 + /// Render the authorization page. 349 + async fn login( 350 + State(knot): State<Knot>, 351 + Query(AuthorizeParams { hint, repo, key }): Query<AuthorizeParams>, 352 + Query(LoginError { error }): Query<LoginError>, 353 + ) -> impl IntoResponse { 354 + ( 355 + [(header::CACHE_CONTROL, "no-store")], 356 + render_login(&knot, hint, repo, key, error).await, 357 + ) 358 + } 359 + 360 + #[tracing::instrument] 361 + async fn authorize_post( 362 + State(knot): State<Knot>, 363 + Form(AuthorizeParams { hint, repo, key }): Form<AuthorizeParams>, 364 + ) -> Response { 365 + let (hint, repo_key, key_id) = { 366 + let validated_repo = validate_repo(&knot, &repo).await; 367 + let validated_key = validate_key(&knot, &key).await; 368 + 369 + match (normalise_str(&hint), validated_repo, validated_key) { 370 + (Some(hint), Some(Ok(repo_key)), Some(Ok(key))) => (hint, repo_key, key), 371 + (_, _, _) => { 372 + return login( 373 + State(knot), 374 + Query(AuthorizeParams { hint, repo, key }), 375 + Query(LoginError { error: None }), 376 + ) 377 + .await 378 + .into_response(); 379 + } 380 + } 381 + }; 382 + 383 + let metadata = raw_client_metadata(&knot); 384 + let client = Client::new_with(metadata.clone(), knot.http(), knot.resolver()) 385 + .with_client_assertion_key(Arc::clone(&KNOT_KEY_PAIR.get_or_init(init_knot_key_pair))); 386 + 387 + let try_login = async || { 388 + let pushed_authorization_client = client.prepare_pushed_authorization(hint).await?; 389 + let response = pushed_authorization_client 390 + .push_authorization(&key_id, &metadata.redirect_uris[0], &["atproto"]) 391 + .await?; 392 + 393 + Ok::<_, Box<dyn core::error::Error>>(response) 394 + }; 395 + 396 + let mut headers = HeaderMap::new(); 397 + match try_login().await { 398 + Ok(authorization_response) => { 399 + let location = authorization_response.authorization_endpoint(&metadata.client_id); 400 + let location_header = HeaderValue::from_str(location.as_str()).unwrap(); 401 + headers.insert(header::LOCATION, location_header.clone()); 402 + 403 + // Save the state so we can retrieve it in the callback. 404 + OAUTH_REQUESTS 405 + .get_or_init(|| Default::default()) 406 + .lock() 407 + .expect("oauth request state should not be poisoned") 408 + .insert( 409 + key_id.into_boxed_str(), 410 + AuthorizationRequestState::Pending(repo_key, authorization_response), 411 + ); 412 + 413 + ( 414 + StatusCode::FOUND, 415 + headers, 416 + maud::html! { 417 + "Navigate to: " (location) " to continue" 418 + }, 419 + ) 420 + } 421 + Err(error) => { 422 + let mut url: Url = format!("http://localhost").parse().unwrap(); 423 + { 424 + let mut query = url.query_pairs_mut(); 425 + if let Some(repo) = normalise_str(&repo) { 426 + query.append_pair("repo", &repo); 427 + } 428 + if let Some(key) = normalise_str(&key) { 429 + query.append_pair("key", &key); 430 + } 431 + // query.append_pair("error", &error.to_string()); 432 + query.append_pair("error", &format!("{error:#?}")); 433 + } 434 + 435 + let url = format!("{PATH_AUTHORIZE}?{}", url.query().unwrap()); 436 + headers.insert(header::LOCATION, url.parse().unwrap()); 437 + 438 + (StatusCode::FOUND, headers, maud::html! {}) 439 + } 440 + } 441 + .into_response() 442 + } 443 + 444 + async fn callback(State(knot): State<Knot>, Query(params): Query<Callback>) -> impl IntoResponse { 445 + match params { 446 + Callback::Success(success) => match process_callback(&knot, success).await { 447 + Ok(()) => super::layout( 448 + maud::html! { title { "Success" }}, 449 + maud::html! { 450 + div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" { 451 + div class="sm:mx-auto sm:w-full sm:max-w-md" { 452 + h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" { 453 + "Authorized" 454 + } 455 + } 456 + (crate::ui::section_pane(maud::html! { 457 + p class="text-center text-slate-800 dark:text-slate-100" { 458 + "You may now close this window" 459 + } 460 + })) 461 + } 462 + }, 463 + super::foot(&knot), 464 + ), 465 + Err(error) => super::layout( 466 + maud::html! { title { "Authorization Error" }}, 467 + maud::html! { 468 + div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" { 469 + div class="sm:mx-auto sm:w-full sm:max-w-md" { 470 + h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" { 471 + "Authorization Error" 472 + } 473 + } 474 + (crate::ui::section_pane(maud::html! { 475 + code { 476 + pre class="text-slate-800 dark:text-slate-100" { 477 + (format!("{error:#?}")) 478 + } 479 + } 480 + })) 481 + } 482 + }, 483 + super::foot(&knot), 484 + ), 485 + }, 486 + Callback::Error(CallbackError { 487 + state: _, 488 + issuer: _, 489 + error, 490 + }) => super::layout( 491 + maud::html! { title { "Authorization Error" }}, 492 + maud::html! { 493 + div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" { 494 + div class="sm:mx-auto sm:w-full sm:max-w-md" { 495 + h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" { 496 + "Authorization Error" 497 + } 498 + } 499 + (crate::ui::section_pane(maud::html! { 500 + code class="space-y-6 mt-6" { 501 + h3 class="text-lg/9 tracking-loose text-slate-900 dark:text-white" { 502 + (error.error) 503 + } 504 + p class="mt-6 text-slate-800 dark:text-slate-100" { 505 + (error.description) 506 + } 507 + } 508 + })) 509 + } 510 + }, 511 + super::foot(&knot), 512 + ), 513 + } 514 + } 515 + 516 + async fn process_callback( 517 + knot: &Knot, 518 + success: CallbackSuccess, 519 + ) -> Result<(), Box<dyn core::error::Error>> { 520 + let auth_request = { 521 + let mut requests = OAUTH_REQUESTS 522 + .get_or_init(|| Default::default()) 523 + .lock() 524 + .expect("oauth requests map lock should not be poisoned"); 525 + 526 + let req = requests.insert(success.state.clone(), AuthorizationRequestState::Verifying); 527 + if let Some(AuthorizationRequestState::Complete) = req { 528 + requests.insert(success.state.clone(), AuthorizationRequestState::Complete); 529 + } 530 + 531 + req 532 + }; 533 + 534 + match auth_request { 535 + None => Err("request not found")?, 536 + Some(AuthorizationRequestState::Pending(repo_key, request)) => { 537 + // YAY! 538 + if request.authorization_meta.issuer != success.issuer { 539 + return Err("Issuer does not match original authorization server")?; 540 + } 541 + 542 + // 1. Request a token to verify DID. 543 + let metadata = raw_client_metadata(&knot); 544 + let client = Client::new_with(metadata.clone(), knot.http(), knot.resolver()) 545 + .with_client_assertion_key(Arc::clone( 546 + &KNOT_KEY_PAIR.get_or_init(init_knot_key_pair), 547 + )); 548 + 549 + let token = client 550 + .request_token(&request, &success, &metadata.redirect_uris[0]) 551 + .await?; 552 + 553 + if !knot.can_push(&repo_key, &token.sub).await { 554 + return Err("DID does not have access to repository")?; 555 + } 556 + 557 + // Notify any waiters. 558 + if let Some(channel) = AUTHORIZATIONS 559 + .get_or_init(|| Mutex::new(HashMap::new())) 560 + .lock() 561 + .unwrap() 562 + .remove(&success.state) 563 + { 564 + let _ = channel.send(true); 565 + } else { 566 + tracing::error!("No waiters for key authorization"); 567 + } 568 + 569 + OAUTH_REQUESTS 570 + .get_or_init(|| Default::default()) 571 + .lock() 572 + .expect("oauth requests map lock should not be poisoned") 573 + .insert(success.state.clone(), AuthorizationRequestState::Complete); 574 + 575 + // Schedule for deletion. 576 + let state = success.state.clone(); 577 + tokio::spawn(async move { 578 + tokio::time::sleep(Duration::from_secs(300)).await; 579 + tracing::info!(?state, "deleting completed request"); 580 + if let Some(requests) = OAUTH_REQUESTS.get() { 581 + if let Ok(mut requests) = requests.lock() { 582 + requests.remove(&state); 583 + } 584 + } 585 + }); 586 + 587 + Ok(()) 588 + } 589 + Some(AuthorizationRequestState::Verifying) => { 590 + // This request is already being processed. 591 + Err("Already processing")? 592 + } 593 + Some(AuthorizationRequestState::Complete) => { 594 + // This request has already been processed. 595 + Err("Already processed")? 596 + } 597 + } 598 + } 599 + 600 + /// Validate `repo` and render just the repo input field and validation errors. 601 + /// 602 + /// This route is triggered by the `hx-post=` attribute on the `<input 603 + /// id="repo">` element. 604 + async fn validate_repo_fragment( 605 + State(knot): State<Knot>, 606 + Form(AuthorizeParams { 607 + hint: _, 608 + repo, 609 + key: _, 610 + }): Form<AuthorizeParams>, 611 + ) -> maud::Markup { 612 + render_repo_input(&repo, validate_repo(&knot, &repo).await) 613 + } 614 + 615 + /// Validate `key` and render just the key input field and any validation 616 + /// errors. 617 + /// 618 + /// This route is triggered by the `hx-post=` attribute on the `<input 619 + /// id="key">` element. 620 + async fn validate_key_fragment( 621 + State(knot): State<Knot>, 622 + Form(AuthorizeParams { 623 + hint: _, 624 + repo: _, 625 + key, 626 + }): Form<AuthorizeParams>, 627 + ) -> maud::Markup { 628 + render_key_input(&key, validate_key(&knot, &key).await) 629 + } 630 + 631 + /// Parse and resolve the repository specifier. 632 + async fn validate_repo(knot: &Knot, repo: &str) -> Option<Result<RepositoryKey, String>> { 633 + let inner = async |repo: &str| -> Result<RepositoryKey, Box<dyn core::error::Error>> { 634 + let repo_path = repo.parse()?; 635 + let repo_key = knot.resolve_repo_key(&repo_path).await?; 636 + Ok(repo_key) 637 + }; 638 + 639 + let repo = normalise_str(repo)?; 640 + Some(inner(repo).await.map_err(|error| error.to_string())) 641 + } 642 + 643 + async fn validate_key(_: &Knot, key: &str) -> Option<Result<String, String>> { 644 + let inner = |key: &str| -> Result<String, Box<dyn core::error::Error>> { 645 + let multibase = MultibaseKey(key).to_verification_key()?; 646 + let digest = aws_lc_rs::digest::digest(&SHA256, multibase.as_ref()); 647 + Ok(data_encoding::BASE64URL_NOPAD.encode(digest.as_ref())) 648 + }; 649 + 650 + let key = normalise_str(key)?; 651 + Some(inner(key).map_err(|error| error.to_string())) 652 + } 653 + 654 + /// Generate the Bluesky oauth client_id parameter. 655 + fn client_id(mut base: Url) -> Url { 656 + if base.scheme() == "http" && base.host_str() == Some("localhost") { 657 + let redirect_base = base.clone(); 658 + base.set_port(None).expect("knot base url must be a base"); 659 + { 660 + let mut query = base.query_pairs_mut(); 661 + query.append_pair("redirect_uri", redirect_uri(redirect_base).as_str()); 662 + query.append_pair("scope", "atproto"); 663 + } 664 + } else { 665 + base.set_path(WELL_KNOWN_CLIENT_METADATA); 666 + } 667 + base 668 + } 669 + 670 + /// Generate the Bluesky oauth redirect parameter. 671 + fn redirect_uri(mut base: Url) -> Url { 672 + use std::net::IpAddr; 673 + use std::net::Ipv4Addr; 674 + 675 + if base.scheme() == "http" && base.host_str() == Some("localhost") { 676 + base.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST)).expect(""); 677 + } 678 + base.set_path(PATH_AUTHORIZE_RESULT); 679 + base 680 + } 681 + 682 + /// Get the URL for the knot's JWKs. 683 + fn jwks_uri(mut base: Url) -> Url { 684 + base.set_path(WELL_KNOWN_JWKS); 685 + base 686 + } 687 + 688 + #[derive(Debug, serde::Deserialize)] 689 + struct EventParameters { 690 + /// base64url-encoded key fingerprint. 691 + key_id: String, 692 + } 693 + 694 + async fn authorize_event( 695 + Query(EventParameters { key_id }): Query<EventParameters>, 696 + ws: WebSocketUpgrade, 697 + ) -> Result<impl IntoResponse, StatusCode> { 698 + async fn wait_for_authorization( 699 + key_id: &str, 700 + mut socket: WebSocket, 701 + ) -> Result<(), Box<dyn core::error::Error>> { 702 + use tokio::time::timeout; 703 + 704 + const TIMEOUT: Duration = Duration::from_mins(10); 705 + const RECEIVER_THRESHOLD: usize = 10; 706 + 707 + let mut rx = { 708 + let mut guard = AUTHORIZATIONS 709 + .get_or_init(|| Mutex::new(HashMap::new())) 710 + .lock() 711 + .unwrap(); 712 + 713 + let tx = guard 714 + .entry(key_id.into()) 715 + .or_insert_with(|| broadcast::channel(1).0); 716 + 717 + if tx.receiver_count() >= RECEIVER_THRESHOLD { 718 + tracing::error!( 719 + ?key_id, 720 + "too many receivers for key authorization notification" 721 + ); 722 + return Err(format!("too many recievers for key id: '{key_id}'").into()); 723 + } 724 + 725 + tx.subscribe() 726 + }; 727 + 728 + let message = match timeout(TIMEOUT, rx.recv()).await { 729 + Ok(Ok(true)) => Message::text(format!("ACCEPTED {key_id}")), 730 + Ok(Ok(false)) => Message::text(format!("REJECTED {key_id}")), 731 + Ok(Err(_)) => Message::text(format!("ABORTED")), 732 + Err(_) => Message::text(format!("TIMEOUT")), 733 + }; 734 + 735 + socket.send(message).await?; 736 + socket.flush().await?; 737 + socket.close().await?; 738 + 739 + Ok(()) 740 + } 741 + 742 + // `key_id` should be a base64-url encoded sha256 digest. 743 + 744 + const OUTPUT_LEN: usize = SHA256.output_len; 745 + let Ok(OUTPUT_LEN) = data_encoding::BASE64URL_NOPAD 746 + .decode(key_id.as_bytes()) 747 + .map(|bytes| bytes.len()) 748 + else { 749 + return Err(StatusCode::BAD_REQUEST); 750 + }; 751 + 752 + Ok(ws.on_upgrade(async move |socket| { 753 + if let Err(error) = wait_for_authorization(&key_id, socket).await { 754 + tracing::error!(?error, ?key_id); 755 + } 756 + })) 757 + }
+13 -12
crates/gordian-knot/src/public/events.rs
··· 1 1 use std::time::Duration; 2 2 3 - use axum::{ 4 - extract::{ 5 - Query, State, WebSocketUpgrade, 6 - ws::{Message, WebSocket}, 7 - }, 8 - http::StatusCode, 9 - response::IntoResponse, 10 - }; 11 - use futures_util::{SinkExt as _, StreamExt as _, TryStreamExt as _}; 3 + use axum::extract::Query; 4 + use axum::extract::State; 5 + use axum::extract::WebSocketUpgrade; 6 + use axum::extract::ws::Message; 7 + use axum::extract::ws::WebSocket; 8 + use axum::http::StatusCode; 9 + use axum::response::IntoResponse; 10 + use futures_util::SinkExt as _; 11 + use futures_util::StreamExt as _; 12 + use futures_util::TryStreamExt as _; 12 13 use gordian_types::Tid; 13 - use serde::{Deserialize, Serialize}; 14 + use serde::Deserialize; 15 + use serde::Serialize; 14 16 use time::OffsetDateTime; 15 17 use tokio::time::Instant; 16 18 17 - use crate::model::Knot; 18 - 19 19 use super::xrpc::XrpcError; 20 + use crate::model::Knot; 20 21 21 22 const KEEP_ALIVE: Duration = Duration::from_secs(45); 22 23
+14 -12
crates/gordian-knot/src/public/git.rs
··· 5 5 mod upload_pack; 6 6 7 7 pub use authorization::GitAuthorization; 8 - pub use error::{Error, NotFound}; 8 + use axum::extract::Query; 9 + use axum::extract::Request; 10 + use axum::extract::State; 11 + use axum::response::IntoResponse as _; 12 + use axum::response::Response; 13 + pub use error::Error; 14 + pub use error::NotFound; 15 + use tokio::io::AsyncWrite; 16 + use tokio::io::AsyncWriteExt as _; 9 17 10 - use axum::{ 11 - extract::{Query, Request, State}, 12 - response::{IntoResponse as _, Response}, 13 - }; 14 - use tokio::io::{AsyncWrite, AsyncWriteExt as _}; 15 - 16 - use crate::{ 17 - extractors::{GitProtocol, request_id::RequestId}, 18 - model::Knot, 19 - }; 18 + use crate::extractors::GitProtocol; 19 + use crate::extractors::request_id::RequestId; 20 + use crate::model::Knot; 20 21 21 22 pub fn router() -> axum::Router<Knot> { 22 - use axum::routing::{get, post}; 23 + use axum::routing::get; 24 + use axum::routing::post; 23 25 axum::Router::new() 24 26 .route("/info/refs", get(info_refs)) 25 27 .route("/git-upload-archive", post(upload_archive::upload_archive))
+22 -21
crates/gordian-knot/src/public/git/authorization.rs
··· 1 - use axum::{ 2 - extract::{FromRef, FromRequestParts}, 3 - http::{header::AUTHORIZATION, request::Parts}, 4 - }; 5 - use gordian_auth::{ 6 - IntoVerificationKey, OpenSshKey, 7 - jwt::{Claims, Token, decode}, 8 - }; 1 + use axum::extract::FromRef; 2 + use axum::extract::FromRequestParts; 3 + use axum::http::header::AUTHORIZATION; 4 + use axum::http::request::Parts; 5 + use gordian_auth::IntoVerificationKey; 6 + use gordian_auth::OpenSshKey; 7 + use gordian_auth::jwt::Claims; 8 + use gordian_auth::jwt::Token; 9 + use gordian_auth::jwt::decode; 9 10 use gordian_identity::Resolver; 10 11 use gordian_types::Nsid; 11 12 use time::OffsetDateTime; 12 13 13 - use crate::{ 14 - model::Knot, 15 - nsid::SH_TANGLED_REPO_GITRECEIVEPACK, 16 - services::authorization::{ 17 - AuthorizationClaimsStore as _, Verification, VerificationError, extract_token, 18 - }, 19 - }; 20 - 21 14 use super::Error; 15 + use crate::model::Knot; 16 + use crate::nsid::SH_TANGLED_REPO_GITRECEIVEPACK; 17 + use crate::services::authorization::AuthorizationClaimsStore as _; 18 + use crate::services::authorization::Verification; 19 + use crate::services::authorization::VerificationError; 20 + use crate::services::authorization::extract_token; 22 21 23 22 #[derive(Debug)] 24 23 struct GitVerification; ··· 54 55 GitVerification::verify(&knot, now, knot.instance(), &unverified_claims) 55 56 .await 56 57 .map_err(|error| match error { 57 - // Git re-uses the token from the credential helper for each request in a single push. 58 + // Git re-uses the token from the credential helper for each request in a single 59 + // push. 58 60 // 59 - // Returning 'Forbidden' here will make git abort. Instead, we return an Unauthorized 60 - // which will force git to get a new token from the credential helper. 61 + // Returning 'Forbidden' here will make git abort. Instead, we return an 62 + // Unauthorized which will force git to get a new token from the 63 + // credential helper. 61 64 VerificationError::Reused => Error::unauthorized(&knot, "authorization re-used"), 62 65 error => Error::forbidden(&knot, error.to_string()), 63 66 })?; ··· 77 76 let verification_keys = doc 78 77 .verification_method 79 78 .into_iter() 80 - .filter_map(|vm| vm.into_verification_key().ok()); 79 + .filter_map(|vm| vm.to_verification_key().ok()); 81 80 82 81 // Try to decode and verify the JWT using any one of the verification keys 83 82 // we have for the DID. ··· 97 96 .await 98 97 .unwrap_or_default() 99 98 .into_iter() 100 - .filter_map(|public_key| OpenSshKey(public_key.key).into_verification_key().ok()); 99 + .filter_map(|public_key| OpenSshKey(public_key.key).to_verification_key().ok()); 101 100 102 101 // Try to decode and verify the JWT using any one of the public keys 103 102 // we have for the DID.
+11 -9
crates/gordian-knot/src/public/git/error.rs
··· 1 - use crate::{model::Knot, services::authorization::AuthorizationClaimsStoreError}; 2 - use axum::{ 3 - extract::rejection::PathRejection, 4 - http::{ 5 - HeaderMap, HeaderValue, StatusCode, 6 - header::{CONTENT_TYPE, WWW_AUTHENTICATE}, 7 - }, 8 - response::IntoResponse, 9 - }; 10 1 use std::borrow::Cow; 2 + 3 + use axum::extract::rejection::PathRejection; 4 + use axum::http::HeaderMap; 5 + use axum::http::HeaderValue; 6 + use axum::http::StatusCode; 7 + use axum::http::header::CONTENT_TYPE; 8 + use axum::http::header::WWW_AUTHENTICATE; 9 + use axum::response::IntoResponse; 10 + 11 + use crate::model::Knot; 12 + use crate::services::authorization::AuthorizationClaimsStoreError; 11 13 12 14 const TEXT_PLAIN: HeaderValue = HeaderValue::from_static("text/plain; charset=utf-8"); 13 15
+22 -16
crates/gordian-knot/src/public/git/receive_pack.rs
··· 1 - use axum::{ 2 - extract::{FromRequestParts as _, Request, State}, 3 - http::header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, 4 - response::IntoResponse, 5 - }; 6 - use axum_extra::body::AsyncReadBody; 7 - use std::{ffi::OsString, process::Stdio}; 8 - use tokio::net::unix::pipe::{Sender, pipe as async_pipe}; 1 + use std::ffi::OsString; 2 + use std::process::Stdio; 9 3 10 - use crate::{ 11 - command::{SetOptionEnv as _, TraceProcessCompletion as _}, 12 - extractors::{GitProtocol, request_id::RequestId}, 13 - model::{Knot, repository::TangledRepository}, 14 - private, 15 - public::git::GitAuthorization, 16 - services::git::stream_to_pipe, 17 - }; 4 + use axum::extract::FromRequestParts as _; 5 + use axum::extract::Request; 6 + use axum::extract::State; 7 + use axum::http::header::CACHE_CONTROL; 8 + use axum::http::header::CONNECTION; 9 + use axum::http::header::CONTENT_TYPE; 10 + use axum::response::IntoResponse; 11 + use axum_extra::body::AsyncReadBody; 12 + use tokio::net::unix::pipe::Sender; 13 + use tokio::net::unix::pipe::pipe as async_pipe; 14 + 15 + use crate::command::SetOptionEnv as _; 16 + use crate::command::TraceProcessCompletion as _; 17 + use crate::extractors::GitProtocol; 18 + use crate::extractors::request_id::RequestId; 19 + use crate::model::Knot; 20 + use crate::model::repository::TangledRepository; 21 + use crate::private; 22 + use crate::public::git::GitAuthorization; 23 + use crate::services::git::stream_to_pipe; 18 24 19 25 const RECEIVE_PACK_ADVERTISEMENT: &str = "application/x-git-receive-pack-advertisement"; 20 26 const RECEIVE_PACK_RESULT: &str = "application/x-git-receive-pack-result";
+12 -11
crates/gordian-knot/src/public/git/upload_archive.rs
··· 1 1 use std::process::Stdio; 2 2 3 - use axum::{ 4 - extract::{Request, State}, 5 - http::header::CONTENT_TYPE, 6 - response::IntoResponse, 7 - }; 3 + use axum::extract::Request; 4 + use axum::extract::State; 5 + use axum::http::header::CONTENT_TYPE; 6 + use axum::response::IntoResponse; 8 7 use axum_extra::body::AsyncReadBody; 9 - use tokio::{io::AsyncWriteExt as _, net::unix::pipe::Sender}; 8 + use tokio::io::AsyncWriteExt as _; 9 + use tokio::net::unix::pipe::Sender; 10 10 11 - use crate::{ 12 - command::{SetOptionEnv as _, TraceProcessCompletion as _}, 13 - extractors::{GitProtocol, request_id::RequestId}, 14 - model::{Knot, repository::TangledRepository}, 15 - }; 11 + use crate::command::SetOptionEnv as _; 12 + use crate::command::TraceProcessCompletion as _; 13 + use crate::extractors::GitProtocol; 14 + use crate::extractors::request_id::RequestId; 15 + use crate::model::Knot; 16 + use crate::model::repository::TangledRepository; 16 17 17 18 const UPLOAD_ARCHIVE_RESULT: &str = "application/x-git-upload-archive-result"; 18 19
+19 -16
crates/gordian-knot/src/public/git/upload_pack.rs
··· 1 - use axum::{ 2 - extract::{Request, State}, 3 - http::header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, 4 - response::IntoResponse, 5 - }; 6 - use axum_extra::body::AsyncReadBody; 7 1 use std::process::Stdio; 8 - use tokio::{ 9 - io::AsyncWriteExt as _, 10 - net::unix::pipe::{Sender, pipe as async_pipe}, 11 - }; 12 2 13 - use crate::{ 14 - command::{SetOptionEnv as _, TraceProcessCompletion}, 15 - extractors::{GitProtocol, request_id::RequestId}, 16 - model::{Knot, repository::TangledRepository}, 17 - }; 3 + use axum::extract::Request; 4 + use axum::extract::State; 5 + use axum::http::header::CACHE_CONTROL; 6 + use axum::http::header::CONNECTION; 7 + use axum::http::header::CONTENT_TYPE; 8 + use axum::response::IntoResponse; 9 + use axum_extra::body::AsyncReadBody; 10 + use tokio::io::AsyncWriteExt as _; 11 + use tokio::net::unix::pipe::Sender; 12 + use tokio::net::unix::pipe::pipe as async_pipe; 13 + 14 + use crate::command::SetOptionEnv as _; 15 + use crate::command::TraceProcessCompletion; 16 + use crate::extractors::GitProtocol; 17 + use crate::extractors::request_id::RequestId; 18 + use crate::model::Knot; 19 + use crate::model::repository::TangledRepository; 18 20 19 21 const UPLOAD_PACK_ADVERTISEMENT: &str = "application/x-git-upload-pack-advertisement"; 20 22 const UPLOAD_PACK_RESULT: &str = "application/x-git-upload-pack-result"; 21 23 const NO_CACHE: &str = "no-cache, max-age=0, must-revalidate"; 22 24 const KEEP_ALIVE: &str = "keep-alive"; 23 25 24 - /// Serve the "/info/refs?service=git-upload-pack" phase of a `git fetch` operation. 26 + /// Serve the "/info/refs?service=git-upload-pack" phase of a `git fetch` 27 + /// operation. 25 28 pub async fn advertise_upload_pack( 26 29 State(knot): State<Knot>, 27 30 protocol: Option<GitProtocol>,
+18 -14
crates/gordian-knot/src/public/xrpc.rs
··· 1 - use crate::model::{ 2 - Knot, errors, 3 - repository::{BlobError, RevspecError, TreeError}, 4 - }; 5 - use axum::{ 6 - Json, Router, 7 - extract::{FromRef, FromRequestParts}, 8 - http::StatusCode, 9 - response::IntoResponse, 10 - }; 1 + use std::borrow::Cow; 2 + 3 + use axum::Json; 4 + use axum::Router; 5 + use axum::extract::FromRef; 6 + use axum::extract::FromRequestParts; 7 + use axum::http::StatusCode; 8 + use axum::response::IntoResponse; 11 9 use gordian_identity::Resolver; 12 10 use serde::de::DeserializeOwned; 13 - use std::borrow::Cow; 11 + 12 + use crate::model::Knot; 13 + use crate::model::errors; 14 + use crate::model::repository::BlobError; 15 + use crate::model::repository::RevspecError; 16 + use crate::model::repository::TreeError; 14 17 15 18 pub mod sh_tangled; 16 19 ··· 76 73 T: IntoResponse, 77 74 { 78 75 fn into_response(self) -> axum::response::Response { 79 - use axum::http::header::{CACHE_CONTROL, HeaderValue}; 76 + use axum::http::header::CACHE_CONTROL; 77 + use axum::http::header::HeaderValue; 80 78 81 79 let Self { 82 80 response, ··· 202 198 ise!(gix::reference::find::existing::Error, StatusCode::NOT_FOUND); 203 199 ise!(gix::repository::commit_graph_if_enabled::Error); 204 200 205 - /// Wraps [`axum::extract::Query`] to customize the rejection type to [`XrpcError`]. 206 - /// 201 + /// Wraps [`axum::extract::Query`] to customize the rejection type to 202 + /// [`XrpcError`]. 207 203 pub struct XrpcQuery<T>(pub T); 208 204 209 205 impl<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for XrpcQuery<T> {
+9 -8
crates/gordian-knot/src/public/xrpc/sh_tangled.rs
··· 6 6 /// Get the owner of a service. 7 7 /// 8 8 /// <https://tangled.org/tangled.org/core/blob/master/lexicons/owner.json> 9 - /// 10 9 pub fn owner<S>() -> axum::Router<S> 11 10 where 12 11 S: Clone + Send + Sync + 'static, 13 12 Knot: axum::extract::FromRef<S>, 14 13 { 15 - use impl_owner::{LXM, owner_query}; 14 + use impl_owner::LXM; 15 + use impl_owner::owner_query; 16 16 axum::Router::new().route(LXM, axum::routing::get(owner_query)) 17 17 } 18 18 19 19 mod impl_owner { 20 - use axum::{ 21 - Json, 22 - extract::{FromRef, State}, 23 - response::{IntoResponse, Response}, 24 - }; 20 + use axum::Json; 21 + use axum::extract::FromRef; 22 + use axum::extract::State; 23 + use axum::response::IntoResponse; 24 + use axum::response::Response; 25 25 26 - use crate::{lexicon::sh_tangled::owner::Output, model::Knot}; 26 + use crate::lexicon::sh_tangled::owner::Output; 27 + use crate::model::Knot; 27 28 28 29 pub const LXM: &str = "/sh.tangled.owner"; 29 30
+2 -2
crates/gordian-knot/src/public/xrpc/sh_tangled/knot.rs
··· 3 3 /// Get the version of a knot. 4 4 /// 5 5 /// <https://tangled.org/tangled.org/core/blob/master/lexicons/knot/version.json> 6 - /// 7 6 pub fn version<S>() -> axum::Router<S> 8 7 where 9 8 S: Clone + Send + Sync + 'static, 10 9 Knot: axum::extract::FromRef<S>, 11 10 { 12 - use impl_version::{LXM, handle}; 11 + use impl_version::LXM; 12 + use impl_version::handle; 13 13 axum::Router::new().route(LXM, axum::routing::get(handle)) 14 14 } 15 15
+4 -2
crates/gordian-knot/src/public/xrpc/sh_tangled/repo.rs
··· 25 25 S: Clone + Send + Sync + 'static, 26 26 Knot: axum::extract::FromRef<S>, 27 27 { 28 - use $module::{LXM, handle}; 28 + use $module::LXM; 29 + use $module::handle; 29 30 axum::Router::new().route(LXM, axum::routing::get(handle)) 30 31 } 31 32 }; ··· 37 36 Knot: axum::extract::FromRef<S>, 38 37 Resolver: axum::extract::FromRef<S>, 39 38 { 40 - use $module::{LXM, handle}; 39 + use $module::LXM; 40 + use $module::handle; 41 41 axum::Router::new().route(LXM, axum::routing::post(handle)) 42 42 } 43 43 };
+14 -11
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_archive.rs
··· 1 - use crate::command::SetOptionArg as _; 2 - use crate::model::Knot; 3 - use crate::model::errors; 4 - use crate::model::repository::ResolvedRevspec; 5 - use crate::model::repository::TangledRepository; 6 - use crate::public::xrpc::XrpcError; 7 - use crate::public::xrpc::XrpcQuery; 8 - use crate::types::repository_path::RepositoryPath; 1 + use std::process::Stdio; 2 + use std::time::Duration; 3 + 9 4 use axum::extract::State; 10 5 use axum::http::HeaderMap; 11 6 use axum::http::HeaderValue; ··· 9 14 use axum_extra::body::AsyncReadBody; 10 15 use gordian_lexicon::sh_tangled::repo::archive::Format; 11 16 use gordian_lexicon::sh_tangled::repo::archive::Input; 12 - use std::process::Stdio; 13 - use std::time::Duration; 17 + 18 + use crate::command::SetOptionArg as _; 19 + use crate::model::Knot; 20 + use crate::model::errors; 21 + use crate::model::repository::ResolvedRevspec; 22 + use crate::model::repository::TangledRepository; 23 + use crate::public::xrpc::XrpcError; 24 + use crate::public::xrpc::XrpcQuery; 25 + use crate::types::repository_path::RepositoryPath; 14 26 15 27 pub const LXM: &str = "/sh.tangled.repo.archive"; 16 28 ··· 34 32 }): XrpcQuery<Input>, 35 33 repository: TangledRepository, 36 34 ) -> Result<impl IntoResponse, XrpcError> { 37 - use crate::model::repository::ResolveRevspec as _; 38 35 use axum::http::header; 36 + 37 + use crate::model::repository::ResolveRevspec as _; 39 38 40 39 let repo_path: RepositoryPath = repo 41 40 .parse()
+21 -16
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_blob.rs
··· 1 1 use std::path::PathBuf; 2 2 3 - use axum::{ 4 - Json, 5 - extract::State, 6 - http::{HeaderMap, HeaderValue, StatusCode}, 7 - response::{IntoResponse, Response}, 8 - }; 3 + use axum::Json; 4 + use axum::extract::State; 5 + use axum::http::HeaderMap; 6 + use axum::http::HeaderValue; 7 + use axum::http::StatusCode; 8 + use axum::response::IntoResponse; 9 + use axum::response::Response; 9 10 use data_encoding::BASE64; 10 - use gordian_lexicon::sh_tangled::repo::blob::{Encoding, Input, Output, Submodule}; 11 + use gordian_lexicon::sh_tangled::repo::blob::Encoding; 12 + use gordian_lexicon::sh_tangled::repo::blob::Input; 13 + use gordian_lexicon::sh_tangled::repo::blob::Output; 14 + use gordian_lexicon::sh_tangled::repo::blob::Submodule; 11 15 use mimetype_detector::MimeType; 12 - use reqwest::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG}; 16 + use reqwest::header::CACHE_CONTROL; 17 + use reqwest::header::CONTENT_TYPE; 18 + use reqwest::header::ETAG; 13 19 use tokio_rayon::AsyncThreadPool as _; 14 20 15 - use crate::{ 16 - extractors::IfNoneMatch, 17 - model::{ 18 - Knot, 19 - repository::{ResolveRevspec as _, ResolvedRevspec, TangledRepository}, 20 - }, 21 - public::xrpc::{XrpcError, XrpcQuery}, 22 - }; 21 + use crate::extractors::IfNoneMatch; 22 + use crate::model::Knot; 23 + use crate::model::repository::ResolveRevspec as _; 24 + use crate::model::repository::ResolvedRevspec; 25 + use crate::model::repository::TangledRepository; 26 + use crate::public::xrpc::XrpcError; 27 + use crate::public::xrpc::XrpcQuery; 23 28 24 29 pub const LXM: &str = "/sh.tangled.repo.blob"; 25 30
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_branch.rs
··· 1 - use axum::{Json, extract::State}; 2 - use gordian_lexicon::sh_tangled::repo::branch::{Input, Output}; 1 + use axum::Json; 2 + use axum::extract::State; 3 + use gordian_lexicon::sh_tangled::repo::branch::Input; 4 + use gordian_lexicon::sh_tangled::repo::branch::Output; 3 5 use tokio_rayon::AsyncThreadPool as _; 4 6 5 - use crate::{ 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 7 + use crate::model::Knot; 8 + use crate::model::repository::TangledRepository; 9 + use crate::public::xrpc::XrpcQuery; 10 + use crate::public::xrpc::XrpcResult; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.branch"; 11 13
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_branches.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - model::{Knot, repository::TangledRepository}, 6 - public::xrpc::{XrpcQuery, XrpcResult}, 7 - types::sh_tangled::repo::branches::{Input, Output}, 8 - }; 5 + use crate::model::Knot; 6 + use crate::model::repository::TangledRepository; 7 + use crate::public::xrpc::XrpcQuery; 8 + use crate::public::xrpc::XrpcResult; 9 + use crate::types::sh_tangled::repo::branches::Input; 10 + use crate::types::sh_tangled::repo::branches::Output; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.branches"; 11 13
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_compare.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - model::{Knot, repository::TangledRepository}, 6 - public::xrpc::{XrpcQuery, XrpcResult}, 7 - types::sh_tangled::repo::compare::{Input, Output}, 8 - }; 5 + use crate::model::Knot; 6 + use crate::model::repository::TangledRepository; 7 + use crate::public::xrpc::XrpcQuery; 8 + use crate::public::xrpc::XrpcResult; 9 + use crate::types::sh_tangled::repo::compare::Input; 10 + use crate::types::sh_tangled::repo::compare::Output; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.compare"; 11 13
+19 -16
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_create.rs
··· 1 - use axum::{Json, extract::State, http::StatusCode}; 2 - use gordian_lexicon::{ 3 - com::atproto::repo::list_records::Record, 4 - sh_tangled::repo::{Repo, create::Input}, 5 - }; 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::http::StatusCode; 4 + use gordian_lexicon::com::atproto::repo::list_records::Record; 5 + use gordian_lexicon::sh_tangled::repo::Repo; 6 + use gordian_lexicon::sh_tangled::repo::create::Input; 6 7 use gordian_types::Nsid; 7 8 8 - use crate::{ 9 - model::{Knot, errors}, 10 - nsid::SH_TANGLED_REPO_CREATE, 11 - public::xrpc::{XrpcError, XrpcResult}, 12 - services::{ 13 - atrepo, 14 - authorization::{Authorization, Verification}, 15 - }, 16 - types::RecordKey, 17 - }; 9 + use crate::model::Knot; 10 + use crate::model::errors; 11 + use crate::nsid::SH_TANGLED_REPO_CREATE; 12 + use crate::public::xrpc::XrpcError; 13 + use crate::public::xrpc::XrpcResult; 14 + use crate::services::atrepo; 15 + use crate::services::authorization::Authorization; 16 + use crate::services::authorization::Verification; 17 + use crate::types::RecordKey; 18 18 19 19 pub const LXM: &str = "/sh.tangled.repo.create"; 20 20 ··· 31 31 authorization: Authorization<CreateVerification>, 32 32 Json(params): Json<Input>, 33 33 ) -> XrpcResult<()> { 34 - use crate::services::rbac::{Action, Policy, PolicyResult::*, RepositoryCreatePolicy}; 34 + use crate::services::rbac::Action; 35 + use crate::services::rbac::Policy; 36 + use crate::services::rbac::PolicyResult::*; 37 + use crate::services::rbac::RepositoryCreatePolicy; 35 38 36 39 let claims = authorization.claims(); 37 40 let policy = RepositoryCreatePolicy;
+16 -12
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_delete.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use gordian_lexicon::sh_tangled::repo::delete::Input; 3 - use gordian_types::{Nsid, Tid}; 4 + use gordian_types::Nsid; 5 + use gordian_types::Tid; 4 6 5 - use crate::{ 6 - model::{Knot, errors}, 7 - nsid::SH_TANGLED_REPO_DELETE, 8 - public::xrpc::XrpcResult, 9 - services::authorization::{Authorization, Verification}, 10 - types::RecordKey, 11 - }; 7 + use crate::model::Knot; 8 + use crate::model::errors; 9 + use crate::nsid::SH_TANGLED_REPO_DELETE; 10 + use crate::public::xrpc::XrpcResult; 11 + use crate::services::authorization::Authorization; 12 + use crate::services::authorization::Verification; 13 + use crate::types::RecordKey; 12 14 13 15 pub const LXM: &str = "/sh.tangled.repo.delete"; 14 16 ··· 27 25 authorization: Authorization<DeleteVerification>, 28 26 Json(params): Json<Input>, 29 27 ) -> XrpcResult<()> { 30 - use crate::services::rbac::{ 31 - Action, Policy, PolicyResult::*, RepositoryDeletePolicy, RepositoryRef, 32 - }; 28 + use crate::services::rbac::Action; 29 + use crate::services::rbac::Policy; 30 + use crate::services::rbac::PolicyResult::*; 31 + use crate::services::rbac::RepositoryDeletePolicy; 32 + use crate::services::rbac::RepositoryRef; 33 33 34 34 let claims = authorization.claims(); 35 35 let policy = RepositoryDeletePolicy;
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_diff.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - model::{Knot, repository::TangledRepository}, 6 - public::xrpc::{XrpcQuery, XrpcResult}, 7 - types::sh_tangled::repo::diff::{Input, Output}, 8 - }; 5 + use crate::model::Knot; 6 + use crate::model::repository::TangledRepository; 7 + use crate::public::xrpc::XrpcQuery; 8 + use crate::public::xrpc::XrpcResult; 9 + use crate::types::sh_tangled::repo::diff::Input; 10 + use crate::types::sh_tangled::repo::diff::Output; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.diff"; 11 13
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_get_default_branch.rs
··· 1 - use axum::{Json, extract::State}; 2 - use gordian_lexicon::sh_tangled::repo::get_default_branch::{Input, Output}; 1 + use axum::Json; 2 + use axum::extract::State; 3 + use gordian_lexicon::sh_tangled::repo::get_default_branch::Input; 4 + use gordian_lexicon::sh_tangled::repo::get_default_branch::Output; 3 5 use tokio_rayon::AsyncThreadPool as _; 4 6 5 - use crate::{ 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 7 + use crate::model::Knot; 8 + use crate::model::repository::TangledRepository; 9 + use crate::public::xrpc::XrpcQuery; 10 + use crate::public::xrpc::XrpcResult; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.getDefaultBranch"; 11 13
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_languages.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - lexicon::sh_tangled::repo::languages::{Input, Output}, 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 5 + use crate::lexicon::sh_tangled::repo::languages::Input; 6 + use crate::lexicon::sh_tangled::repo::languages::Output; 7 + use crate::model::Knot; 8 + use crate::model::repository::TangledRepository; 9 + use crate::public::xrpc::XrpcQuery; 10 + use crate::public::xrpc::XrpcResult; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.languages"; 11 13
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_log.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - model::{Knot, repository::TangledRepository}, 6 - public::xrpc::{XrpcQuery, XrpcResult}, 7 - types::sh_tangled::repo::log::{Input, Output}, 8 - }; 5 + use crate::model::Knot; 6 + use crate::model::repository::TangledRepository; 7 + use crate::public::xrpc::XrpcQuery; 8 + use crate::public::xrpc::XrpcResult; 9 + use crate::types::sh_tangled::repo::log::Input; 10 + use crate::types::sh_tangled::repo::log::Output; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.log"; 11 13
+9 -7
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_merge_check.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - lexicon::sh_tangled::repo::merge_check::{Input, Output}, 6 - model::{Knot, errors, repository::TangledRepository}, 7 - public::xrpc::XrpcResult, 8 - types::repository_path::RepositoryPath, 9 - }; 5 + use crate::lexicon::sh_tangled::repo::merge_check::Input; 6 + use crate::lexicon::sh_tangled::repo::merge_check::Output; 7 + use crate::model::Knot; 8 + use crate::model::errors; 9 + use crate::model::repository::TangledRepository; 10 + use crate::public::xrpc::XrpcResult; 11 + use crate::types::repository_path::RepositoryPath; 10 12 11 13 pub const LXM: &str = "/sh.tangled.repo.mergeCheck"; 12 14
+22 -19
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_set_default_branch.rs
··· 1 - use axum::{Json, extract::State}; 2 - use gix::{ 3 - lock::acquire::Fail, 4 - refs::{ 5 - FullName, Target, 6 - transaction::{Change, LogChange, PreviousValue, RefEdit}, 7 - }, 8 - }; 1 + use axum::Json; 2 + use axum::extract::State; 3 + use gix::lock::acquire::Fail; 4 + use gix::refs::FullName; 5 + use gix::refs::Target; 6 + use gix::refs::transaction::Change; 7 + use gix::refs::transaction::LogChange; 8 + use gix::refs::transaction::PreviousValue; 9 + use gix::refs::transaction::RefEdit; 9 10 use gordian_types::Nsid; 10 11 11 - use crate::{ 12 - lexicon::sh_tangled::repo::set_default_branch::Input, 13 - model::{Knot, errors}, 14 - nsid::SH_TANGLED_REPO_SETDEFAULTBRANCH, 15 - public::xrpc::XrpcError, 16 - services::{ 17 - authorization::{Authorization, Verification}, 18 - rbac::{Action, Policy, PolicyResult::Granted, RepositoryEditPolicy, RepositoryRef}, 19 - }, 20 - types::repository_key::RepositoryKey, 21 - }; 12 + use crate::lexicon::sh_tangled::repo::set_default_branch::Input; 13 + use crate::model::Knot; 14 + use crate::model::errors; 15 + use crate::nsid::SH_TANGLED_REPO_SETDEFAULTBRANCH; 16 + use crate::public::xrpc::XrpcError; 17 + use crate::services::authorization::Authorization; 18 + use crate::services::authorization::Verification; 19 + use crate::services::rbac::Action; 20 + use crate::services::rbac::Policy; 21 + use crate::services::rbac::PolicyResult::Granted; 22 + use crate::services::rbac::RepositoryEditPolicy; 23 + use crate::services::rbac::RepositoryRef; 24 + use crate::types::repository_key::RepositoryKey; 22 25 23 26 pub const LXM: &str = "/sh.tangled.repo.setDefaultBranch"; 24 27
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_tags.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - model::{Knot, repository::TangledRepository}, 6 - public::xrpc::{XrpcQuery, XrpcResult}, 7 - types::sh_tangled::repo::tags::{Input, Output}, 8 - }; 5 + use crate::model::Knot; 6 + use crate::model::repository::TangledRepository; 7 + use crate::public::xrpc::XrpcQuery; 8 + use crate::public::xrpc::XrpcResult; 9 + use crate::types::sh_tangled::repo::tags::Input; 10 + use crate::types::sh_tangled::repo::tags::Output; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.tags"; 11 13
+8 -6
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_tree.rs
··· 1 - use axum::{Json, extract::State}; 1 + use axum::Json; 2 + use axum::extract::State; 2 3 use tokio_rayon::AsyncThreadPool as _; 3 4 4 - use crate::{ 5 - lexicon::sh_tangled::repo::tree::{Input, Output}, 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 5 + use crate::lexicon::sh_tangled::repo::tree::Input; 6 + use crate::lexicon::sh_tangled::repo::tree::Output; 7 + use crate::model::Knot; 8 + use crate::model::repository::TangledRepository; 9 + use crate::public::xrpc::XrpcQuery; 10 + use crate::public::xrpc::XrpcResult; 9 11 10 12 pub const LXM: &str = "/sh.tangled.repo.tree"; 11 13
+2 -1
crates/gordian-knot/src/services/atrepo.rs
··· 1 - use crate::lexicon::com::atproto::repo::list_records; 2 1 use bytes::Bytes; 3 2 use gix::bstr::ByteSlice as _; 4 3 use gordian_identity::HttpClient; 5 4 use gordian_types::Did; 5 + 6 + use crate::lexicon::com::atproto::repo::list_records; 6 7 7 8 #[derive(Debug, thiserror::Error)] 8 9 pub enum Error<E> {
+13 -16
crates/gordian-knot/src/services/authorization.rs
··· 1 1 use core::fmt; 2 2 3 - use axum::{ 4 - extract::{FromRef, FromRequestParts}, 5 - http::{ 6 - header::{AUTHORIZATION, AsHeaderName}, 7 - request::Parts, 8 - }, 9 - }; 3 + use axum::extract::FromRef; 4 + use axum::extract::FromRequestParts; 5 + use axum::http::header::AUTHORIZATION; 6 + use axum::http::header::AsHeaderName; 7 + use axum::http::request::Parts; 10 8 use futures_util::future::BoxFuture; 11 - use gordian_auth::{ 12 - IntoVerificationKey as _, 13 - jwt::{Claims, Token, decode}, 14 - }; 9 + use gordian_auth::IntoVerificationKey as _; 10 + use gordian_auth::jwt::Claims; 11 + use gordian_auth::jwt::Token; 12 + use gordian_auth::jwt::decode; 15 13 use gordian_identity::Resolver; 16 14 use gordian_types::Nsid; 17 15 use time::OffsetDateTime; 18 16 19 - use crate::{ 20 - model::{Knot, errors}, 21 - public::xrpc::XrpcError, 22 - }; 17 + use crate::model::Knot; 18 + use crate::model::errors; 19 + use crate::public::xrpc::XrpcError; 23 20 24 21 #[derive(Debug, thiserror::Error)] 25 22 #[error("transparent")] ··· 179 182 let verification_keys = doc 180 183 .verification_method 181 184 .into_iter() 182 - .filter_map(|vm| vm.into_verification_key().ok()); 185 + .filter_map(|vm| vm.to_verification_key().ok()); 183 186 184 187 // @TODO (Maybe) allow the Verifier to restrict acceptable signature algorithms. 185 188
+19 -15
crates/gordian-knot/src/services/database.rs
··· 1 1 // mod pg_impl; 2 2 pub mod types; 3 3 4 - use futures_util::{StreamExt, stream::BoxStream}; 4 + use futures_util::StreamExt; 5 + use futures_util::stream::BoxStream; 5 6 use gordian_auth::jwt; 6 7 use gordian_jetstream::Value; 7 - use gordian_lexicon::sh_tangled::{PublicKey, knot::Member, repo::Repo}; 8 - use gordian_types::{Did, DidBuf}; 8 + use gordian_lexicon::sh_tangled::PublicKey; 9 + use gordian_lexicon::sh_tangled::knot::Member; 10 + use gordian_lexicon::sh_tangled::repo::Repo; 11 + use gordian_types::Did; 12 + use gordian_types::DidBuf; 9 13 use serde::Serialize; 10 - use sqlx::{SqlitePool, error::ErrorKind}; 14 + use sqlx::SqlitePool; 15 + use sqlx::error::ErrorKind; 11 16 use time::OffsetDateTime; 12 - use types::{DeletedRecord, EventRow}; 17 + use types::DeletedRecord; 18 + use types::EventRow; 13 19 14 20 use crate::types::RecordKey; 15 21 ··· 75 69 .boxed() 76 70 } 77 71 78 - /// Get all the knot members and repository collaborators associated with the knot. 72 + /// Get all the knot members and repository collaborators associated with 73 + /// the knot. 79 74 pub fn members(&self) -> BoxStream<'_, Result<DidBuf, DataStoreError>> { 80 75 sqlx::query!(r#"SELECT DISTINCT subject AS "subject: DidBuf" FROM knot_member UNION SELECT DISTINCT subject AS "subject: DidBuf" FROM repository_collaborator"#) 81 76 .fetch(&self.db) ··· 86 79 87 80 /// Upsert a knot member. 88 81 /// 89 - /// Returns `true` if the member record was newly inserted/updated, or `false` if 90 - /// the member was already present in the database. 91 - /// 82 + /// Returns `true` if the member record was newly inserted/updated, or 83 + /// `false` if the member was already present in the database. 92 84 pub async fn upsert_knot_member( 93 85 &self, 94 86 rkey: &str, ··· 141 135 142 136 /// Upsert a repository collaborator. 143 137 /// 144 - /// Returns `true` if the collaborator record was newly inserted/updated, or `false` if 145 - /// the collaborator was already present in the database. 146 - /// 138 + /// Returns `true` if the collaborator record was newly inserted/updated, or 139 + /// `false` if the collaborator was already present in the database. 147 140 pub async fn upsert_repository_collaborator( 148 141 &self, 149 142 did: &Did, ··· 486 481 Ok(self.tx.commit().await?) 487 482 } 488 483 489 - /// Insert a new repository entry from a jetstream commit, returning `true` if the repository 490 - /// appears to be new. 484 + /// Insert a new repository entry from a jetstream commit, returning `true` 485 + /// if the repository appears to be new. 491 486 /// 492 487 /// # Note 493 488 /// 494 489 /// This is *not* an UPSERT. 495 - /// 496 490 pub async fn insert_repository( 497 491 &mut self, 498 492 did: &Did,
+4 -2
crates/gordian-knot/src/services/database/types.rs
··· 1 - use crate::lexicon::sh_tangled::PublicKey; 2 - use gordian_types::{Did, DidBuf}; 1 + use gordian_types::Did; 2 + use gordian_types::DidBuf; 3 3 use time::OffsetDateTime; 4 + 5 + use crate::lexicon::sh_tangled::PublicKey; 4 6 5 7 /// A flattened public key record. 6 8 pub struct PublicKeyRecordRef<'a> {
+4 -2
crates/gordian-knot/src/services/git.rs
··· 1 1 use std::io; 2 2 3 - use axum::body::{BodyDataStream, HttpBody as _}; 4 - use tokio::{io::AsyncWriteExt as _, net::unix::pipe::Sender}; 3 + use axum::body::BodyDataStream; 4 + use axum::body::HttpBody as _; 5 + use tokio::io::AsyncWriteExt as _; 6 + use tokio::net::unix::pipe::Sender; 5 7 6 8 pub async fn stream_to_pipe(mut body_stream: BodyDataStream, mut pipe: Sender) -> io::Result<()> { 7 9 use tokio_stream::StreamExt as _;
+20 -11
crates/gordian-knot/src/services/jetstream.rs
··· 1 - use crate::{ 2 - Lexicon, 3 - model::Knot, 4 - services::rbac::{ 5 - Action, AddCollaboratorPolicy, AddMemberPolicy, Policy, PolicyResult::*, 6 - RemoveCollaboratorPolicy, RemoveMemberPolicy, RepositoryCreatePolicy, 7 - RepositoryDeletePolicy, RepositoryRef, 8 - }, 9 - }; 1 + use std::borrow::Cow; 2 + use std::time::Duration; 3 + 10 4 use futures_util::StreamExt as _; 11 - use gordian_jetstream::{CommitEvent, Event, JetstreamClient, client_config::JetstreamConfig}; 12 - use std::{borrow::Cow, time::Duration}; 5 + use gordian_jetstream::CommitEvent; 6 + use gordian_jetstream::Event; 7 + use gordian_jetstream::JetstreamClient; 8 + use gordian_jetstream::client_config::JetstreamConfig; 13 9 use tokio::time::Instant; 14 10 use tokio_util::sync::CancellationToken; 11 + 12 + use crate::Lexicon; 13 + use crate::model::Knot; 14 + use crate::services::rbac::Action; 15 + use crate::services::rbac::AddCollaboratorPolicy; 16 + use crate::services::rbac::AddMemberPolicy; 17 + use crate::services::rbac::Policy; 18 + use crate::services::rbac::PolicyResult::*; 19 + use crate::services::rbac::RemoveCollaboratorPolicy; 20 + use crate::services::rbac::RemoveMemberPolicy; 21 + use crate::services::rbac::RepositoryCreatePolicy; 22 + use crate::services::rbac::RepositoryDeletePolicy; 23 + use crate::services::rbac::RepositoryRef; 15 24 16 25 pub fn init_consumer<T: AsRef<str>>( 17 26 knot: &Knot,
+4 -2
crates/gordian-knot/src/services/rbac.rs
··· 1 - use futures_util::{FutureExt, future::BoxFuture}; 1 + use futures_util::FutureExt; 2 + use futures_util::future::BoxFuture; 2 3 use gordian_types::Did; 3 4 4 - use crate::{model::KnotState, types::repository_key::RepositoryKey}; 5 + use crate::model::KnotState; 6 + use crate::types::repository_key::RepositoryKey; 5 7 6 8 pub trait Policy<Subject, Resource, Action, Context>: Send + Sync { 7 9 /// Evaluates whether access should be granted.
+8 -10
crates/gordian-knot/src/services/seed.rs
··· 1 - use gordian_types::{Did, Tid}; 1 + use gordian_types::Did; 2 + use gordian_types::Tid; 2 3 3 - use crate::{ 4 - lexicon::{ 5 - com::atproto::repo::list_records::Record, 6 - sh_tangled::{knot::Member, repo::Repo}, 7 - }, 8 - model::Knot, 9 - services::atrepo, 10 - types::RecordKey, 11 - }; 4 + use crate::lexicon::com::atproto::repo::list_records::Record; 5 + use crate::lexicon::sh_tangled::knot::Member; 6 + use crate::lexicon::sh_tangled::repo::Repo; 7 + use crate::model::Knot; 8 + use crate::services::atrepo; 9 + use crate::types::RecordKey; 12 10 13 11 pub async fn all(knot: &Knot) -> anyhow::Result<()> { 14 12 let knot = knot.clone();
-1
crates/gordian-knot/src/sync.rs
··· 1 1 //! 2 2 //! Atmosphere synchronization. 3 - //! 4 3 5 4 pub mod tap;
+1
crates/gordian-knot/src/sync/tap.rs
··· 1 +
+14 -10
crates/gordian-knot/src/tests.rs
··· 1 + use axum::body::Body; 2 + use axum::http::Request; 3 + use axum::http::StatusCode; 1 4 use gordian_auth::jwt::Claims; 2 5 use gordian_lexicon::sh_tangled; 3 - use gordian_types::{Did, Tid}; 4 - 5 - use axum::{ 6 - body::Body, 7 - http::{Request, StatusCode}, 8 - }; 9 - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; 6 + use gordian_types::Did; 7 + use gordian_types::Tid; 8 + use time::OffsetDateTime; 9 + use time::format_description::well_known::Rfc3339; 10 10 use tower::ServiceExt; 11 11 12 12 use crate::model::Knot; ··· 103 103 } 104 104 105 105 mod sh_tangled_repo_create { 106 - use crate::nsid::{SH_TANGLED_REPO_CREATE, SH_TANGLED_REPO_DELETE}; 106 + use axum::http::HeaderValue; 107 + use axum::http::Method; 108 + use axum::http::Response; 109 + use axum::http::header; 110 + use gordian_pds::Pds; 107 111 108 112 use super::super::public; 109 113 use super::*; 110 - use axum::http::{HeaderValue, Method, Response, header}; 111 - use gordian_pds::Pds; 114 + use crate::nsid::SH_TANGLED_REPO_CREATE; 115 + use crate::nsid::SH_TANGLED_REPO_DELETE; 112 116 113 117 fn make_claims<F>(iss: &Did, aud: &Did, modify_claims: F) -> Claims 114 118 where
+3 -1
crates/gordian-knot/src/types.rs
··· 1 1 use core::fmt; 2 2 3 + use gordian_types::Did; 4 + use gordian_types::RecordUri; 5 + 3 6 use crate::lexicon::com::atproto::repo::list_records::Record; 4 - use gordian_types::{Did, RecordUri}; 5 7 6 8 pub mod push_certificate; 7 9 pub mod repository_key;
+6 -4
crates/gordian-knot/src/types/push_certificate.rs
··· 51 51 Bad, 52 52 /// `git push --signed` sent the nonce we asked it to send 53 53 Ok, 54 - /// `git push --signed` send a nonce different from what we asked it to send now, but 55 - /// in a previous session. 54 + /// `git push --signed` send a nonce different from what we asked it to send 55 + /// now, but in a previous session. 56 56 Slop, 57 57 } 58 58 ··· 70 70 } 71 71 } 72 72 73 - /// Push certificate assembled from environment variables passed to a git pre-receive hook. 73 + /// Push certificate assembled from environment variables passed to a git 74 + /// pre-receive hook. 74 75 /// 75 76 /// See: `man 1 git-receive-pack` 76 77 #[derive(Debug)] 77 78 pub struct PushCertificate<'a> { 78 79 /// Object ID of the push certificate. 79 80 /// 80 - /// The certificate may be read from the git repository as a blob using this ID. 81 + /// The certificate may be read from the git repository as a blob using this 82 + /// ID. 81 83 pub cert: &'a str, 82 84 pub key: &'a str, 83 85 pub signer: &'a str,
+2 -1
crates/gordian-knot/src/types/repository_key.rs
··· 3 3 use gordian_types::DidBuf; 4 4 use serde::Deserialize; 5 5 6 - use super::repository_path::{Error, validate}; 6 + use super::repository_path::Error; 7 + use super::repository_path::validate; 7 8 8 9 #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize)] 9 10 #[serde(try_from = "UnvalidatedRepositoryKey")]
+2 -1
crates/gordian-knot/src/types/repository_path.rs
··· 132 132 mod tests { 133 133 use std::str::FromStr; 134 134 135 - use super::{Error, RepositoryPath}; 135 + use super::Error; 136 + use super::RepositoryPath; 136 137 137 138 #[test] 138 139 fn can_parse_from_string() {
+12 -11
crates/gordian-knot/src/types/sh_tangled.rs
··· 1 1 pub mod repo { 2 2 pub mod branches { 3 - use crate::lexicon::sh_tangled::repo::refs; 4 3 use serde::Serialize; 5 4 6 5 pub use crate::lexicon::sh_tangled::repo::branches::Input; 6 + use crate::lexicon::sh_tangled::repo::refs; 7 7 8 8 /// Output of `sh.tangled.repo.branches` query. 9 9 #[derive(Debug, Default, Serialize)] ··· 26 26 } 27 27 28 28 pub mod compare { 29 - use crate::lexicon::extra::objectid::ObjectId; 30 29 use serde::Serialize; 31 30 31 + use crate::lexicon::extra::objectid::ObjectId; 32 32 pub use crate::lexicon::sh_tangled::repo::compare::Input; 33 33 34 34 #[derive(Debug, Serialize)] ··· 45 45 } 46 46 47 47 pub mod diff { 48 - use crate::lexicon::{extra::objectid::ObjectId, sh_tangled::repo::refs}; 49 - use serde::Serialize; 50 48 use std::borrow::Cow; 51 49 50 + use serde::Serialize; 51 + 52 + use crate::lexicon::extra::objectid::ObjectId; 52 53 pub use crate::lexicon::sh_tangled::repo::diff::Input; 54 + use crate::lexicon::sh_tangled::repo::refs; 53 55 54 56 #[derive(Debug, Serialize)] 55 57 pub struct Output { ··· 158 156 } 159 157 160 158 pub mod log { 161 - use crate::lexicon::sh_tangled::repo::refs; 162 159 use serde::Serialize; 163 160 164 161 pub use crate::lexicon::sh_tangled::repo::log::Input; 162 + use crate::lexicon::sh_tangled::repo::refs; 165 163 166 164 #[derive(Debug, Serialize)] 167 165 pub struct Output { ··· 174 172 } 175 173 176 174 pub mod tags { 177 - use crate::lexicon::{ 178 - extra::objectid::{Array, ObjectId}, 179 - sh_tangled::repo::refs, 180 - }; 181 175 use serde::Serialize; 182 176 177 + use crate::lexicon::extra::objectid::Array; 178 + use crate::lexicon::extra::objectid::ObjectId; 179 + use crate::lexicon::sh_tangled::repo::refs; 183 180 pub use crate::lexicon::sh_tangled::repo::tags::Input; 184 181 185 182 /// Output of `sh.tangled.repo.tags` query. 186 183 /// 187 - /// This is not defined in the lexicon, but models what knotserver currently 188 - /// produces. 184 + /// This is not defined in the lexicon, but models what knotserver 185 + /// currently produces. 189 186 #[derive(Debug, Serialize)] 190 187 pub struct Output { 191 188 pub tags: Vec<Tag>,
+33
crates/gordian-knot/src/ui.rs
··· 1 + use maud::html; 2 + 3 + pub fn section_pane(content: maud::Markup) -> maud::Markup { 4 + html! { 5 + div class="mt-4 sm:mx-auto sm:w-full sm:max-w-[640px]" { 6 + div class="bg-white px-6 py-12 shadow-sm sm:rounded-xs sm:px-12 dark:bg-slate-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10" { 7 + (content) 8 + } 9 + } 10 + } 11 + } 12 + 13 + pub fn tick() -> maud::Markup { 14 + html! { 15 + svg 16 + stroke="currentColor" 17 + fill="currentColor" 18 + stroke-width="0" 19 + viewBox="0 0 24 24" 20 + height="1em" 21 + width="1em" 22 + xmlns="http://www.w3.org/2000/svg" { 23 + path 24 + d="M9.9997 15.1709L19.1921 5.97852L20.6063 7.39273L9.9997 17.9993L3.63574 11.6354L5.04996 10.2212L9.9997 15.1709Z" { } 25 + } 26 + } 27 + } 28 + 29 + pub fn script(script: &str) -> maud::Markup { 30 + html! { 31 + script { (maud::PreEscaped(script)) } 32 + } 33 + }
+83
crates/gordian-knot/tailwind.css
··· 1 + @import "tailwindcss"; 2 + @source "src/"; 3 + 4 + @layer base { 5 + label { 6 + @apply 7 + block 8 + text-sm/6 9 + font-medium 10 + text-slate-900 11 + dark:text-white; 12 + } 13 + 14 + input { 15 + @apply 16 + block 17 + w-full 18 + px-3 19 + py-1.5 20 + rounded-xs 21 + bg-white 22 + text-base 23 + text-slate-900 24 + outline-1 25 + -outline-offset-1 26 + outline-slate-300 27 + placeholder:text-slate-400 28 + focus:outline-2 29 + focus:-outline-offset-2 30 + focus:outline-zinc-600 31 + sm:text-sm/6 32 + dark:bg-white/5 33 + dark:text-white 34 + dark:outline-white/10 35 + dark:placeholder:text-slate-500 36 + dark:focus:outline-zinc-500; 37 + } 38 + 39 + input[disabled] { 40 + @apply 41 + text-slate-500 42 + cursor-not-allowed 43 + dark:bg-white/10 44 + ; 45 + } 46 + 47 + input[aria-invalid="true"], input:invalid { 48 + @apply 49 + text-base 50 + sm:text-sm/6 51 + text-red-500 52 + outline-red-500 53 + placeholder:text-red-500 54 + focus:outline-red-500 55 + dark:text-red-500 56 + dark:outline-red-500 57 + dark:placeholder:text-red-500 58 + dark:focus:outline-red-500; 59 + } 60 + 61 + button[type="submit"] { 62 + @apply 63 + flex 64 + w-full 65 + justify-center 66 + rounded-xs 67 + bg-rose-600 68 + px-3 69 + py-1.5 70 + text-sm/6 71 + font-semibold 72 + text-white 73 + shadow-xs 74 + hover:bg-zinc-500 75 + focus-visible:outline-2 76 + focus-visible:outline-offset-2 77 + focus-visible:outline-zinc-600 78 + dark:bg-rose-500 79 + dark:shadow-none 80 + dark:hover:bg-rose-400 81 + dark:focus-visible:outline-rose-500; 82 + } 83 + }
-1
crates/gordian-lexicon/src/com/atproto/repo.rs
··· 4 4 //! collection. Does not require auth. 5 5 //! 6 6 //! <https://docs.bsky.app/docs/api/com-atproto-repo-list-records> 7 - //! 8 7 use gordian_types::RecordUri; 9 8 10 9 #[derive(Debug, serde::Deserialize, serde::Serialize)]
+7 -3
crates/gordian-lexicon/src/extra/objectid.rs
··· 1 - use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeSeq}; 2 - use std::{marker::PhantomData, str::FromStr}; 1 + use std::marker::PhantomData; 2 + use std::str::FromStr; 3 + 4 + use serde::Deserialize; 5 + use serde::Serialize; 6 + use serde::de::Visitor; 7 + use serde::ser::SerializeSeq; 3 8 4 9 #[doc(hidden)] 5 10 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] ··· 15 10 /// An object ID that can serialized as a hex-string or an array of integers. 16 11 /// 17 12 /// This only exists because knotserver uses both representations. 18 - /// 19 13 #[derive(Clone, Hash, PartialEq, Eq)] 20 14 pub struct ObjectId<E = Hex> { 21 15 inner: gix_hash::ObjectId,
-1
crates/gordian-lexicon/src/lib.rs
··· 3 3 //! 4 4 //! When you're too lazy to setup codegen, not lazy enough to not write them 5 5 //! all out manually. 6 - //! 7 6 pub mod com; 8 7 pub mod sh_tangled; 9 8
+6 -4
crates/gordian-lexicon/src/sh_tangled.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/tree/master/lexicons> 3 - //! 4 3 pub mod actor; 5 4 pub mod feed; 6 5 pub mod git; ··· 9 10 pub mod spindle; 10 11 pub mod string; 11 12 12 - use gordian_types::Did; 13 - use serde::{Deserialize, Serialize}; 14 13 use std::borrow::Cow; 14 + 15 + use gordian_types::Did; 16 + use serde::Deserialize; 17 + use serde::Serialize; 15 18 use time::OffsetDateTime; 16 19 17 20 pub mod owner { 18 21 use gordian_types::Did; 19 - use serde::{Deserialize, Serialize}; 22 + use serde::Deserialize; 23 + use serde::Serialize; 20 24 21 25 /// XRPC query `sh.tangled.owner` output. 22 26 ///
+3 -2
crates/gordian-lexicon/src/sh_tangled/actor.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/tree/master/lexicons/actor> 3 - //! 4 - use serde::{Deserialize, Serialize}; 5 3 use std::borrow::Cow; 4 + 5 + use serde::Deserialize; 6 + use serde::Serialize; 6 7 7 8 /// `sh.tangled.actor.profile` record. 8 9 ///
+4 -3
crates/gordian-lexicon/src/sh_tangled/feed.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/tree/master/lexicons/feed> 3 - //! 4 - use gordian_types::RecordUri; 5 - use serde::{Deserialize, Serialize}; 6 3 use std::borrow::Cow; 4 + 5 + use gordian_types::RecordUri; 6 + use serde::Deserialize; 7 + use serde::Serialize; 7 8 use time::OffsetDateTime; 8 9 9 10 /// `sh.tangled.feed.reaction` record.
+2 -1
crates/gordian-lexicon/src/sh_tangled/git.rs
··· 1 1 use gordian_types::DidBuf; 2 - use serde::{Deserialize, Serialize}; 2 + use serde::Deserialize; 3 + use serde::Serialize; 3 4 4 5 use crate::extra::objectid::ObjectId; 5 6
-2
crates/gordian-lexicon/src/sh_tangled/graph.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/tree/master/lexicons/graph> 3 - //! 4 3 use std::borrow::Cow; 5 4 6 5 /// `sh.tangled.graph.follow` record. 7 6 /// 8 7 /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/graph/follow.json> 9 - /// 10 8 #[derive(Debug, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)] 11 9 #[serde(rename_all = "camelCase")] 12 10 pub struct Follow<'a> {
+7 -3
crates/gordian-lexicon/src/sh_tangled/knot.rs
··· 1 - use gordian_types::Did; 2 - use serde::{Deserialize, Serialize}; 3 1 use std::borrow::Cow; 2 + 3 + use gordian_types::Did; 4 + use serde::Deserialize; 5 + use serde::Serialize; 4 6 use time::OffsetDateTime; 5 7 6 8 pub mod version { 7 - use serde::{Deserialize, Serialize}; 8 9 use std::borrow::Cow; 10 + 11 + use serde::Deserialize; 12 + use serde::Serialize; 9 13 10 14 /// XRPC query `sh.tangled.knot.version` output. 11 15 #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)]
+12 -4
crates/gordian-lexicon/src/sh_tangled/repo.rs
··· 16 16 pub mod tags; 17 17 pub mod tree; 18 18 19 - use gordian_types::{Did, DidBuf, RecordUri}; 20 - use serde::{Deserialize, Serialize}; 21 19 use std::borrow::Cow; 20 + 21 + use gordian_types::Did; 22 + use gordian_types::DidBuf; 23 + use gordian_types::RecordUri; 24 + use serde::Deserialize; 25 + use serde::Serialize; 22 26 use time::OffsetDateTime; 23 27 24 28 use crate::extra::objectid::ObjectId; ··· 155 151 156 152 /// Lexicon sub-types. 157 153 pub mod refs { 158 - use crate::extra::objectid::{Array, ObjectId}; 154 + use std::borrow::Cow; 155 + use std::collections::HashMap; 156 + 159 157 use serde::Serialize; 160 - use std::{borrow::Cow, collections::HashMap}; 161 158 use time::OffsetDateTime; 159 + 160 + use crate::extra::objectid::Array; 161 + use crate::extra::objectid::ObjectId; 162 162 163 163 #[derive(Debug, Default, Serialize)] 164 164 #[serde(rename_all = "camelCase")]
-1
crates/gordian-lexicon/src/sh_tangled/repo/archive.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/archive.json> 3 - //! 4 3 5 4 use core::fmt; 6 5
+4 -4
crates/gordian-lexicon/src/sh_tangled/repo/blob.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/blob.json> 3 - //! 4 - use std::{borrow::Cow, path::PathBuf}; 3 + use std::borrow::Cow; 4 + use std::path::PathBuf; 5 5 6 6 use time::OffsetDateTime; 7 7 8 - use crate::extra::objectid::{Hex, ObjectId}; 9 - 10 8 use super::refs; 9 + use crate::extra::objectid::Hex; 10 + use crate::extra::objectid::ObjectId; 11 11 12 12 #[derive(Debug, serde::Deserialize)] 13 13 pub struct Input {
+4 -4
crates/gordian-lexicon/src/sh_tangled/repo/branch.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/branch.json> 3 - //! 4 3 5 - use serde::{Deserialize, Serialize}; 4 + use serde::Deserialize; 5 + use serde::Serialize; 6 6 use time::OffsetDateTime; 7 7 8 - use crate::{extra::objectid::ObjectId, sh_tangled::repo::refs::Signature}; 8 + use crate::extra::objectid::ObjectId; 9 + use crate::sh_tangled::repo::refs::Signature; 9 10 10 11 /// Parameters for the `sh.tangled.repo.branch` query. 11 12 /// ··· 31 30 32 31 // @NOTE Refusing to send redundant data 33 32 // pub short_hash: String, 34 - // 35 33 /// Timestamp of the latest commit. 36 34 #[serde(with = "time::serde::rfc3339")] 37 35 pub when: OffsetDateTime,
-1
crates/gordian-lexicon/src/sh_tangled/repo/branches.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/branches.json> 3 - //! 4 3 5 4 const LIMIT_MIN: u16 = 1; 6 5 const LIMIT_MAX: u16 = 100;
-1
crates/gordian-lexicon/src/sh_tangled/repo/compare.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/compare.json> 3 - //! 4 3 5 4 /// Parameters for the `sh.tangled.repo.compare` query. 6 5 ///
+4 -3
crates/gordian-lexicon/src/sh_tangled/repo/create.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/create.json> 3 - //! 4 3 5 - use serde::{Deserialize, Serialize}; 4 + use serde::Deserialize; 5 + use serde::Serialize; 6 6 7 7 /// Parameters for the `sh.tangled.repo.create` procedure. 8 8 /// ··· 17 17 #[serde(skip_serializing_if = "Option::is_none")] 18 18 pub default_branch: Option<String>, 19 19 20 - /// A source URL to clone from , populate this when forking or importing a repository. 20 + /// A source URL to clone from , populate this when forking or importing a 21 + /// repository. 21 22 #[serde(skip_serializing_if = "Option::is_none")] 22 23 pub source: Option<String>, 23 24 }
+2 -2
crates/gordian-lexicon/src/sh_tangled/repo/delete.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/delete.json> 3 - //! 4 3 5 4 use gordian_types::DidBuf; 6 - use serde::{Deserialize, Serialize}; 5 + use serde::Deserialize; 6 + use serde::Serialize; 7 7 8 8 /// Parameters for the `sh.tangled.repo.delete` procedure. 9 9 ///
-1
crates/gordian-lexicon/src/sh_tangled/repo/diff.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/diff.json> 3 - //! 4 3 5 4 #[derive(Debug, serde::Deserialize)] 6 5 pub struct Input {
+4 -2
crates/gordian-lexicon/src/sh_tangled/repo/get_default_branch.rs
··· 1 - use crate::extra::objectid::{Hex, ObjectId}; 2 - use serde::{Deserialize, Serialize}; 1 + use serde::Deserialize; 2 + use serde::Serialize; 3 3 use time::OffsetDateTime; 4 4 5 5 use super::refs; 6 + use crate::extra::objectid::Hex; 7 + use crate::extra::objectid::ObjectId; 6 8 7 9 #[derive(Debug, Deserialize)] 8 10 pub struct Input {
+5 -3
crates/gordian-lexicon/src/sh_tangled/repo/issue.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/tree/master/lexicons/issue> 3 - //! 4 - use gordian_types::{DidBuf, RecordUri}; 5 - use serde::{Deserialize, Serialize}; 6 3 use std::borrow::Cow; 4 + 5 + use gordian_types::DidBuf; 6 + use gordian_types::RecordUri; 7 + use serde::Deserialize; 8 + use serde::Serialize; 7 9 use time::OffsetDateTime; 8 10 9 11 /// `sh.tangled.repo.issue.comment` record.
-1
crates/gordian-lexicon/src/sh_tangled/repo/languages.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/languages.json> 3 - //! 4 3 5 4 #[derive(Debug, serde::Deserialize)] 6 5 pub struct Input {
-1
crates/gordian-lexicon/src/sh_tangled/repo/log.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/log.json> 3 - //! 4 3 use std::path::PathBuf; 5 4 6 5 const LIMIT_MIN: u16 = 1;
+2 -2
crates/gordian-lexicon/src/sh_tangled/repo/merge_check.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/mergeCheck.json> 3 - //! 4 3 use std::borrow::Cow; 5 4 6 5 use gordian_types::DidBuf; 7 - use serde::{Deserialize, Serialize}; 6 + use serde::Deserialize; 7 + use serde::Serialize; 8 8 9 9 /// Parameters for the `sh.tangled.repo.mergeCheck` query. 10 10 ///
+2 -2
crates/gordian-lexicon/src/sh_tangled/repo/pull.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/tree/master/lexicons/pulls> 3 - //! 4 3 use std::borrow::Cow; 5 4 6 - use serde::{Deserialize, Serialize}; 5 + use serde::Deserialize; 6 + use serde::Serialize; 7 7 8 8 /// `sh.tangled.repo.pull.comment` record. 9 9 ///
-1
crates/gordian-lexicon/src/sh_tangled/repo/set_default_branch.rs
··· 4 4 /// Parameters for the `sh.tangled.repo.setDefaultBranch` procedure. 5 5 /// 6 6 /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/defaultBranch.json> 7 - /// 8 7 #[derive(Debug, Deserialize)] 9 8 #[serde(rename_all = "camelCase")] 10 9 pub struct Input {
-1
crates/gordian-lexicon/src/sh_tangled/repo/tags.rs
··· 1 1 //! 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/tags.json> 3 - //! 4 3 5 4 const LIMIT_MIN: u16 = 1; 6 5 const LIMIT_MAX: u16 = 100;
-1
crates/gordian-lexicon/src/sh_tangled/repo/tree.rs
··· 1 1 //! 2 2 //! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/tree.json> 3 - //! 4 3 use std::path::PathBuf; 5 4 6 5 use crate::extra::objectid::ObjectId;
+4 -2
crates/gordian-lexicon/src/sh_tangled/spindle.rs
··· 1 - use gordian_types::Did; 2 - use serde::{Deserialize, Serialize}; 3 1 use std::borrow::Cow; 2 + 3 + use gordian_types::Did; 4 + use serde::Deserialize; 5 + use serde::Serialize; 4 6 use time::OffsetDateTime; 5 7 6 8 /// `sh.tangled.spindle` record.
+3 -1
crates/gordian-lexicon/src/sh_tangled/string.rs
··· 1 - use serde::{Deserialize, Serialize}; 2 1 use std::borrow::Cow; 2 + 3 + use serde::Deserialize; 4 + use serde::Serialize; 3 5 use time::OffsetDateTime; 4 6 5 7 /// `sh.tangled.string` record.
+1 -1
crates/gordian-pds/Cargo.toml
··· 12 12 gordian-auth = { workspace = true } 13 13 gordian-identity = { workspace = true } 14 14 15 - aws-lc-rs = "1.15.4" 15 + aws-lc-rs.workspace = true 16 16 axum.workspace = true 17 17 data-encoding.workspace = true 18 18 futures-util = "0.3.31"
+7 -6
crates/gordian-pds/src/api.rs
··· 9 9 10 10 pub mod com_atproto { 11 11 pub mod repo { 12 - use axum::{ 13 - Json, Router, 14 - extract::{FromRef, Query, State}, 15 - http::StatusCode, 16 - response::IntoResponse, 17 - }; 12 + use axum::Json; 13 + use axum::Router; 14 + use axum::extract::FromRef; 15 + use axum::extract::Query; 16 + use axum::extract::State; 17 + use axum::http::StatusCode; 18 + use axum::response::IntoResponse; 18 19 use gordian_types::DidBuf; 19 20 use serde_json::Value; 20 21 use sqlx::Row as _;
+28 -44
crates/gordian-pds/src/state.rs
··· 1 - use std::{fmt::Debug, net::SocketAddr, sync::Arc}; 1 + use std::fmt::Debug; 2 + use std::net::SocketAddr; 3 + use std::sync::Arc; 2 4 3 - use aws_lc_rs::{ 4 - encoding::{AsBigEndian as _, EcPublicKeyCompressedBin}, 5 - rand::SystemRandom, 6 - signature::{ECDSA_P256K1_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _}, 7 - }; 5 + use aws_lc_rs::encoding::AsBigEndian as _; 6 + use aws_lc_rs::encoding::EcPublicKeyCompressedBin; 7 + use aws_lc_rs::signature::ECDSA_P256K1_SHA256_FIXED_SIGNING; 8 + use aws_lc_rs::signature::EcdsaKeyPair; 9 + use aws_lc_rs::signature::KeyPair as _; 8 10 use futures_util::FutureExt as _; 9 11 use gordian_auth::jwt; 10 12 use gordian_identity::DidDocument; 11 - use gordian_types::{DidBuf, Tid}; 12 - use sqlx::{ 13 - SqlitePool, 14 - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, 15 - types::time::OffsetDateTime, 16 - }; 17 - use tokio::{ 18 - net::TcpListener, 19 - sync::broadcast::{self, Receiver, Sender}, 20 - }; 13 + use gordian_types::DidBuf; 14 + use gordian_types::Tid; 15 + use sqlx::SqlitePool; 16 + use sqlx::sqlite::SqliteConnectOptions; 17 + use sqlx::sqlite::SqlitePoolOptions; 18 + use sqlx::types::time::OffsetDateTime; 19 + use tokio::net::TcpListener; 20 + use tokio::sync::broadcast::Receiver; 21 + use tokio::sync::broadcast::Sender; 22 + use tokio::sync::broadcast::{self}; 21 23 22 24 pub type Event = (); 23 25 ··· 44 42 .expect("service endpoint should be a valid URL") 45 43 } 46 44 47 - /// Add a DID document created from `did`, `handle`, and a random ecdsa key-pair to the PDS. 45 + /// Add a DID document created from `did`, `handle`, and a random ecdsa 46 + /// key-pair to the PDS. 48 47 /// 49 - /// The internal address of the mock PDS will be set as the "#atproto_pds" service for 50 - /// the new identity. 51 - /// 48 + /// The internal address of the mock PDS will be set as the "#atproto_pds" 49 + /// service for the new identity. 52 50 pub async fn insert_identity(&self, did: &gordian_types::Did, handle: &str) { 53 51 let mut doc = DidDocument::new(did, handle).expect("valid did for did document"); 54 52 doc.service.push(gordian_identity::Service::atproto_pds( ··· 123 121 124 122 // Create an inter-service auth header for an account in the fake PDS. 125 123 pub async fn service_auth(&self, claims: &jwt::Claims) -> String { 126 - use data_encoding::BASE64URL_NOPAD as Encoding; 127 124 use sqlx::Row as _; 128 125 129 - let mut token = String::new(); 130 - let header = Encoding.encode( 131 - &serde_json::to_vec(&jwt::Header { 132 - typ: jwt::Type::JWT, 133 - alg: jwt::Algorithm::ES256K, 134 - crv: None, 135 - }) 136 - .unwrap(), 137 - ); 138 - 139 - token.push_str(&header); 140 - 141 - let claims_enc = Encoding.encode(&serde_json::to_vec(claims).unwrap()); 142 - token.push('.'); 143 - token.push_str(&claims_enc); 126 + let header = jwt::Header { 127 + typ: jwt::Type::Jwt, 128 + alg: jwt::Algorithm::ES256K, 129 + ..Default::default() 130 + }; 144 131 145 132 let result = sqlx::query("SELECT key FROM identity WHERE did = ?") 146 133 .bind(claims.iss.as_ref()) ··· 141 150 let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256K1_SHA256_FIXED_SIGNING, pkcs8) 142 151 .expect("PKCSv8 key must be valid"); 143 152 144 - let signature = key_pair 145 - .sign(&SystemRandom::new(), token.as_bytes()) 146 - .unwrap(); 147 - 148 - let signature = Encoding.encode(signature.as_ref()); 149 - token.push('.'); 150 - token.push_str(&signature); 151 - 153 + let token = jwt::encode_and_sign(header, claims, &key_pair).unwrap(); 152 154 format!("Bearer {token}") 153 155 } 154 156 }
+8 -5
crates/gordian-types/src/aturi.rs
··· 1 1 //! AT-protocol specific URI. 2 2 //! 3 3 //! <https://atproto.com/specs/at-uri-scheme> 4 - //! 5 4 use core::fmt; 6 5 7 6 #[cfg(feature = "serde")] 8 - use serde::{Deserialize, Serialize}; 7 + use serde::Deserialize; 8 + #[cfg(feature = "serde")] 9 + use serde::Serialize; 9 10 10 - use crate::{did::Did, handle::Handle, nsid::Nsid}; 11 + use crate::did::Did; 12 + use crate::handle::Handle; 13 + use crate::nsid::Nsid; 11 14 12 15 #[derive(Debug, Hash, PartialEq, Eq)] 13 16 pub struct AtUri<'a> { ··· 94 91 /// # Errors 95 92 /// 96 93 /// Returns an error if `uri` is not a valid AT-URI. 97 - /// 98 94 pub fn parse(uri: &'a str) -> Result<Self, Error> { 99 95 let uri = uri.strip_prefix("at://").ok_or(Error::InvalidScheme)?; 100 96 let mut parts = uri.split('/'); ··· 155 153 156 154 #[cfg(test)] 157 155 mod tests { 158 - use super::{AtUri, Error}; 156 + use super::AtUri; 157 + use super::Error; 159 158 160 159 #[test] 161 160 fn valid_samples() {
+10 -13
crates/gordian-types/src/did.rs
··· 1 - use core::{borrow, fmt, ops}; 1 + use core::borrow; 2 + use core::fmt; 3 + use core::ops; 2 4 3 5 #[cfg(feature = "serde")] 4 6 pub(crate) use __serde_impl::Visitor as DidVisitor; 5 7 6 8 /// An Atmosphere DID. 7 9 /// 8 - /// This is an _unsized_ type similiar to [`str`], so must always be used behind a pointer like 9 - /// `&` or [`Box`]. 10 + /// This is an _unsized_ type similiar to [`str`], so must always be used behind 11 + /// a pointer like `&` or [`Box`]. 10 12 /// 11 13 /// For an owned variant use [`DidBuf`]. 12 14 /// ··· 25 23 /// # Panics 26 24 /// 27 25 /// Panics if `did` is not a valid DID. 28 - /// 29 26 #[must_use] 30 27 pub fn from_static(did: &'static str) -> &'static Self { 31 28 validate_did(did).expect("Hard-coded did should be valid"); ··· 47 46 /// # Errors 48 47 /// 49 48 /// Returns an error if `did` is not a valid DID. 50 - /// 51 49 pub fn parse<D: AsRef<str> + ?Sized>(did: &D) -> Result<&Self, Error> { 52 50 validate_did(did.as_ref())?; 53 51 Ok(Self::new(did)) ··· 62 62 /// let did = Did::parse("did:plc:65gha4t3avpfpzmvpbwovss7").unwrap(); 63 63 /// assert_eq!(did.typ(), "did"); 64 64 /// ``` 65 - /// 66 65 #[must_use] 67 66 #[allow(clippy::missing_panics_doc)] 68 67 pub fn typ(&self) -> &'static str { ··· 86 87 /// let did = Did::parse("did:web:tjh.dev").unwrap(); 87 88 /// assert_eq!(did.method(), "web"); 88 89 /// ``` 89 - /// 90 90 #[must_use] 91 91 pub fn method(&self) -> &str { 92 92 &self.inner[4..self.method_terminator()] ··· 104 106 /// let did = Did::parse("did:web:tjh.dev").unwrap(); 105 107 /// assert_eq!(did.ident(), "tjh.dev"); 106 108 /// ``` 107 - /// 108 109 #[must_use] 109 110 pub fn ident(&self) -> &str { 110 111 &self.inner[1 + self.method_terminator()..] ··· 174 177 } 175 178 176 179 /// Length in bytes an [`DidBuf`] can be before being allocated on the heap. 177 - /// 178 180 // Assume most DIDs are PLC method, and this DID is a typical length. 179 181 const DID_PLC_LEN: usize = "did:plc:65gha4t3avpfpzmvpbwovss7".len(); 180 182 ··· 196 200 /// # Panics 197 201 /// 198 202 /// Panics if `did` is not a valid DID. 199 - /// 200 203 #[must_use] 201 204 pub fn from_static(did: &'static str) -> Self { 202 205 validate_did(did).expect("hard-coded did should be valid"); ··· 219 224 /// # Errors 220 225 /// 221 226 /// Returns an error if `did` is not a valid DID. 222 - /// 223 227 pub fn parse<D: AsRef<str> + Into<SmallDid>>(did: D) -> Result<Self, Error> { 224 228 validate_did(did.as_ref())?; 225 229 Ok(Self::new(did)) ··· 326 332 327 333 #[cfg(feature = "sqlx")] 328 334 mod sqlx_impl { 329 - use super::{Did, DidBuf}; 335 + use super::Did; 336 + use super::DidBuf; 330 337 impl_str_wrapper_sqlx!(ref Did); 331 338 impl_str_wrapper_sqlx!(Box<Did>); 332 339 impl_str_wrapper_sqlx!(DidBuf); ··· 335 340 336 341 #[cfg(test)] 337 342 mod tests { 338 - use super::{Did, DidBuf, Error}; 343 + use super::Did; 344 + use super::DidBuf; 345 + use super::Error; 339 346 340 347 /// An example DID. 341 348 const EXAMPLE_DID: &str = "did:plc:65gha4t3avpfpzmvpbwovss7";
+5 -6
crates/gordian-types/src/handle.rs
··· 1 1 /// An Atmosphere handle. 2 2 /// 3 - /// This is an _unsized_ type similiar to [`str`], so must always be used behind a pointer like 4 - /// `&` or [`Box`]. 3 + /// This is an _unsized_ type similiar to [`str`], so must always be used behind 4 + /// a pointer like `&` or [`Box`]. 5 5 /// 6 6 /// See: <https://atproto.com/specs/handle> 7 - /// 8 7 #[derive(Hash, PartialEq, Eq, PartialOrd, Ord)] 9 8 #[repr(transparent)] 10 9 pub struct Handle { ··· 16 17 /// # Panics 17 18 /// 18 19 /// Panics if `handle` is not a valid DID. 19 - /// 20 20 #[must_use] 21 21 pub fn from_static(handle: &'static str) -> &'static Self { 22 22 validate_handle(handle).expect("Hard-coded handle should be valid"); ··· 36 38 /// # Errors 37 39 /// 38 40 /// Returns an error if `handle` is not a valid handle. 39 - /// 40 41 pub fn parse<S: AsRef<str> + ?Sized>(handle: &S) -> Result<&Self, Error> { 41 42 validate_handle(handle.as_ref())?; 42 43 Ok(Self::new(handle)) ··· 171 174 172 175 #[cfg(feature = "serde")] 173 176 mod serde_tests { 177 + use serde::Deserialize; 178 + use serde::Serialize; 179 + 174 180 use super::super::Handle; 175 - use serde::{Deserialize, Serialize}; 176 181 177 182 #[test] 178 183 fn serde_handle_buf() {
+4 -3
crates/gordian-types/src/lib.rs
··· 1 1 //! 2 2 //! Primitive types in the atmosphere. 3 - //! 4 3 #[macro_use] 5 4 mod macros; 6 5 ··· 10 11 pub mod tid; 11 12 pub mod uri; 12 13 13 - pub use did::{Did, DidBuf}; 14 + pub use did::Did; 15 + pub use did::DidBuf; 14 16 pub use handle::Handle; 15 17 pub use nsid::Nsid; 16 - pub use tid::{Tid, TidClock}; 18 + pub use tid::Tid; 19 + pub use tid::TidClock; 17 20 pub use uri::RecordUri; 18 21 19 22 #[cfg(feature = "serde")]
+2 -6
crates/gordian-types/src/nsid.rs
··· 2 2 3 3 /// An Atmosphere Namespaced Identifer. 4 4 /// 5 - /// This is an _unsized_ type similiar to [`str`], so must always be used behind a pointer like 6 - /// `&` or [`Box`]. 5 + /// This is an _unsized_ type similiar to [`str`], so must always be used behind 6 + /// a pointer like `&` or [`Box`]. 7 7 /// 8 8 /// See: <https://atproto.com/specs/nsid> 9 - /// 10 9 #[derive(Hash, PartialEq, Eq, PartialOrd, Ord)] 11 10 #[repr(transparent)] 12 11 pub struct Nsid { ··· 18 19 /// # Panics 19 20 /// 20 21 /// Panics if `nsid` is not a valid NSID. 21 - /// 22 22 #[must_use] 23 23 pub fn from_static(nsid: &'static str) -> &'static Self { 24 24 validate_nsid(nsid).expect("hard-coded NSID should be valid"); ··· 39 41 /// # Safety 40 42 /// 41 43 /// It is the callers responsibility to ensure the NSID is valid. 42 - /// 43 44 #[must_use] 44 45 pub const unsafe fn from_static_unchecked(nsid: &'static str) -> &'static Self { 45 46 unsafe { &*(ptr::from_ref::<str>(nsid) as *const Self) } ··· 57 60 /// # Errors 58 61 /// 59 62 /// Returns an error if `nsid` is not a valid NSID. 60 - /// 61 63 pub fn parse<S: AsRef<str> + ?Sized>(nsid: &S) -> Result<&Self, Error> { 62 64 validate_nsid(nsid.as_ref())?; 63 65 Ok(Self::new(nsid))
+4 -3
crates/gordian-types/src/serde.rs
··· 16 16 //! ``` 17 17 //! 18 18 //! [with]: https://serde.rs/field-attrs.html#with 19 - //! 20 19 use std::borrow::Cow; 21 20 22 - use serde::{Deserializer, Serializer}; 21 + use serde::Deserializer; 22 + use serde::Serializer; 23 23 24 - use crate::{Did, did::DidVisitor}; 24 + use crate::Did; 25 + use crate::did::DidVisitor; 25 26 26 27 #[allow(clippy::missing_errors_doc)] 27 28 pub fn deserialize<'a, 'de: 'a, D>(deserializer: D) -> Result<Cow<'a, Did>, D::Error>
+5 -17
crates/gordian-types/src/tid.rs
··· 54 54 /// # Panics 55 55 /// 56 56 /// Panics if `micros` is greater than [`Self::MAX_TIMESTAMP`]. 57 - /// 58 57 pub const fn set_micros(&mut self, micros: u64) { 59 58 assert!( 60 59 micros <= Self::MAX_TIMESTAMP, ··· 67 68 /// # Panics 68 69 /// 69 70 /// Panics if `clock_id` is greater than [`Self::MAX_CLOCK_ID`]. 70 - /// 71 71 pub const fn set_clock_id(&mut self, clock_id: u16) { 72 72 assert!( 73 73 clock_id <= Self::MAX_CLOCK_ID, ··· 83 85 /// 84 86 /// * `micros` exceeds [`Self::MAX_TIMESTAMP`]. 85 87 /// * `clock_id` exceeds [`Self::MAX_CLOCK_ID`] 86 - /// 87 88 #[must_use] 88 89 pub const fn new(micros: u64, clock_id: u16) -> Self { 89 90 let mut new = Self(0); ··· 106 109 /// 107 110 /// Panics under the following conditions: 108 111 /// 109 - /// * `seconds` exceeds [`Self::MAX_TIMESTAMP`] when converted to microseconds. 112 + /// * `seconds` exceeds [`Self::MAX_TIMESTAMP`] when converted to 113 + /// microseconds. 110 114 /// * `clock_id` exceeds [`Self::MAX_CLOCK_ID`] 111 - /// 112 115 #[must_use] 113 116 pub const fn from_secs(seconds: u64, clock_id: u16) -> Self { 114 117 Self::new(seconds * 1_000_000, clock_id) ··· 136 139 /// # Errors 137 140 /// 138 141 /// Returns an error if `tid` is not a valid TID. 139 - /// 140 142 pub fn parse(tid: &str) -> Result<Self, Error> { 141 143 parse(tid) 142 144 } ··· 147 151 /// assert_eq!(Tid::MIN.micros(), 0); 148 152 /// assert_eq!(Tid::MAX.micros(), 9007199254740991); 149 153 /// ``` 150 - /// 151 154 #[must_use] 152 155 pub const fn micros(&self) -> u64 { 153 156 (self.0 >> BITS_CLOCK_ID) & Self::MAX_TIMESTAMP ··· 159 164 /// assert_eq!(Tid::MIN.clock_id(), 0); 160 165 /// assert_eq!(Tid::MAX.clock_id(), 1023); 161 166 /// ``` 162 - /// 163 167 #[must_use] 164 168 pub const fn clock_id(&self) -> u16 { 165 169 // CONVERSION: Clock ID mask ensures this will always produce an equivalent u16. ··· 175 181 /// ```rust 176 182 /// # use gordian_types::tid::Tid; 177 183 /// # use time::OffsetDateTime; 178 - /// const DT: OffsetDateTime = time::macros::datetime!(2025-11-25 10:28:43.234 UTC); 184 + /// const DT: OffsetDateTime = time::macros::datetime!(2025-11-25 10:28:43.234 UTC); 179 185 /// 180 186 /// let mut t = Tid::default(); 181 187 /// t.set_datetime(DT); ··· 191 197 /// # Panics 192 198 /// 193 199 /// Panics if `dt` is later than 2255-06-05 23:47:34.740991 +00:00:00. 194 - /// 195 200 pub fn set_datetime(&mut self, dt: time::OffsetDateTime) { 196 201 let micros = dt.unix_timestamp_nanos() / 1000; 197 202 self.set_micros(micros.try_into().unwrap()); ··· 201 208 /// # Panics 202 209 /// 203 210 /// Panics if `dt` is later than 2255-06-05 23:47:34.740991 +00:00:00. 204 - /// 205 211 #[must_use] 206 212 pub fn from_datetime(dt: time::OffsetDateTime, clock_id: u16) -> Self { 207 213 let micros = dt.unix_timestamp_nanos() / 1000; ··· 212 220 /// # Panics 213 221 /// 214 222 /// *Will* panic if called later than 2255-06-05 23:47:34.740991 +00:00:00. 215 - /// 216 223 #[must_use] 217 224 pub fn now_utc() -> Self { 218 225 use time::OffsetDateTime; ··· 220 229 } 221 230 222 231 /// Convert the TID to a [`time::OffsetDateTime`] 223 - /// 224 232 #[must_use] 225 233 #[allow(clippy::missing_panics_doc)] 226 234 pub fn as_datetime(&self) -> time::OffsetDateTime { ··· 299 309 /// # Errors 300 310 /// 301 311 /// Returns an error if `tid` is not a valid TID. 302 - /// 303 312 pub fn parse(tid: &str) -> Result<Tid, Error> { 304 313 let bytes = tid.as_bytes(); 305 314 if bytes.len() != 13 { ··· 343 354 /// # Panics 344 355 /// 345 356 /// Panics if `clock_id` is greater than [`Tid::MAX_CLOCK_ID`]. 346 - /// 347 357 #[must_use] 348 358 pub const fn with_id(clock_id: u16) -> Self { 349 359 assert!( ··· 360 372 /// 361 373 /// # Panics 362 374 /// 363 - /// Panics if the current date is later than 2255-06-05 23:47:34.740991 +00:00:00. 375 + /// Panics if the current date is later than 2255-06-05 23:47:34.740991 376 + /// +00:00:00. 364 377 /// 365 378 /// [`SystemTime::now()`]: std::time::SystemTime 366 - /// 367 379 pub fn next(&self) -> Tid { 368 380 use std::time::SystemTime; 369 381
+4 -3
crates/gordian-types/src/uri.rs
··· 1 - use crate::{Did, did::DidBuf}; 1 + use crate::Did; 2 + use crate::did::DidBuf; 2 3 3 4 /// A fully defined Atmosphere URI pointing to a record. 4 5 /// 5 - /// For example: "at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22" 6 - /// 6 + /// For example: 7 + /// "at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22" 7 8 #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] 8 9 pub struct RecordUri { 9 10 pub authority: DidBuf,
crates/gordian-types/src/util.rs
+9 -1
justfile
··· 2 2 host := "helr01:gordian-knot" 3 3 target := "x86_64-unknown-linux-gnu" 4 4 5 + fmt: 6 + cargo +nightly fmt 7 + 5 8 build: 6 9 cross build --release --target {{target}} --package {{bin}} 7 10 8 11 build-compress: build 9 12 {{require("upx")}} target/{{target}}/release/{{bin}} 13 + 14 + build-cred: 15 + cross build --release --target {{target}} --package gordian-cred 16 + {{require("upx")}} target/{{target}}/release/gordian-cred 17 + 10 18 11 19 deployffs: build 12 20 incus exec {{host}} -- unlink /usr/bin/{{bin}} ··· 22 14 incus exec {{host}} -- systemctl restart gordian-knot.service 23 15 24 16 resolve *ident: 25 - cargo run --package identity --bin resolve --features tracing-subscriber,tokio/rt -- {{ident}} 17 + cargo run --package gordian-identity --bin resolve --features tracing-subscriber,tokio/rt -- {{ident}}
+9
rustfmt.toml
··· 1 + # The ONE TRUE ULTIMATE import style. 2 + imports_granularity = "item" 3 + 4 + # Strive for consistency! 5 + group_imports = "stdexternalcrate" 6 + 7 + # Why TF not? 8 + format_code_in_doc_comments = true 9 + wrap_comments = true