···11+use std::borrow::Cow;22+use std::sync::Arc;33+use std::time::Duration;44+55+use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED_SIGNING;66+use aws_lc_rs::signature::EcdsaKeyPair;77+use exn::Exn;88+use exn::OptionExt as _;99+use exn::ResultExt as _;1010+use gordian_identity::Resolver;1111+use gordian_types::Did;1212+use gordian_types::Handle;1313+use serde::Serialize;1414+use time::OffsetDateTime;1515+use url::Url;1616+1717+use crate::HttpClient;1818+use crate::Request;1919+use crate::dpop::HttpClientExt as _;2020+use crate::pkce;2121+use crate::pkce::CodeChallengeMethod;2222+use crate::pkce::ProofKeyCodeExchange;2323+use crate::resources::AuthorizationMetadata;2424+use crate::resources::ClientMetadata;2525+use crate::resources::IntoAuthorizationServerUrl;2626+use crate::resources::IntoProtectedResourceUrl;2727+use crate::resources::ProtectedResource;2828+use crate::types::CallbackSuccess;2929+use crate::types::ClientAssertionType;3030+use crate::types::GrantType;3131+use crate::types::PushedAuthorizationResponse;3232+use crate::types::ResponseType;3333+use crate::types::SigningAlgorithm;3434+use crate::types::TokenResponse;3535+3636+/// OAuth client.3737+#[derive(Debug)]3838+pub struct Client {3939+ /// Client metadata used to initialise the client.4040+ pub metadata: ClientMetadata,4141+4242+ // Key-pair for client assertion.4343+ assertion_key: Option<Arc<EcdsaKeyPair>>,4444+4545+ http: HttpClient,4646+ resolver: Resolver,4747+}4848+4949+impl Client {5050+ #[must_use]5151+ pub fn new_with(metadata: ClientMetadata, http: &HttpClient, resolver: &Resolver) -> Self {5252+ Self {5353+ metadata,5454+ assertion_key: None,5555+ http: http.clone(),5656+ resolver: resolver.clone(),5757+ }5858+ }5959+6060+ /// Set the client assertion key.6161+ #[must_use]6262+ pub fn with_client_assertion_key(mut self, client_key: Arc<EcdsaKeyPair>) -> Self {6363+ self.assertion_key = Some(client_key);6464+ self6565+ }6666+6767+ /// Prepare a pushed authorization request.6868+ ///6969+ /// # Errors7070+ ///7171+ /// Returns an error under the following conditions:7272+ ///7373+ /// - A suitable authorization server cannot be found from `hint`.7474+ /// - A DPoP key-pair cannot be generated.7575+ pub async fn prepare_pushed_authorization<S: AsRef<str>>(7676+ &self,7777+ hint: S,7878+ ) -> Result<PushedAuthorizationClient<'_>, Exn<Error>> {7979+ let err = || Error::new("Unable to begin authorization");8080+8181+ let (hint, authorization_meta) = self8282+ .resolve_authorization_meta(hint.as_ref())8383+ .await8484+ .or_raise(err)?;8585+8686+ Self::validate_authorization_meta(&authorization_meta).or_raise(err)?;8787+8888+ let pkce = ProofKeyCodeExchange::default();8989+ let dpop_key_pair = EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING)9090+ .or_raise(|| Error::new("Could not generate a DPoP key pair"))9191+ .or_raise(err)?;9292+9393+ Ok(PushedAuthorizationClient {9494+ client: self,9595+ authorization_meta,9696+ pkce: Some(pkce),9797+ dpop_key_pair,9898+ hint,9999+ })100100+ }101101+102102+ /// Request an authorization code.103103+ ///104104+ /// # Errors105105+ ///106106+ /// Returns an error if:107107+ ///108108+ /// - The token request cannot be constructed.109109+ /// - The authorization server responds with an error.110110+ pub async fn request_token(111111+ &self,112112+ par: &PushedAuthorization,113113+ callback: &CallbackSuccess,114114+ redirect_uri: &Url,115115+ ) -> Result<TokenResponse, Exn<Error>> {116116+ #[derive(Debug, Serialize)]117117+ struct TokenRequest<'a> {118118+ redirect_uri: &'a Url,119119+ grant_type: GrantType,120120+ code: &'a str,121121+ code_verifier: Option<&'a pkce::CodeVerifier>,122122+ client_id: &'a Url,123123+ #[serde(flatten)]124124+ pub client_assertion: Option<ClientAssertion>,125125+ }126126+127127+ let request = TokenRequest {128128+ redirect_uri,129129+ grant_type: GrantType::AuthorizationCode,130130+ code: &callback.code,131131+ code_verifier: par.pkce.as_ref().map(|pkce| &pkce.code_verifier),132132+ client_id: &self.metadata.client_id,133133+ client_assertion: self.client_assertion(&par.authorization_meta.issuer),134134+ };135135+136136+ let request = self137137+ .http138138+ .post(par.authorization_meta.token_endpoint.clone())139139+ .form(&request)140140+ .build()141141+ .or_raise(|| Error::new("Could not build token request"))?;142142+143143+ let mut nonce = self.get_dpop_nonce(&par.authorization_meta.issuer).await;144144+145145+ let response: TokenResponse = self146146+ .http147147+ .execute_with_dpop(request, &mut nonce, &par.dpop_key_pair)148148+ .await149149+ .or_raise(|| Error::new("Unable to request token"))?;150150+151151+ if let Some(nonce) = nonce {152152+ self.store_dpop_nonce(&par.authorization_meta.issuer, &nonce)153153+ .await;154154+ }155155+156156+ Ok(response)157157+ }158158+159159+ fn validate_authorization_meta(meta: &AuthorizationMetadata) -> Result<(), Exn<Error>> {160160+ exn::ensure!(161161+ meta.require_pushed_authorization_requests,162162+ Error::new("Authorization server does not require pushed authorization requests")163163+ );164164+165165+ exn::ensure!(166166+ meta.scopes_supported.contains("atproto"),167167+ Error::new("Authorization server does not support \"atproto\" scope")168168+ );169169+170170+ exn::ensure!(171171+ meta.code_challenge_methods_supported172172+ .contains(&CodeChallengeMethod::S256),173173+ Error::new("Authorization server does not support \"S256\" code challenge method")174174+ );175175+176176+ exn::ensure!(177177+ meta.dpop_signing_alg_values_supported178178+ .contains(&SigningAlgorithm::ES256),179179+ Error::new("Authorization server does not support \"ES256\" signing algorithm")180180+ );181181+182182+ exn::ensure!(183183+ meta.grant_types_supported184184+ .contains(&GrantType::AuthorizationCode),185185+ Error::new("Authorization server does not support \"authorization_code\" grant type")186186+ );187187+188188+ Ok(())189189+ }190190+191191+ /// Resolve `hint` to an authorization server, returning the authorization192192+ /// server's metadata, and a normalised `hint` if it was a handle or193193+ /// DID.194194+ ///195195+ /// `hint` may be a DID, handle, or URL.196196+ ///197197+ /// # Errors198198+ ///199199+ /// Returns an error if an authorization server cannot be found.200200+ async fn resolve_authorization_meta(201201+ &self,202202+ hint: &str,203203+ ) -> Result<(Option<String>, AuthorizationMetadata), Exn<Error>> {204204+ let hint = hint.trim_start_matches('@').trim_start_matches("at://");205205+206206+ let mut err = None;207207+208208+ if Did::parse(hint).is_ok() || Handle::parse(hint).is_ok() {209209+ match self.resolver.resolve(hint).await {210210+ Ok((_, doc)) => {211211+ //212212+ // We've successfully resolved the hint as a identity so assume any further213213+ // errors resolving the authorization server are terminal.214214+ //215215+ let resource = doc216216+ .atproto_pds()217217+ .ok_or_raise(|| Error::new("Identity does not have an associated PDS"))?;218218+ let resource = self219219+ .protected_resource_meta(resource)220220+ .await221221+ .or_raise(|| Error::new("Failed to resolve protected resource"))?;222222+ for auth_url in resource.authorization_servers {223223+ if let Ok(auth_meta) = self.authorization_meta(auth_url).await {224224+ return Ok((Some(hint.to_string()), auth_meta));225225+ }226226+ }227227+228228+ exn::bail!(Error::new(229229+ "Protected resource does declare any usable authorization servers"230230+ ));231231+ }232232+ Err(error) => _ = err.replace(error),233233+ }234234+ }235235+236236+ if let Some(url_hint) = coalesce_to_url(hint) {237237+ // The hint may be a PDS.238238+ if let Ok(resource) = self.protected_resource_meta(&url_hint).await {239239+ for auth_url in resource.authorization_servers {240240+ if let Ok(auth_meta) = self.authorization_meta(auth_url).await {241241+ return Ok((None, auth_meta));242242+ }243243+ }244244+245245+ exn::bail!(Error::new(246246+ "Protected resource does declare any usable authorization servers"247247+ ));248248+ }249249+250250+ // The hint may be an authorization server.251251+ if let Ok(auth_meta) = self.authorization_meta(url_hint).await {252252+ return Ok((None, auth_meta));253253+ }254254+ }255255+256256+ Err(match err {257257+ Some(error) => Err(error).or_raise(|| Error::new(""))?,258258+ None => Exn::new(Error::new(259259+ "Unable to extract authorization server from hint",260260+ )),261261+ })262262+ }263263+264264+ /// Fetch the "/.well-known/oauth-protected-resource" document for a265265+ /// resource.266266+ async fn protected_resource_meta<R: IntoProtectedResourceUrl>(267267+ &self,268268+ resource: R,269269+ ) -> Result<ProtectedResource, reqwest::Error> {270270+ let url = resource.into_protected_resource_url();271271+ let meta: ProtectedResource = self.http.get(url).send().await?.json().await?;272272+273273+ // @TODO Check meta.resource corresponds to the fetched URL.274274+275275+ Ok(meta)276276+ }277277+278278+ /// Fetch the "/.well-known/oauth-authorization-server" metadata for a279279+ /// resource.280280+ async fn authorization_meta<R: IntoAuthorizationServerUrl>(281281+ &self,282282+ resource: R,283283+ ) -> Result<AuthorizationMetadata, reqwest::Error> {284284+ let url = resource.into_authorization_server_url();285285+ let meta = self.http.get(url).send().await?.json().await?;286286+287287+ // @TODO Check meta.issuer corresponds to the fetched URL.288288+289289+ Ok(meta)290290+ }291291+292292+ fn client_assertion(&self, audience: impl AsRef<str>) -> Option<ClientAssertion> {293293+ if let Some(key_pair) = &self.assertion_key {294294+ let client_assertion = self.compute_client_assertion(audience, key_pair);295295+ return Some(ClientAssertion {296296+ client_assertion_type: ClientAssertionType::JwtBearer,297297+ client_assertion,298298+ });299299+ }300300+ None301301+ }302302+303303+ fn compute_client_assertion(304304+ &self,305305+ audience: impl AsRef<str>,306306+ key_pair: &EcdsaKeyPair,307307+ ) -> String {308308+ use crate::jwk;309309+ use crate::jwt;310310+311311+ assert_eq!(key_pair.algorithm(), &ECDSA_P256_SHA256_FIXED_SIGNING);312312+ let jwk::JsonWebKey { key_id, .. } = key_pair313313+ .try_into()314314+ .expect("ECDSA-P256-SHA256 should be a supported key type");315315+316316+ jwt::encode_and_sign(317317+ serde_json::json!({318318+ "typ": jwt::Type::Jwt,319319+ "alg": jwt::Algorithm::ES256,320320+ "kid": key_id321321+ }),322322+ serde_json::json!({323323+ "iss": &self.metadata.client_id,324324+ "sub": &self.metadata.client_id,325325+ "aud": audience.as_ref(),326326+ "jti": jwt::generate_jti(),327327+ "iat": time::OffsetDateTime::now_utc().unix_timestamp(),328328+ "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 60329329+ }),330330+ key_pair,331331+ )332332+ .expect("Client assertion should be OK")333333+ }334334+335335+ #[tracing::instrument(skip(self))]336336+ async fn store_dpop_nonce(&self, authorization_server: &str, nonce: &str) {337337+ tracing::warn!("unimplemented");338338+ }339339+340340+ #[tracing::instrument(skip(self))]341341+ async fn get_dpop_nonce(&self, authorization_server: &str) -> Option<String> {342342+ tracing::warn!("unimplemented");343343+ None344344+ }345345+}346346+347347+#[derive(Debug, serde::Serialize)]348348+pub struct ClientAssertion {349349+ pub client_assertion_type: ClientAssertionType,350350+ pub client_assertion: String,351351+}352352+353353+#[derive(Debug)]354354+pub struct PushedAuthorizationClient<'client> {355355+ pub authorization_meta: AuthorizationMetadata,356356+ pub hint: Option<String>,357357+358358+ client: &'client Client,359359+ pkce: Option<ProofKeyCodeExchange>,360360+ dpop_key_pair: EcdsaKeyPair,361361+}362362+363363+impl PushedAuthorizationClient<'_> {364364+ /// Push the authorization request to the authorization server.365365+ ///366366+ /// # Errors367367+ ///368368+ /// Returns an error if the request cannot be constructed or if the369369+ /// authorization returns an error response.370370+ pub fn push_authorization<S: AsRef<str> + Send, Scope: Serialize + Send>(371371+ self,372372+ state: &S,373373+ redirect_uri: &Url,374374+ scope: &[Scope],375375+ ) -> impl Future<Output = Result<PushedAuthorization, Exn<PushedAuthorizationError>>> {376376+ let request = self.build_request(state.as_ref(), redirect_uri, scope);377377+ async {378378+ let mut nonce = self379379+ .client380380+ .get_dpop_nonce(&self.authorization_meta.issuer)381381+ .await;382382+383383+ let PushedAuthorizationResponse {384384+ request_uri,385385+ expires_in,386386+ } = self387387+ .client388388+ .http389389+ .execute_with_dpop(request, &mut nonce, &self.dpop_key_pair)390390+ .await391391+ .or_raise(|| PushedAuthorizationError::new(""))?;392392+393393+ if let Some(nonce) = nonce {394394+ self.client395395+ .store_dpop_nonce(&self.authorization_meta.issuer, &nonce)396396+ .await;397397+ }398398+399399+ let expires = OffsetDateTime::now_utc() + Duration::from_secs(expires_in);400400+401401+ Ok(PushedAuthorization {402402+ request_uri,403403+ expires,404404+ authorization_meta: self.authorization_meta,405405+ dpop_key_pair: self.dpop_key_pair,406406+ pkce: self.pkce,407407+ })408408+ }409409+ }410410+411411+ fn build_request<Scope: Serialize>(412412+ &self,413413+ state: &str,414414+ redirect_uri: &Url,415415+ scope: &[Scope],416416+ ) -> Request {417417+ #[derive(Serialize)]418418+ struct PushedAuthorizationRequest<'a, S: Serialize> {419419+ pub state: &'a str,420420+ pub client_id: &'a Url,421421+ pub response_type: ResponseType,422422+ pub redirect_uri: &'a Url,423423+ #[serde(with = "crate::serde::vec_as_spaced_string")]424424+ pub scope: &'a [S],425425+ pub login_hint: Option<&'a str>,426426+ #[serde(flatten)]427427+ pub client_assertion: Option<ClientAssertion>,428428+ #[serde(flatten)]429429+ pub code_challenge: Option<pkce::CodeChallenge>,430430+ }431431+432432+ let audience = &self.authorization_meta.issuer;433433+ let request_body = PushedAuthorizationRequest {434434+ state,435435+ client_id: &self.client.metadata.client_id,436436+ response_type: ResponseType::Code,437437+ redirect_uri,438438+ scope,439439+ login_hint: self.hint.as_deref(),440440+ code_challenge: self.pkce.as_ref().map(Into::into),441441+ client_assertion: self.client.client_assertion(audience),442442+ };443443+444444+ self.client445445+ .http446446+ .post(447447+ self.authorization_meta448448+ .pushed_authorization_request_endpoint449449+ .clone(),450450+ )451451+ .form(&request_body)452452+ .build()453453+ .expect("PushAuthorizationRequest should be serialized infallibly")454454+ }455455+}456456+457457+#[derive(Debug)]458458+pub struct PushedAuthorization {459459+ /// Request URI string.460460+ pub request_uri: String,461461+462462+ /// Timestamp the request URI expires.463463+ pub expires: OffsetDateTime,464464+465465+ /// Authorization server metadata we're authorizing with.466466+ pub authorization_meta: AuthorizationMetadata,467467+468468+ /// DPoP key-pair for the authorization session.469469+ pub dpop_key_pair: EcdsaKeyPair,470470+471471+ /// Proof Key for Code Exchange for the authorization session.472472+ pub pkce: Option<ProofKeyCodeExchange>,473473+}474474+475475+impl PushedAuthorization {476476+ #[must_use]477477+ pub fn authorization_endpoint(&self, client_id: &Url) -> Url {478478+ let mut url = self.authorization_meta.authorization_endpoint.clone();479479+ {480480+ let mut query = url.query_pairs_mut();481481+ query.append_pair("request_uri", &self.request_uri);482482+ query.append_pair("client_id", client_id.as_str());483483+ }484484+ url485485+ }486486+}487487+488488+fn coalesce_to_url(s: &str) -> Option<Url> {489489+ if let Ok(url) = Url::parse(s) {490490+ return Some(url);491491+ }492492+ if let Ok(url) = format!("https://{s}").parse() {493493+ return Some(url);494494+ }495495+ if let Some(idx) = s.find("://") {496496+ let (_, s) = s.split_at(idx);497497+ if let Ok(url) = format!("https{s}").parse() {498498+ return Some(url);499499+ }500500+ }501501+ None502502+}503503+504504+#[derive(Debug, thiserror::Error)]505505+#[error("{0}")]506506+pub struct Error(Cow<'static, str>);507507+508508+impl Error {509509+ fn new(message: impl Into<Cow<'static, str>>) -> Self {510510+ Self(message.into())511511+ }512512+}513513+514514+#[derive(Debug, thiserror::Error)]515515+#[error("{0}")]516516+pub struct PushedAuthorizationError(Cow<'static, str>);517517+518518+impl PushedAuthorizationError {519519+ fn new(message: impl Into<Cow<'static, str>>) -> Self {520520+ Self(message.into())521521+ }522522+}
+269
crates/gordian-auth/src/dpop.rs
···11+use std::borrow::Cow;22+33+use aws_lc_rs::signature::EcdsaKeyPair;44+use exn::Exn;55+use exn::OptionExt as _;66+use exn::ResultExt as _;77+use reqwest::StatusCode;88+use reqwest::header::HeaderName;99+use reqwest::header::HeaderValue;1010+use serde::Deserialize;1111+use serde::Serialize;1212+use serde::de::DeserializeOwned;1313+use url::Url;1414+1515+use crate::HttpClient;1616+use crate::Method;1717+use crate::Request;1818+use crate::jwk;1919+use crate::jwt;2020+use crate::types;2121+2222+/// Header used to Demonstrate Proof of Possession to the server.2323+pub const DPOP: HeaderName = HeaderName::from_static("dpop");2424+2525+/// Header used to convey the the servers nonce value to the client.2626+pub const DPOP_NONCE: HeaderName = HeaderName::from_static("dpop-nonce");2727+2828+/// DPoP JWT types2929+#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)]3030+pub enum Type {3131+ #[default]3232+ #[serde(rename = "dpop+jwt")]3333+ DpopJwt,3434+}3535+3636+/// DPoP JWT Header3737+#[derive(Debug, Deserialize, Serialize)]3838+pub struct Header {3939+ pub typ: Type,4040+4141+ /// Signing-key algorithm.4242+ pub alg: jwt::Algorithm,4343+4444+ pub jwk: jwk::JsonWebKey,4545+}4646+4747+impl From<jwk::JsonWebKey> for Header {4848+ /// Create a DPoP header from a JWK.4949+ fn from(jwk: jwk::JsonWebKey) -> Self {5050+ Self {5151+ typ: Type::DpopJwt,5252+ alg: jwk.algorithm(),5353+ jwk,5454+ }5555+ }5656+}5757+5858+/// DPoP Proof5959+///6060+/// See: <https://datatracker.ietf.org/doc/html/rfc9449#name-dpop-proof-jwts>6161+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]6262+pub struct Proof<'a> {6363+ /// Unique identifier for the DPoP proof JWT.6464+ ///6565+ /// Should contain at least 96 bits of randomness.6666+ #[serde(borrow)]6767+ pub jti: Cow<'a, str>,6868+6969+ /// The HTTP method of the request to which the JWT is attached.7070+ #[serde(with = "method")]7171+ pub htm: Cow<'a, Method>,7272+7373+ /// The HTTP target URI of the request to which the JWT is attached.7474+ pub htu: Cow<'a, Url>,7575+7676+ /// Creation timestamp of the JWT as a UNIX epoch.7777+ pub iat: i64,7878+7979+ /// base64url encoded hash of the access token if the DPoP proof is used in8080+ /// conjunction with the presentation of an access token.8181+ #[serde(skip_serializing_if = "Option::is_none")]8282+ pub ath: Option<Cow<'a, str>>,8383+8484+ /// A recent nonce provided via the DPoP-Nonce HTTP header.8585+ ///8686+ /// Only present if the authentication server or resource server provided a8787+ /// DPoP-Nonce.8888+ #[serde(skip_serializing_if = "Option::is_none")]8989+ pub nonce: Option<Cow<'a, str>>,9090+}9191+9292+impl<'a> Proof<'a> {9393+ pub fn from_request(request: &'a reqwest::Request, nonce: Option<&'a str>) -> Self {9494+ Self {9595+ jti: Cow::Owned(jwt::generate_jti()),9696+ htm: Cow::Borrowed(request.method()),9797+ htu: Cow::Borrowed(request.url()),9898+ iat: time::OffsetDateTime::now_utc().unix_timestamp(),9999+ ath: None,100100+ nonce: nonce.map(Cow::Borrowed),101101+ }102102+ }103103+}104104+105105+impl Proof<'_> {106106+ /// Encode the proof and sign using `key_pair`.107107+ ///108108+ /// # Errors109109+ ///110110+ /// Returns an error if:111111+ ///112112+ /// - The proof cannot be serialized as JSON. This is only likely to occur113113+ /// if [`Proof::nonce`] is not a serializable header value.114114+ ///115115+ /// - The ECDSA signature fails.116116+ ///117117+ /// # Panics118118+ ///119119+ /// Panics if `key_pair` cannot be represented as a JWK.120120+ pub fn encode_and_sign(&self, key_pair: &EcdsaKeyPair) -> Result<String, jwt::EncodeError> {121121+ let jwk: jwk::JsonWebKey = key_pair.try_into().unwrap();122122+ let header: Header = jwk.into();123123+ jwt::encode_and_sign(header, self, key_pair)124124+ }125125+}126126+127127+#[derive(Debug, thiserror::Error)]128128+#[error("Unable to DPoP for request")]129129+pub struct ProofError;130130+131131+/// Add proof of possession to a [`Request`].132132+///133133+/// # Errors134134+///135135+/// Returns an error if the DPoP cannot be encoded and signed, or if the DPoP136136+/// cannot be encoded as a [`HeaderValue`].137137+pub fn prove(138138+ mut request: Request,139139+ nonce: Option<&str>,140140+ key_pair: &EcdsaKeyPair,141141+) -> Result<Request, Exn<ProofError>> {142142+ let claims = Proof::from_request(&request, nonce);143143+ let proof = claims.encode_and_sign(key_pair).or_raise(|| ProofError)?;144144+145145+ request146146+ .headers_mut()147147+ .insert(DPOP, HeaderValue::from_str(&proof).or_raise(|| ProofError)?);148148+149149+ Ok(request)150150+}151151+152152+#[tracing::instrument(skip(http))]153153+async fn dpop_request_inner<T: DeserializeOwned>(154154+ http: &HttpClient,155155+ request: Request,156156+ nonce: &mut Option<String>,157157+ key_pair: &EcdsaKeyPair,158158+) -> Result<Option<T>, Exn<ProofError>> {159159+ let err = || ProofError;160160+161161+ let request = prove(request, nonce.as_deref(), key_pair)?;162162+ let mut response = http.execute(request).await.or_raise(err)?;163163+164164+ tracing::info!(?response);165165+166166+ // Take the DPoP-Nonce header and store it.167167+ if let Some(new_nonce) = response.headers_mut().get(DPOP_NONCE) {168168+ let new_nonce = new_nonce.to_str().or_raise(err)?;169169+ *nonce = Some(new_nonce.to_string());170170+ }171171+172172+ let status = response.status();173173+ if status.is_success() {174174+ let response = response.json().await.or_raise(err)?;175175+ return Ok(Some(response));176176+ }177177+178178+ if status.is_client_error() {179179+ let error_response: types::AuthorizationError = response.json().await.or_raise(err)?;180180+ if status == StatusCode::BAD_REQUEST && error_response.error == "use_dpop_nonce" {181181+ return Ok(None);182182+ }183183+184184+ tracing::error!(?status, ?error_response, "error from authorization server");185185+ exn::bail!(err());186186+ }187187+188188+ exn::bail!(ProofError);189189+}190190+191191+async fn execute<T: DeserializeOwned>(192192+ http: &HttpClient,193193+ request: Request,194194+ nonce: &mut Option<String>,195195+ key_pair: &EcdsaKeyPair,196196+) -> Result<T, Exn<ProofError>> {197197+ let first_request = request.try_clone().ok_or_raise(|| ProofError)?;198198+ let second_request = request;199199+200200+ let result = dpop_request_inner(http, first_request, nonce, key_pair).await?;201201+ if let Some(response) = result {202202+ return Ok(response);203203+ }204204+205205+ let result = dpop_request_inner(http, second_request, nonce, key_pair).await?;206206+ if let Some(response) = result {207207+ return Ok(response);208208+ }209209+210210+ exn::bail!(ProofError);211211+}212212+213213+pub trait HttpClientExt {214214+ /// Execute the request with DPoP.215215+ ///216216+ /// Requires `request` to be cloneable. See [`Request::try_clone()`]217217+ fn execute_with_dpop<T: DeserializeOwned>(218218+ &self,219219+ request: Request,220220+ nonce: &mut Option<String>,221221+ key_pair: &EcdsaKeyPair,222222+ ) -> impl Future<Output = Result<T, Exn<ProofError>>>;223223+}224224+225225+impl HttpClientExt for HttpClient {226226+ fn execute_with_dpop<T: DeserializeOwned>(227227+ &self,228228+ request: Request,229229+ nonce: &mut Option<String>,230230+ key_pair: &EcdsaKeyPair,231231+ ) -> impl Future<Output = Result<T, Exn<ProofError>>> {232232+ execute(self, request, nonce, key_pair)233233+ }234234+}235235+236236+mod method {237237+ //! Use this module in combination with serde's [`#[with]`][with] attribute.238238+ //!239239+ //! [with]: https://serde.rs/field-attrs.html#with240240+241241+ use std::borrow::Cow;242242+243243+ use serde::Deserialize;244244+ use serde::Deserializer;245245+ use serde::Serializer;246246+ use serde::de::Error;247247+248248+ use crate::Method;249249+250250+ #[allow(clippy::missing_errors_doc)]251251+ pub fn deserialize<'de: 'a, 'a, D>(deserializer: D) -> Result<Cow<'a, Method>, D::Error>252252+ where253253+ D: Deserializer<'de>,254254+ {255255+ let method = <&str as Deserialize>::deserialize(deserializer)?256256+ .parse()257257+ .map_err(Error::custom)?;258258+259259+ Ok(Cow::Owned(method))260260+ }261261+262262+ #[allow(clippy::missing_errors_doc)]263263+ pub fn serialize<S>(method: &Method, serializer: S) -> Result<S::Ok, S::Error>264264+ where265265+ S: Serializer,266266+ {267267+ serializer.serialize_str(method.as_str())268268+ }269269+}
···11use core::fmt;2233+use aws_lc_rs::rand::SystemRandom;44+use aws_lc_rs::signature::EcdsaKeyPair;35use data_encoding::BASE64URL_NOPAD as Encoding;44-use gordian_types::{Nsid, DidBuf};55-use serde::{Deserialize, Serialize, de::DeserializeOwned};66+use gordian_types::DidBuf;77+use gordian_types::Nsid;88+use serde::Deserialize;99+use serde::Serialize;1010+use serde::de::DeserializeOwned;61177-use crate::verification_key::{Unspecified, VerificationKey};1212+use crate::error::Unspecified;1313+use crate::jwk::JsonWebKey;1414+use crate::verification_key::VerificationKey;81599-#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]1616+#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)]1017pub enum Type {1111- JWT,1818+ #[default]1919+ #[serde(rename = "JWT")]2020+ Jwt,2121+ #[serde(rename = "dpop+jwt")]2222+ DpopJwt,1223}13241425/// Signature algorithm.1526///1627/// See: <https://atproto.com/specs/xrpc#inter-service-authentication-jwt>1717-#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]2828+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]1829#[non_exhaustive]1930pub enum Algorithm {2031 ES256K,3232+ #[default]2133 ES256,2234 ES384,2335 ES512,···4937 Ed25519,5038}51395252-#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]4040+#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)]5341pub struct Header {5442 pub typ: Type,5543···58465947 #[serde(skip_serializing_if = "Option::is_none")]6048 pub crv: Option<Curve>,4949+5050+ pub jwk: Option<JsonWebKey>,6151}62526353impl Header {6454 #[must_use]6555 pub const fn new(alg: Algorithm, crv: Option<Curve>) -> Self {6656 Self {6767- typ: Type::JWT,5757+ typ: Type::Jwt,6858 alg,6959 crv,6060+ jwk: None,7061 }7162 }7263}···10893}1099411095impl Token<Claims> {111111- #[inline]9696+ /// Deserialize [`Self`] from `token` after verifying the `token`'s9797+ /// signature with `public_key`.9898+ ///9999+ /// # Errors100100+ ///101101+ /// Returns an error if the token cannot be deserialized or if the signature102102+ /// verification fails.112103 pub fn decode(113104 token: impl AsRef<[u8]>,114105 public_key: &dyn VerificationKey,···122101 decode(token, public_key)123102 }124103125125- #[inline]104104+ /// Deserialize [`Self`] from `token` without verifying the `token`'s105105+ /// signature.106106+ ///107107+ /// # Errors108108+ ///109109+ /// Returns an error if the token is in the wrong format, cannot be decoded,110110+ /// or if deserialization fails.126111 pub fn decode_unverified(token: impl AsRef<[u8]>) -> Result<Self, Error> {127112 decode_unverified(token)128113 }···175148176149/// Get the deserialized [`Token`] without verifying the `token`'s signature.177150///151151+/// # Errors152152+///153153+/// Returns an error if the token is in the wrong format, cannot be decoded, or154154+/// if deserialization fails.178155pub fn decode_unverified<C: DeserializeOwned + Serialize>(179156 token: impl AsRef<[u8]>,180157) -> Result<Token<C>, Error> {···190159 Ok(Token { header, claims })191160}192161193193-/// Verify the JWT signature using `verification_key` and return the deserialized [`Token`].162162+/// Verify the JWT signature using `verification_key` and return the163163+/// deserialized [`Token`].194164///165165+/// # Errors166166+///167167+/// Returns an error if the token cannot be deserialized or if the signature168168+/// verification fails.195169pub fn decode<C: DeserializeOwned + Serialize>(196170 token: impl AsRef<[u8]>,197171 verification_key: &dyn VerificationKey,···224188 Ok(Token { header, claims })225189}226190191191+#[derive(Debug, thiserror::Error)]192192+pub enum EncodeError {193193+ #[error("Unable to serialize claims: {0}")]194194+ SerializationFailed(#[from] serde_json::Error),195195+ #[error("Unable to sign JWT")]196196+ Signing(#[from] aws_lc_rs::error::Unspecified),197197+}198198+199199+/// Encodes a JWT from `header` and `claims`, then signs the token with200200+/// `key_pair`.201201+///202202+/// # Errors203203+///204204+/// Returns an error if either `header` or `claims` cannot be serialized as205205+/// JSON, or if the signing operation fails.206206+#[allow(clippy::missing_panics_doc)]207207+pub fn encode_and_sign<H: Serialize, C: Serialize>(208208+ header: H,209209+ claims: C,210210+ key_pair: &EcdsaKeyPair,211211+) -> Result<String, EncodeError> {212212+ use data_encoding::BASE64URL_NOPAD as Encoding;213213+214214+ let mut token = String::new();215215+ token.push_str(216216+ &Encoding.encode(217217+ serde_json::to_string(&header)218218+ .expect("JWT header should be serializable as JSON")219219+ .as_bytes(),220220+ ),221221+ );222222+ token.push('.');223223+ token.push_str(&Encoding.encode(serde_json::to_string(&claims)?.as_bytes()));224224+225225+ let signature = key_pair.sign(&SystemRandom::new(), token.as_bytes())?;226226+227227+ token.push('.');228228+ token.push_str(&Encoding.encode(signature.as_ref()));229229+ Ok(token)230230+}231231+232232+/// Generate random bytes encoded to base64.233233+#[must_use]234234+pub fn generate_jti() -> String {235235+ let jti: [u8; 16] = rand::random();236236+ data_encoding::BASE64URL_NOPAD.encode(&jti)237237+}238238+227239#[cfg(test)]228240mod tests {229241 use gordian_identity::VerificationMethod;230242 use gordian_types::Did;231243232232- use super::{Algorithm, Error, Token, Type};244244+ use super::Algorithm;245245+ use super::Error;246246+ use super::Token;247247+ use super::Type;233248234249 #[test]235250 fn can_split_token() {···302215 fn can_decode_token() {303216 let Token { header, claims } = Token::decode_unverified(TOKEN).unwrap();304217305305- assert_eq!(header.typ, Type::JWT);218218+ assert_eq!(header.typ, Type::Jwt);306219 assert_eq!(header.alg, Algorithm::ES256K);307220 assert_eq!(308221 claims.aud.as_ref(),···334247335248 let Token { header, claims } = Token::decode(TOKEN, &vm).unwrap();336249337337- assert_eq!(header.typ, Type::JWT);250250+ assert_eq!(header.typ, Type::Jwt);338251 assert_eq!(header.alg, Algorithm::ES256K);339252 assert_eq!(340253 claims.aud.as_ref(),
+18-7
crates/gordian-auth/src/lib.rs
···11+pub mod client;22+pub mod dpop;33+pub mod error;44+pub mod jwk;15pub mod jwt;66+pub mod pkce;27pub mod resources;88+pub mod supported;99+pub mod types;1010+1111+pub(crate) mod serde;1212+313mod verification_key;1414+pub use verification_key::IntoVerificationKey;1515+pub use verification_key::KeyRejected;1616+pub use verification_key::MultibaseKey;1717+pub use verification_key::OpenSshKey;1818+pub use verification_key::VerificationKey;41955-pub(crate) mod support_set;66-pub(crate) mod types;77-pub(crate) mod validated_url;88-99-pub use verification_key::{1010- IntoVerificationKey, KeyRejected, OpenSshKey, Unspecified, VerificationKey,1111-};2020+pub type HttpClient = reqwest::Client;2121+pub type Request = reqwest::Request;2222+pub type Method = reqwest::Method;
+254
crates/gordian-auth/src/pkce.rs
···11+use core::fmt;22+33+use aws_lc_rs::constant_time;44+use aws_lc_rs::digest::SHA256;55+use aws_lc_rs::digest::digest;66+use aws_lc_rs::rand::SecureRandom;77+use serde::Deserialize;88+use serde::Serialize;99+1010+use crate::error::ParseError;1111+use crate::error::Unspecified;1212+use crate::serde::base64::ENCODING;1313+1414+/// Default length in bytes for a randomly generated [`CodeVerifier`]1515+pub const DEFAULT_LEN: usize = 64;1616+1717+pub const MIN_VERIFIER_LEN: usize = 32;1818+1919+/// Code challenge method for use with Proof Key for Code Exchange.2020+#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Deserialize, Serialize)]2121+pub enum CodeChallengeMethod {2222+ #[serde(rename = "plain")]2323+ Plain,2424+ #[default]2525+ S256,2626+}2727+2828+#[derive(Debug, thiserror::Error)]2929+#[error("invalid_grant")]3030+pub struct InvalidGrant;3131+3232+/// Verifier for Proof Key for Code Exchange.3333+#[derive(Clone)]3434+#[repr(transparent)]3535+pub struct CodeVerifier(Vec<u8>);3636+3737+impl<'de> serde::Deserialize<'de> for CodeVerifier {3838+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>3939+ where4040+ D: serde::Deserializer<'de>,4141+ {4242+ let encoded = <&str as Deserialize>::deserialize(deserializer)?;4343+ let bytes = ENCODING4444+ .decode(encoded.as_bytes())4545+ .map_err(serde::de::Error::custom)?;4646+ let validated = validate_length(bytes).map_err(serde::de::Error::custom)?;4747+ Ok(Self(validated))4848+ }4949+}5050+5151+impl serde::Serialize for CodeVerifier {5252+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>5353+ where5454+ S: serde::Serializer,5555+ {5656+ let encoded = self.encode();5757+ serializer.serialize_str(&encoded)5858+ }5959+}6060+6161+#[derive(Debug, thiserror::Error)]6262+#[error("Code verifier is shorted than {MIN_VERIFIER_LEN} bytes")]6363+pub struct InsufficientEntropy;6464+6565+fn validate_length<B>(bytes: B) -> Result<B, InsufficientEntropy>6666+where6767+ B: AsRef<[u8]>,6868+{6969+ if bytes.as_ref().len() >= MIN_VERIFIER_LEN {7070+ Ok(bytes)7171+ } else {7272+ Err(InsufficientEntropy)7373+ }7474+}7575+7676+impl CodeVerifier {7777+ /// Create a new `CodeVerifier` of `len` randomly generated bytes.7878+ ///7979+ /// # Errors8080+ ///8181+ /// Returns an error if [`SecureRandom::fill()`] fails.8282+ pub fn new(rng: &dyn SecureRandom, len: usize) -> Result<Self, Unspecified> {8383+ let mut bytes = vec![0u8; len];8484+ rng.fill(bytes.as_mut_slice())?;8585+ Ok(Self(bytes))8686+ }8787+8888+ /// Compute the code challenge using `method`.8989+ #[must_use]9090+ pub fn code_challenge(&self, method: CodeChallengeMethod) -> String {9191+ let encoded = self.encode();9292+ match method {9393+ CodeChallengeMethod::Plain => ENCODING.encode(encoded.as_bytes()),9494+ CodeChallengeMethod::S256 => {9595+ ENCODING.encode(digest(&SHA256, encoded.as_bytes()).as_ref())9696+ }9797+ }9898+ }9999+100100+ /// Verify `challenge` was generated by this verifier use `method`.101101+ ///102102+ /// # Errors103103+ ///104104+ /// Returns an [`InvalidGrant`] error if the challenge could not have been105105+ /// generated by this verifier.106106+ pub fn verify<C>(&self, method: CodeChallengeMethod, challenge: C) -> Result<(), InvalidGrant>107107+ where108108+ C: AsRef<[u8]>,109109+ {110110+ constant_time::verify_slices_are_equal(111111+ self.code_challenge(method).as_bytes(),112112+ challenge.as_ref(),113113+ )114114+ .map_err(|_| InvalidGrant)115115+ }116116+117117+ fn encode(&self) -> String {118118+ ENCODING.encode(&self.0)119119+ }120120+}121121+122122+impl fmt::Debug for CodeVerifier {123123+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {124124+ f.debug_struct("CodeVerifier")125125+ .field("len", &self.0.len())126126+ .finish_non_exhaustive()127127+ }128128+}129129+130130+impl std::str::FromStr for CodeVerifier {131131+ type Err = ParseError;132132+133133+ fn from_str(s: &str) -> Result<Self, Self::Err> {134134+ let bytes = ENCODING.decode(s.as_bytes())?;135135+ let bytes = validate_length(bytes).map_err(|error| ParseError::new(error.to_string()))?;136136+ Ok(Self(bytes))137137+ }138138+}139139+140140+impl Default for CodeVerifier {141141+ /// Create a randomly generated code verifier of the default length.142142+ ///143143+ /// # Panics144144+ ///145145+ /// Panics if the random number generation fails.146146+ fn default() -> Self {147147+ let mut bytes = vec![0u8; DEFAULT_LEN];148148+ aws_lc_rs::rand::fill(bytes.as_mut_slice()).expect("failed to fill random bytes");149149+ Self(bytes)150150+ }151151+}152152+153153+/// Proof Key for Code Exchange154154+///155155+/// See: [RFC7636](https://www.rfc-editor.org/rfc/rfc7636)156156+#[derive(Debug)]157157+pub struct ProofKeyCodeExchange {158158+ /// Method used to generate the code challenge.159159+ pub code_challenge_method: CodeChallengeMethod,160160+161161+ /// Code verifier secret.162162+ pub code_verifier: CodeVerifier,163163+}164164+165165+impl ProofKeyCodeExchange {166166+ /// Compute the code challenge.167167+ #[must_use]168168+ pub fn code_challenge(&self) -> String {169169+ self.code_verifier170170+ .code_challenge(self.code_challenge_method)171171+ }172172+173173+ /// Verify `challenge` was generated by this verifier.174174+ ///175175+ /// # Errors176176+ ///177177+ /// Returns an [`InvalidGrant`] error if the challenge could not have been178178+ /// generated by this verifier.179179+ pub fn verify<C>(&self, challenge: C) -> Result<(), InvalidGrant>180180+ where181181+ C: AsRef<[u8]>,182182+ {183183+ self.code_verifier184184+ .verify(self.code_challenge_method, challenge)185185+ }186186+}187187+188188+impl Default for ProofKeyCodeExchange {189189+ /// Create a Proof Key for Code Exchange with randomly generated code190190+ /// verifier of default length and the default code challenge method191191+ /// (currently 'S256').192192+ ///193193+ /// # Panics194194+ ///195195+ /// Panics if the random number generation fails.196196+ fn default() -> Self {197197+ Self {198198+ code_challenge_method: CodeChallengeMethod::default(),199199+ code_verifier: CodeVerifier::default(),200200+ }201201+ }202202+}203203+204204+#[derive(Debug, Serialize)]205205+pub struct CodeChallenge {206206+ pub code_challenge_method: CodeChallengeMethod,207207+ pub code_challenge: String,208208+}209209+210210+impl From<&ProofKeyCodeExchange> for CodeChallenge {211211+ fn from(value: &ProofKeyCodeExchange) -> Self {212212+ Self {213213+ code_challenge_method: value.code_challenge_method,214214+ code_challenge: value.code_challenge(),215215+ }216216+ }217217+}218218+219219+#[test]220220+fn can_generate_and_verify() {221221+ let verifier = CodeVerifier::default();222222+ let challenge = verifier.code_challenge(CodeChallengeMethod::S256);223223+ verifier224224+ .verify(CodeChallengeMethod::S256, challenge)225225+ .expect("challenge generated by verifier should be valid");226226+}227227+228228+#[test]229229+fn can_serialize_and_deserialize_verifier() {230230+ #[derive(Default, Deserialize, Serialize)]231231+ struct Transport {232232+ code_verifier: CodeVerifier,233233+ }234234+235235+ let (serialized, challenge) = {236236+ let original = Transport::default();237237+ let challenge = original238238+ .code_verifier239239+ .code_challenge(CodeChallengeMethod::S256);240240+241241+ (242242+ serde_json::to_string(&original).expect("code verifier should be serializabled"),243243+ challenge,244244+ )245245+ };246246+247247+ let deserialized: Transport =248248+ serde_json::from_str(&serialized).expect("code verifier should be deserializable");249249+250250+ deserialized251251+ .code_verifier252252+ .verify(CodeChallengeMethod::S256, challenge)253253+ .expect("challenge generated by verifier should be valid");254254+}
+197-55
crates/gordian-auth/src/resources.rs
···11-use serde::{Deserialize, Serialize};11+use std::borrow::Cow;22+use std::collections::HashSet;2333-use crate::{44- support_set::SupportSet, types::CodeChallengeMethod, types::GrantType, types::Scope,55- types::SigningAlgorithm, validated_url::ValidatedUrl,66-};44+use serde::Deserialize;55+use serde::Serialize;66+use url::Url;77+88+use crate::jwk::JsonWebKey;99+use crate::pkce;1010+use crate::supported::Supported;1111+use crate::types::ApplicationType;1212+use crate::types::GrantType;1313+use crate::types::ResponseType;1414+use crate::types::SigningAlgorithm;1515+use crate::types::TokenEndpointAuthMethod;1616+1717+pub type OpaqueString = Box<str>;718819#[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)]920#[serde(rename_all = "snake_case")]···2817///2918/// See: <https://atproto.com/specs/oauth#server-metadata>3019/// See: <https://datatracker.ietf.org/doc/rfc9728/>3131-///3220#[derive(Debug, Deserialize, Serialize)]3321#[serde(rename_all = "snake_case")]3434-pub struct OauthProtectedResource {2222+pub struct ProtectedResource {3523 /// Protected resource's identifier.3636- pub resource: ValidatedUrl,2424+ pub resource: OpaqueString,37253826 #[serde(default)]3939- pub authorization_servers: Vec<ValidatedUrl>,2727+ pub authorization_servers: Vec<Url>,40284129 #[serde(default)]4242- pub scopes_supported: SupportSet<Scope>,3030+ pub scopes_supported: Vec<String>,43314432 #[serde(default)]4545- pub bearer_methods_supported: SupportSet<BearerMethod>,3333+ pub bearer_methods_supported: Supported<BearerMethod>,46344735 #[serde(default)]4848- pub resource_documentation: Option<ValidatedUrl>,3636+ pub resource_documentation: Option<Url>,3737+}3838+3939+pub trait IntoProtectedResourceUrl {4040+ /// Convert `self` into a URL for the protected resource metadata document4141+ /// located at "/.well-known/oauth-protected-resource".4242+ fn into_protected_resource_url(self) -> Url;4343+}4444+4545+impl IntoProtectedResourceUrl for &gordian_identity::Service {4646+ fn into_protected_resource_url(self) -> Url {4747+ let mut url = self.service_endpoint.clone();4848+ url.set_path("/.well-known/oauth-protected-resource");4949+ url5050+ }5151+}5252+5353+impl IntoProtectedResourceUrl for &Url {5454+ fn into_protected_resource_url(self) -> Url {5555+ let mut url = self.clone();5656+ url.set_path("/.well-known/oauth-protected-resource");5757+ url5858+ }5959+}6060+6161+impl IntoProtectedResourceUrl for Url {6262+ // Optimized impl for owned [`Url`].6363+ fn into_protected_resource_url(mut self) -> Url {6464+ self.set_path("/.well-known/oauth-protected-resource");6565+ self6666+ }4967}50685169/// Authorization Server Metadata.5270///5353-/// Published by an Authorization Server or PDS at "/.well-known/oauth-authorization-server".7171+/// Published by an Authorization Server or PDS at7272+/// "/.well-known/oauth-authorization-server".5473///5574/// See: <https://atproto.com/specs/oauth#server-metadata>5675/// See: <https://datatracker.ietf.org/doc/html/rfc8414>5757-///5876#[derive(Debug, Deserialize, Serialize)]5977#[serde(rename_all = "snake_case")]6060-pub struct OauthAuthorizationServer {6161- pub issuer: ValidatedUrl,7878+#[allow(clippy::struct_excessive_bools)]7979+pub struct AuthorizationMetadata {8080+ pub issuer: OpaqueString,62816382 pub request_parameter_supported: bool,6483···96559756 pub require_request_uri_registration: bool,98579999- pub scopes_supported: SupportSet<Scope>,5858+ pub scopes_supported: HashSet<String>,10059101101- pub subject_types_supported: Vec<String>,6060+ pub subject_types_supported: HashSet<String>,10261103103- pub response_types_supported: Vec<String>,6262+ pub response_types_supported: HashSet<String>,10463105105- pub response_modes_supported: Vec<String>,6464+ pub response_modes_supported: HashSet<String>,10665107107- pub grant_types_supported: SupportSet<GrantType>,6666+ pub grant_types_supported: Supported<GrantType>,10867109109- pub code_challenge_methods_supported: SupportSet<CodeChallengeMethod>,6868+ pub code_challenge_methods_supported: Supported<pkce::CodeChallengeMethod>,1106911170 pub ui_locales_supported: Vec<String>,1127111372 pub display_values_supported: Vec<String>,11473115115- pub request_object_signing_alg_values_supported: SupportSet<SigningAlgorithm>,7474+ pub request_object_signing_alg_values_supported: Supported<SigningAlgorithm>,1167511776 pub authorization_response_iss_parameter_supported: bool,11877119119- pub request_object_encryption_alg_values_supported: Vec<String>,7878+ pub request_object_encryption_alg_values_supported: HashSet<String>,12079121121- pub request_object_encryption_enc_values_supported: Vec<String>,8080+ pub request_object_encryption_enc_values_supported: HashSet<String>,12281123123- pub jwks_uri: Option<ValidatedUrl>,8282+ pub jwks_uri: Option<String>,12483125125- pub authorization_endpoint: ValidatedUrl,8484+ pub authorization_endpoint: Url,12685127127- pub token_endpoint: ValidatedUrl,8686+ pub token_endpoint: Url,12887129129- pub token_endpoint_auth_methods_supported: Vec<String>,8888+ pub token_endpoint_auth_methods_supported: Supported<TokenEndpointAuthMethod>,13089131131- /// Must include `SigningAlgorithm::ES256`.132132- pub token_endpoint_auth_signing_alg_values_supported: SupportSet<SigningAlgorithm>,9090+ pub token_endpoint_auth_signing_alg_values_supported: Supported<SigningAlgorithm>,13391134134- pub revocation_endpoint: ValidatedUrl,9292+ pub revocation_endpoint: Url,13593136136- pub introspection_endpoint: ValidatedUrl,9494+ pub introspection_endpoint: Option<Url>,13795138138- /// PAR endpoint URL. Required.139139- pub pushed_authorization_request_endpoint: ValidatedUrl,9696+ pub pushed_authorization_request_endpoint: Url,1409714198 pub require_pushed_authorization_requests: bool,14299143143- /// Must include `SigningAlgorithm::ES256`.144144- pub dpop_signing_alg_values_supported: SupportSet<SigningAlgorithm>,100100+ pub dpop_signing_alg_values_supported: Supported<SigningAlgorithm>,145101146146- /// Must be `true`. Required.147102 pub client_id_metadata_document_supported: bool,148103}149104150150-#[cfg(test)]151151-mod tests {152152- use super::OauthProtectedResource;105105+pub trait IntoAuthorizationServerUrl {106106+ /// Convert `self` into a URL for the authoriztion server metadata document107107+ /// located at "/.well-known/oauth-authorization-server".108108+ fn into_authorization_server_url(self) -> Url;109109+}153110154154- #[test]155155- fn parse_protected_resource() {156156- 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"}"#;157157- let parsed: OauthProtectedResource = serde_json::from_str(sample).unwrap();158158- assert_eq!(159159- parsed.resource.as_str(),160160- "https://porcini.us-east.host.bsky.network/"161161- );111111+impl IntoAuthorizationServerUrl for &Url {112112+ fn into_authorization_server_url(self) -> Url {113113+ let mut url = self.clone();114114+ url.set_path("/.well-known/oauth-authorization-server");115115+ url162116 }117117+}163118164164- #[test]165165- fn parse_authorization_server() {166166- const SAMPLE: &str = r#"167167- {119119+impl IntoAuthorizationServerUrl for Url {120120+ // Optimized impl for owned [`Url`].121121+ fn into_authorization_server_url(mut self) -> Url {122122+ self.set_path("/.well-known/oauth-authorization-server");123123+ self124124+ }125125+}126126+127127+#[derive(Clone, Debug, Deserialize, Serialize)]128128+pub struct ClientMetadata {129129+ /// Full URL used to fetch the client metadata JSON.130130+ pub client_id: Url,131131+132132+ pub application_type: ApplicationType,133133+134134+ #[serde(skip_serializing_if = "Vec::is_empty")]135135+ pub grant_types: Vec<GrantType>,136136+137137+ /// Any scope values which _might_ be requested by this client. The138138+ /// "atproto" scope is required.139139+ #[serde(with = "crate::serde::vec_as_spaced_string")]140140+ pub scope: Vec<Cow<'static, str>>,141141+142142+ #[serde(skip_serializing_if = "Vec::is_empty")]143143+ pub response_types: Vec<ResponseType>,144144+145145+ /// Fully-qualified redirect/callback url.146146+ #[serde(skip_serializing_if = "Vec::is_empty")]147147+ pub redirect_uris: Vec<Url>,148148+149149+ #[serde(skip_serializing_if = "Option::is_none")]150150+ pub token_endpoint_auth_method: Option<TokenEndpointAuthMethod>,151151+152152+ #[serde(skip_serializing_if = "Option::is_none")]153153+ pub token_endpoint_auth_signing_alg: Option<SigningAlgorithm>,154154+155155+ pub dpop_bound_access_tokens: bool,156156+157157+ #[serde(skip_serializing_if = "Vec::is_empty")]158158+ pub jwks: Vec<JsonWebKey>,159159+160160+ #[serde(skip_serializing_if = "Option::is_none")]161161+ pub jwks_uri: Option<Url>,162162+163163+ /// Human-readable name of the client.164164+ #[serde(skip_serializing_if = "Option::is_none")]165165+ pub client_name: Option<String>,166166+167167+ /// Homepage URL for the client.168168+ ///169169+ /// If provided, must have same hostname as [`client_id`].170170+ #[serde(skip_serializing_if = "Option::is_none")]171171+ pub client_uri: Option<Url>,172172+173173+ /// HTTP URL to client logo.174174+ #[serde(skip_serializing_if = "Option::is_none")]175175+ pub logo_uri: Option<String>,176176+177177+ /// HTTP URL to human-readable terms of service for the client.178178+ #[serde(skip_serializing_if = "Option::is_none")]179179+ pub tos_uri: Option<String>,180180+181181+ /// HTTP URL to human-readable privacy policy for the client.182182+ #[serde(skip_serializing_if = "Option::is_none")]183183+ pub policy_uri: Option<String>,184184+}185185+186186+impl From<Url> for ClientMetadata {187187+ fn from(client_id: Url) -> Self {188188+ Self {189189+ client_id,190190+ application_type: ApplicationType::default(),191191+ grant_types: vec![GrantType::AuthorizationCode],192192+ scope: vec!["atproto".into()],193193+ response_types: vec![ResponseType::default()],194194+ redirect_uris: Vec::new(),195195+ token_endpoint_auth_method: None,196196+ token_endpoint_auth_signing_alg: None,197197+ dpop_bound_access_tokens: true,198198+ jwks: Vec::new(),199199+ jwks_uri: None,200200+ client_name: None,201201+ client_uri: None,202202+ logo_uri: None,203203+ tos_uri: None,204204+ policy_uri: None,205205+ }206206+ }207207+}208208+209209+#[test]210210+fn can_parse_protected_resource() {211211+ let sample = r#"{212212+ "resource": "https://porcini.us-east.host.bsky.network",213213+ "authorization_servers": ["https://bsky.social"],214214+ "scopes_supported": [],215215+ "bearer_methods_supported": ["header"],216216+ "resource_documentation": "https://atproto.com"217217+ }"#;218218+ let parsed: ProtectedResource = serde_json::from_str(sample).unwrap();219219+ assert_eq!(220220+ parsed.resource.as_ref(),221221+ "https://porcini.us-east.host.bsky.network"222222+ );223223+}224224+225225+#[test]226226+fn can_parse_authorization_server() {227227+ const SAMPLE: &str = r#"228228+ {168229 "issuer": "https://bsky.social",169230 "request_parameter_supported": true,170231 "request_uri_parameter_supported": true,···358215 }359216 "#;360217361361- let _: super::OauthAuthorizationServer = serde_json::from_str(SAMPLE).unwrap();362362- }218218+ let _: AuthorizationMetadata = serde_json::from_str(SAMPLE).unwrap();363219}
+95
crates/gordian-auth/src/serde.rs
···11+pub mod base64 {22+ //! Serialize and deserialize a `Vec<u8>` as an URL-safe base64 string.33+ //!44+ //! Use this module in combination with serde's [`#[with]`][with] attribute.55+ //!66+ //! [with]: https://serde.rs/field-attrs.html#with77+88+ pub use data_encoding::BASE64URL_NOPAD as ENCODING;99+ use serde::Deserialize;1010+ use serde::Deserializer;1111+ use serde::Serializer;1212+1313+ #[allow(clippy::missing_errors_doc)]1414+ pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>1515+ where1616+ D: Deserializer<'de>,1717+ {1818+ let encoded = <&str as Deserialize>::deserialize(deserializer)?;1919+ let bytes = ENCODING2020+ .decode(encoded.as_bytes())2121+ .map_err(serde::de::Error::custom)?;2222+2323+ Ok(bytes)2424+ }2525+2626+ #[allow(clippy::missing_errors_doc)]2727+ pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>2828+ where2929+ S: Serializer,3030+ {3131+ let encoded = ENCODING.encode(bytes);3232+ serializer.serialize_str(&encoded)3333+ }3434+}3535+3636+pub mod vec_as_spaced_string {3737+ //! Serialize and deserialize sequence of items as a space-separated string.3838+ //!3939+ //! Use this module in combination with serde's [`#[with]`][with] attribute.4040+ //!4141+ //! [with]: https://serde.rs/field-attrs.html#with4242+4343+ use std::borrow::Cow;4444+4545+ use serde::Deserialize;4646+ use serde::Deserializer;4747+ use serde::Serialize;4848+ use serde::Serializer;4949+5050+ #[allow(clippy::missing_errors_doc)]5151+ pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Cow<'static, str>>, D::Error>5252+ where5353+ D: Deserializer<'de>,5454+ {5555+ let values = <&str as Deserialize>::deserialize(deserializer)?;5656+ Ok(values5757+ .split_whitespace()5858+ .map(|value| Cow::Owned(value.to_string()))5959+ .collect())6060+ }6161+6262+ #[allow(clippy::missing_errors_doc)]6363+ pub fn serialize<S, T>(values: &[T], serializer: S) -> Result<S::Ok, S::Error>6464+ where6565+ S: Serializer,6666+ T: Serialize,6767+ {6868+ let mut buffer = String::new();6969+7070+ for value in values {7171+ let ser = serde_json::to_string(&value).map_err(serde::ser::Error::custom)?;7272+ buffer.push_str(ser.trim_matches('"'));7373+ buffer.push(' ');7474+ }7575+7676+ serializer.serialize_str(buffer.trim_end())7777+ }7878+7979+ #[test]8080+ fn can_serialize_spaced_str() {8181+ #[derive(Serialize)]8282+ struct Test<'a> {8383+ #[serde(with = "self")]8484+ scopes: &'a [&'static str],8585+ }8686+8787+ let scopes = ["atproto", "transition:generic"];8888+ let s = serde_json::to_string(&Test {8989+ scopes: scopes.as_slice(),9090+ })9191+ .unwrap();9292+9393+ assert_eq!(s, r#"{"scopes":"atproto transition:generic"}"#);9494+ }9595+}
-163
crates/gordian-auth/src/support_set.rs
···11-use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeSeq as _};22-use std::{33- collections::HashSet,44- hash::{BuildHasher, RandomState},55- marker::PhantomData,66- ops::{Deref, DerefMut},77-};88-99-/// Deserializes a sequence while ignoring elements that cannot be deserialized.1010-///1111-/// # Example1212-///1313-/// ```rust,ignore1414-/// use oauth::support_set::SupportSet;1515-/// use serde::Deserialize;1616-///1717-/// /// Signature algorithms our program supports.1818-/// #[derive(Deserialize, Hash, PartialEq, Eq)]1919-/// enum Algorithms {2020-/// ES256K,2121-/// ES256,2222-/// }2323-///2424-/// // Response from some API.2525-/// let response = r#"["ES256", "RS256", "RS384", "RS512"]"#;2626-/// let matched: SupportSet<Algorithms> = serde_json::from_str(response).unwrap();2727-///2828-/// // The response only contains one algorithm we recognise.2929-/// assert_eq!(matched.len(), 1);3030-/// assert!(matched.contains(&Algorithms::ES256));3131-/// ```3232-///3333-#[derive(Debug)]3434-pub struct SupportSet<T, S = RandomState>(pub HashSet<T, S>);3535-3636-impl<T, S> SupportSet<T, S>3737-where3838- S: BuildHasher + Default,3939-{4040- pub fn new() -> Self {4141- Self(HashSet::with_hasher(Default::default()))4242- }4343-}4444-4545-impl<T> Default for SupportSet<T> {4646- #[inline]4747- fn default() -> Self {4848- Self(HashSet::new())4949- }5050-}5151-5252-impl<T> Deref for SupportSet<T> {5353- type Target = HashSet<T>;5454- #[inline]5555- fn deref(&self) -> &Self::Target {5656- &self.05757- }5858-}5959-6060-impl<T> DerefMut for SupportSet<T> {6161- #[inline]6262- fn deref_mut(&mut self) -> &mut Self::Target {6363- &mut self.06464- }6565-}6666-6767-impl<T, S> FromIterator<T> for SupportSet<T, S>6868-where6969- T: Eq + std::hash::Hash,7070- S: BuildHasher + Default,7171-{7272- #[inline]7373- fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {7474- let mut set = HashSet::with_hasher(Default::default());7575- set.extend(iter);7676- Self(set)7777- }7878-}7979-8080-impl<'de, T> Deserialize<'de> for SupportSet<T>8181-where8282- T: Deserialize<'de> + std::hash::Hash + Eq,8383-{8484- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>8585- where8686- D: serde::Deserializer<'de>,8787- {8888- struct RecognisedSet<T>(PhantomData<T>);8989-9090- impl<'de, T> Visitor<'de> for RecognisedSet<T>9191- where9292- T: Deserialize<'de> + std::hash::Hash + Eq,9393- {9494- type Value = HashSet<T>;9595-9696- fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {9797- formatter.write_str("something")9898- }9999-100100- fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>101101- where102102- A: serde::de::SeqAccess<'de>,103103- {104104- let mut set = HashSet::new();105105- loop {106106- let Ok(maybe_element) = seq.next_element() else {107107- continue;108108- };109109- let Some(element) = maybe_element else {110110- break;111111- };112112- set.insert(element);113113- }114114- Ok(set)115115- }116116- }117117-118118- deserializer119119- .deserialize_seq(RecognisedSet(PhantomData))120120- .map(Self)121121- }122122-}123123-124124-impl<T> Serialize for SupportSet<T>125125-where126126- T: Serialize,127127-{128128- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>129129- where130130- S: serde::Serializer,131131- {132132- let mut seq = serializer.serialize_seq(Some(self.len()))?;133133- for element in self.iter() {134134- seq.serialize_element(element)?;135135- }136136- seq.end()137137- }138138-}139139-140140-#[cfg(test)]141141-mod tests {142142- use super::SupportSet;143143- use serde::Deserialize;144144-145145- #[derive(Deserialize, Hash, PartialEq, Eq)]146146- #[serde(rename_all = "lowercase")]147147- enum SupportedValues {148148- Elephants,149149- Bananas,150150- Aardvarks,151151- }152152-153153- #[test]154154- fn ignore_unsupported_values() {155155- let sample = r#"["elephants", "pears", "bananas", "goose"]"#;156156- let set: SupportSet<SupportedValues> = serde_json::from_str(sample).unwrap();157157-158158- assert_eq!(set.len(), 2);159159- assert!(set.contains(&SupportedValues::Elephants));160160- assert!(set.contains(&SupportedValues::Bananas));161161- assert!(!set.contains(&SupportedValues::Aardvarks));162162- }163163-}
+222
crates/gordian-auth/src/supported.rs
···11+use core::fmt;22+use core::ops;33+use std::collections::HashSet;44+use std::hash::BuildHasher;55+use std::hash::Hash;66+use std::hash::RandomState;77+use std::marker::PhantomData;88+99+use serde::Deserialize;1010+use serde::Serialize;1111+use serde::de::Visitor;1212+use serde::ser::SerializeSeq;1313+1414+/// A wrapper for [`HashSet`] which discards unknown values when deserialized1515+/// via serde.1616+///1717+/// NOTE: This will not successfully round-trip when re-serialized.1818+///1919+/// # Example2020+///2121+/// ```rust2222+/// use gordian_auth::supported::Supported;2323+/// use serde::Deserialize;2424+///2525+/// // Algorithms supported by some API, including algorithms we don't support.2626+/// let response = r#"["P256", "RS256", "ES256", "ES384", "ES512"]"#;2727+///2828+/// /// Algorithms our program supports.2929+/// #[derive(Deserialize, Hash, PartialEq, Eq)]3030+/// enum Algorithm {3131+/// ES256,3232+/// ES256K,3333+/// }3434+///3535+/// let matched: Supported<Algorithm> = serde_json::from_str(response).unwrap();3636+///3737+/// // The algorithm we support will be in the set.3838+/// assert!(matched.contains(&Algorithm::ES256));3939+///4040+/// // Everything was discarded.4141+/// assert_eq!(matched.len(), 1);4242+/// ```4343+pub struct Supported<T, S = RandomState>(HashSet<T, S>);4444+4545+impl<T> Supported<T, RandomState> {4646+ /// Create an empty `Supported`.4747+ ///4848+ /// # Example4949+ ///5050+ /// ```rust5151+ /// use gordian_auth::supported::Supported;5252+ /// use gordian_auth::types::SigningAlgorithm;5353+ /// let set: Supported<SigningAlgorithm> = Supported::new();5454+ /// ```5555+ #[must_use]5656+ pub fn new() -> Self {5757+ Self(HashSet::new())5858+ }5959+6060+ /// Create an empty `Supported` with a least the specified `capacity`.6161+ ///6262+ /// # Example6363+ ///6464+ /// ```rust6565+ /// use gordian_auth::supported::Supported;6666+ /// use gordian_auth::types::SigningAlgorithm;6767+ /// let set: Supported<SigningAlgorithm> = Supported::with_capacity(42);6868+ /// assert!(set.capacity() >= 42);6969+ /// ```7070+ #[must_use]7171+ pub fn with_capacity(capacity: usize) -> Self {7272+ Self(HashSet::with_capacity(capacity))7373+ }7474+}7575+7676+impl<T, S: BuildHasher> Supported<T, S> {7777+ pub const fn with_hasher(hasher: S) -> Self {7878+ Self(HashSet::with_hasher(hasher))7979+ }8080+8181+ pub fn with_capacity_and_hasher(capacity: usize, hasher: S) -> Self {8282+ Self(HashSet::with_capacity_and_hasher(capacity, hasher))8383+ }8484+}8585+8686+impl<T: fmt::Debug> fmt::Debug for Supported<T> {8787+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {8888+ fmt::Debug::fmt(&self.0, f)8989+ }9090+}9191+9292+impl<T> ops::Deref for Supported<T> {9393+ type Target = HashSet<T>;9494+9595+ fn deref(&self) -> &Self::Target {9696+ &self.09797+ }9898+}9999+100100+impl<T> ops::DerefMut for Supported<T> {101101+ fn deref_mut(&mut self) -> &mut Self::Target {102102+ &mut self.0103103+ }104104+}105105+106106+impl<T> Default for Supported<T> {107107+ fn default() -> Self {108108+ Self(HashSet::new())109109+ }110110+}111111+112112+impl<T, S> From<HashSet<T, S>> for Supported<T, S> {113113+ fn from(value: HashSet<T, S>) -> Self {114114+ Self(value)115115+ }116116+}117117+118118+impl<T, S> From<Supported<T, S>> for HashSet<T, S> {119119+ fn from(value: Supported<T, S>) -> Self {120120+ value.0121121+ }122122+}123123+124124+impl<'de, T> Deserialize<'de> for Supported<T>125125+where126126+ T: Deserialize<'de> + Hash + Eq,127127+{128128+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>129129+ where130130+ D: serde::Deserializer<'de>,131131+ {132132+ struct RecognisedSet<T>(PhantomData<T>);133133+134134+ impl<T> RecognisedSet<T> {135135+ const fn new() -> Self {136136+ Self(PhantomData)137137+ }138138+ }139139+140140+ impl<'de, T> Visitor<'de> for RecognisedSet<T>141141+ where142142+ T: Deserialize<'de> + Hash + Eq,143143+ {144144+ type Value = Supported<T>;145145+146146+ fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {147147+ formatter.write_str("sequence")148148+ }149149+150150+ fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>151151+ where152152+ A: serde::de::SeqAccess<'de>,153153+ {154154+ let mut set = HashSet::new();155155+ loop {156156+ match seq.next_element() {157157+ Ok(None) => break,158158+ Ok(Some(value)) => _ = set.insert(value),159159+ Err(_) => {}160160+ }161161+ }162162+ Ok(Supported(set))163163+ }164164+ }165165+166166+ deserializer.deserialize_seq(RecognisedSet::new())167167+ }168168+}169169+170170+impl<T> Serialize for Supported<T>171171+where172172+ T: Serialize,173173+{174174+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>175175+ where176176+ S: serde::Serializer,177177+ {178178+ let mut seq = serializer.serialize_seq(Some(self.len()))?;179179+ for element in self.iter() {180180+ seq.serialize_element(element)?;181181+ }182182+ seq.end()183183+ }184184+}185185+186186+impl<I: Hash + Eq> FromIterator<I> for Supported<I> {187187+ fn from_iter<T: IntoIterator<Item = I>>(iter: T) -> Self {188188+ Self(HashSet::from_iter(iter))189189+ }190190+}191191+192192+#[cfg(test)]193193+mod tests {194194+ use serde::Deserialize;195195+196196+ use super::Supported;197197+198198+ #[derive(Deserialize, Hash, PartialEq, Eq)]199199+ enum Algorithm {200200+ ES256,201201+ }202202+203203+ #[derive(Deserialize)]204204+ struct Resource {205205+ signing_algorithms: Supported<Algorithm>,206206+ }207207+208208+ #[test]209209+ fn can_deserialize() {210210+ let resource: Resource =211211+ serde_json::from_str(r#"{"signing_algorithms":["RS256", "PS256", "ES256", "ES384"]}"#)212212+ .unwrap();213213+214214+ assert!(resource.signing_algorithms.contains(&Algorithm::ES256));215215+ }216216+217217+ #[test]218218+ fn construct_from_iter() {219219+ let sup = Supported::from_iter([Algorithm::ES256]);220220+ assert!(sup.contains(&Algorithm::ES256));221221+ }222222+}
···11use gordian_types::did::DidBuf;22-use serde::{Deserialize, Serialize};22+use serde::Deserialize;33+use serde::Serialize;34use url::Url;4556#[derive(Clone, Debug, Deserialize, Serialize)]···2423}25242625impl Service {2727- /// Create a [`Service`] definition for an `ATproto` PDS from `service_endpoint`.2626+ /// Create a [`Service`] definition for an `ATproto` PDS from2727+ /// `service_endpoint`.2828 #[must_use]2929 pub fn atproto_pds(service_endpoint: Url) -> Self {3030 Self {···5755 /// # Panics5856 ///5957 /// Panics if `handle` is not a valid Athmosphere handle6060- ///6158 pub fn new(id: &str, handle: &str) -> Result<Self, gordian_types::did::Error> {6259 let id = id.parse()?;6360 Ok(Self {
+14-16
crates/gordian-identity/src/lib.rs
···44use core::fmt;55use std::sync::Arc;6677-use futures_util::{FutureExt as _, future::BoxFuture};88-use gordian_types::did::DidBuf;99-1010-pub use document::{DidDocument, Service, VerificationMethod};77+pub use document::DidDocument;88+pub use document::Service;99+pub use document::VerificationMethod;1010+use futures_util::FutureExt as _;1111+use futures_util::future::BoxFuture;1112pub use gordian_types::did::Did;1313+use gordian_types::did::DidBuf;12141315pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";1416···19172018pub trait ResolveIdentity: fmt::Debug + Sync {2119 /// Resolve a handle or a DID to a DID and DID document.2222- ///2320 fn resolve<'s: 'a, 'a>(2421 &'s self,2522 ident: &'a str,···59586059 /// Resolve a handle to a DID.6160 ///6262- /// Implementors are not required to bi-directionally confirm the resolution.6161+ /// Implementors are not required to bi-directionally confirm the6262+ /// resolution.6363 ///6464 /// [`ResolveIdentity::resolve`] should be preferred.6565 ///···6967 /// # Errors7068 ///7169 /// Returns an error `handle` cannot be resolved to a DID.7272- ///7370 fn resolve_handle<'s: 'h, 'h>(7471 &'s self,7572 handle: &'h str,···76757776 /// Resolve a DID to DID document.7877 ///7979- /// Implementors are not required to bi-directionally confirm the resolution.7878+ /// Implementors are not required to bi-directionally confirm the7979+ /// resolution.8080 ///8181 /// [`ResolveIdentity::resolve`] should be preferred.8282 ///···8684 /// # Errors8785 ///8886 /// Returns an error if `did` cannot be resolved to a DID document.8989- ///9087 fn resolve_did<'s: 'd, 'd>(9188 &'s self,9289 did: &'d Did,···9594 /// specified DID.9695 ///9796 /// This will have no effect on system-level caches, like DNS caches.9898- ///9997 fn invalidate_did<'s: 'd, 'd>(&'s self, _: &'d Did) -> BoxFuture<'d, ()> {10098 async {}.boxed()10199 }···145145 ///146146 /// # Errors147147 ///148148- /// Returns a error if `ident` cannot be resolved. This included bi-directional resolution149149- /// errors.150150- ///148148+ /// Returns a error if `ident` cannot be resolved. This included149149+ /// bi-directional resolution errors.151150 pub async fn resolve(&self, ident: &str) -> Result<(DidBuf, DidDocument), ResolveError> {152151 let ident = ident.trim_start_matches('@');153152 self.inner.resolve(ident).await···157158 /// # Errors158159 ///159160 /// Returns a error if `handle` cannot be resolved to a DID.160160- ///161161 #[inline]162162 pub async fn resolve_handle(&self, handle: &str) -> Result<DidBuf, ResolveError> {163163 let handle = handle.trim_start_matches('@');···168170 /// # Errors169171 ///170172 /// Returns a error if `did` cannot be resolved to a DID document.171171- ///172173 #[inline]173174 pub async fn resolve_did(&self, did: &Did) -> Result<DidDocument, ResolveError> {174175 self.inner.resolve_did(did).await···264267265268 #[must_use]266269 pub fn build_with(self, http: HttpClient) -> Resolver {270270+ use std::sync::Arc;271271+267272 use resolvers::direct::DirectResolver;268273 use resolvers::memcache::MemcacheResolver;269269- use std::sync::Arc;270274271275 let inner: Arc<dyn ResolveIdentity + Send + Sync + 'static> = match self.backend {272276 ResolverBackend::Direct => Arc::new(
+12-7
crates/gordian-identity/src/resolvers/direct.rs
···11use std::borrow::Cow;2233-use futures_util::{FutureExt as _, future::BoxFuture};33+use futures_util::FutureExt as _;44+use futures_util::future::BoxFuture;45use gordian_types::did::DidBuf;66+use hickory_resolver::ResolveError as DnsResolveError;77+use hickory_resolver::Resolver as DnsClient;88+use hickory_resolver::TokioResolver;99+use hickory_resolver::name_server::ConnectionProvider;510use hickory_resolver::name_server::TokioConnectionProvider;66-use hickory_resolver::{77- ResolveError as DnsResolveError, Resolver as DnsClient, TokioResolver,88- name_server::ConnectionProvider,99-};1011use tokio::time::Instant;11121212-use crate::{DEFAULT_PLC_DIRECTORY, Did, DidDocument, HttpClient, ResolveError, ResolveIdentity};1313+use crate::DEFAULT_PLC_DIRECTORY;1414+use crate::Did;1515+use crate::DidDocument;1616+use crate::HttpClient;1717+use crate::ResolveError;1818+use crate::ResolveIdentity;13191420pub struct DirectResolver<'plc, R: ConnectionProvider> {1521 plc: Cow<'plc, str>,···137131 /// # Panics138132 ///139133 /// Panics if the dns resolver cannot be initialised.140140- ///141134 #[must_use]142135 pub fn build_with(self, http: HttpClient) -> DirectResolver<'plc, TokioConnectionProvider> {143136 DirectResolver {
+11-4
crates/gordian-identity/src/resolvers/memcache.rs
···11-use std::{sync::Arc, time::Duration};11+use std::sync::Arc;22+use std::time::Duration;2333-use futures_util::{FutureExt as _, TryFutureExt as _, future::BoxFuture};44+use futures_util::FutureExt as _;55+use futures_util::TryFutureExt as _;66+use futures_util::future::BoxFuture;47use gordian_types::DidBuf;55-use moka::future::{Cache, CacheBuilder};88+use moka::future::Cache;99+use moka::future::CacheBuilder;61077-use crate::{Did, DidDocument, ResolveError, ResolveIdentity};1111+use crate::Did;1212+use crate::DidDocument;1313+use crate::ResolveError;1414+use crate::ResolveIdentity;815916const DEFAULT_DID_CACHE_CAP: u64 = 1024;1017const DEFAULT_DOC_CACHE_CAP: u64 = 1024;
+14-16
crates/gordian-jetstream/src/client.rs
···11-use crate::{22- Nsid,33- de::Event,44- metrics::{Metrics, MetricsData},55- subscriber_options::SubscriberOptions,66- task::JetstreamTaskError,77-};11+use std::sync::Arc;22+use std::sync::Mutex;33+84use bytes::Bytes;95use gordian_types::DidBuf;1010-use std::sync::{Arc, Mutex};116use tokio::sync::oneshot;1212-use tokio_util::sync::{CancellationToken, DropGuard};77+use tokio_util::sync::CancellationToken;88+use tokio_util::sync::DropGuard;99+1010+use crate::Nsid;1111+use crate::de::Event;1212+use crate::metrics::Metrics;1313+use crate::metrics::MetricsData;1414+use crate::subscriber_options::SubscriberOptions;1515+use crate::task::JetstreamTaskError;13161417#[derive(Debug)]1518pub struct JetstreamClient {···4744 /// # Panics4845 ///4946 /// Panics if the [`Mutex`] for the client options has been poisoned.5050- ///5147 pub async fn add_did(&self, did: impl Into<DidBuf>) -> Result<(), JetstreamClientError> {5248 if self.options.lock().unwrap().add_did(did.into())? {5349 // The DID is new to the client, notify the task to update.···6462 /// # Panics6563 ///6664 /// Panics if the [`Mutex`] for the client options has been poisoned.6767- ///6865 pub async fn remove_did(&self, did: impl Into<DidBuf>) -> Result<(), JetstreamClientError> {6966 if self.options.lock().unwrap().remove_did(&did.into()) {7067 self.update_task().await?;···8079 /// # Panics8180 ///8281 /// Panics if the [`Mutex`] for the client options has been poisoned.8383- ///8482 pub async fn add_collection(8583 &self,8684 collection: impl Into<Box<Nsid>>,···105105 /// # Panics106106 ///107107 /// Panics if the [`Mutex`] for the client options has been poisoned.108108- ///109108 pub async fn remove_collection(110109 &self,111110 collection: impl Into<Box<Nsid>>,···130131 /// # Errors131132 ///132133 /// Returns an error if the Jetstream task is no longer active.133133- ///134134 pub async fn shutdown(self) -> Result<(), JetstreamClientError> {135135 let (command, complete) = ClientCommand::shutdown();136136 self.client_tx.send(command)?;···227229 Some(JetstreamEvent::new(bytes))228230 }229231230230- /// Consume the Jetstream receiver and return the wrapped flume channel receiver.232232+ /// Consume the Jetstream receiver and return the wrapped flume channel233233+ /// receiver.231234 #[must_use]232235 pub fn to_inner(self) -> flume::Receiver<Bytes> {233236 self.event_rx···262263 ///263264 /// Returns an error if the event cannot be deserialized from it JSON264265 /// representation.265265- ///266266 pub fn deserialize(&'a self) -> Result<Event<'a>, serde_json::Error> {267267 let value = serde_json::from_slice(&self.bytes)?;268268 Ok(value)
+19-16
crates/gordian-jetstream/src/client_config.rs
···11-use std::{22- borrow::Cow,33- sync::{Arc, Mutex},44-};11+use std::borrow::Cow;22+use std::sync::Arc;33+use std::sync::Mutex;5465use futures_util::FutureExt as _;76use tokio_util::sync::CancellationToken;8799-use crate::{1010- JetstreamClient, JetstreamReceiver, PUBLIC_JETSTREAM_US_EAST1, PUBLIC_JETSTREAM_US_EAST2,1111- PUBLIC_JETSTREAM_US_WEST1, PUBLIC_JETSTREAM_US_WEST2, client_options::ClientOptions,1212- metrics::Metrics, subscriber_options::SubscriberOptions, task::JetstreamTask,1313-};88+use crate::JetstreamClient;99+use crate::JetstreamReceiver;1010+use crate::PUBLIC_JETSTREAM_US_EAST1;1111+use crate::PUBLIC_JETSTREAM_US_EAST2;1212+use crate::PUBLIC_JETSTREAM_US_WEST1;1313+use crate::PUBLIC_JETSTREAM_US_WEST2;1414+use crate::client_options::ClientOptions;1515+use crate::metrics::Metrics;1616+use crate::subscriber_options::SubscriberOptions;1717+use crate::task::JetstreamTask;14181519#[derive(Clone, Debug, Default)]1620#[must_use]···2420}25212622impl JetstreamConfig {2727- /// Create default a [`JetstreamConfig`] connecting to jetstream{1,2}.us-east.bsky.network2828- /// instances.2323+ /// Create default a [`JetstreamConfig`] connecting to2424+ /// jetstream{1,2}.us-east.bsky.network instances.2925 pub fn us_east() -> Self {3026 Self {3127 client_options: ClientOptions {···3935 }4036 }41374242- /// Create default a [`JetstreamConfig`] connecting to jetstream{1,2}.us-west.bsky.network4343- /// instances.3838+ /// Create default a [`JetstreamConfig`] connecting to3939+ /// jetstream{1,2}.us-west.bsky.network instances.4440 pub fn us_west() -> Self {4541 Self {4642 client_options: ClientOptions {···9086 ///9187 /// # Panics9288 ///9393- /// Panics if a collection is not a valid NSID or if the maximum number of filters is9494- /// exceeded.9595- ///8989+ /// Panics if a collection is not a valid NSID or if the maximum number of9090+ /// filters is exceeded.9691 pub fn with_collections<'a>(mut self, collections: impl IntoIterator<Item = &'a str>) -> Self {9792 for collection in collections {9893 let collection = collection
···77pub mod metrics;88pub mod subscriber_options;991010-pub use client::{JetstreamClient, JetstreamClientError, JetstreamEvent, JetstreamReceiver};1111-pub use de::{AccountStatus, Commit, CommitEvent, Delete, Event, Identity, InnerAccount};1212-pub use gordian_types::{Did, Nsid};1010+pub use client::JetstreamClient;1111+pub use client::JetstreamClientError;1212+pub use client::JetstreamEvent;1313+pub use client::JetstreamReceiver;1414+pub use de::AccountStatus;1515+pub use de::Commit;1616+pub use de::CommitEvent;1717+pub use de::Delete;1818+pub use de::Event;1919+pub use de::Identity;2020+pub use de::InnerAccount;2121+pub use gordian_types::Did;2222+pub use gordian_types::Nsid;1323pub use serde_json::Value;14241525pub const PUBLIC_JETSTREAM_US_EAST1: &str = "wss://jetstream1.us-east.bsky.network";
···11use std::collections::HashSet;2233use gordian_types::DidBuf;44-use serde::{Deserialize, Serialize};44+use serde::Deserialize;55+use serde::Serialize;5666-use crate::{Did, Nsid};77+use crate::Did;88+use crate::Nsid;79810pub const MAX_WANTED_COLLECTIONS: usize = 100;911···16141715/// Jetstream subscription options.1816///1919-/// Can either be appended to the `/subscribe` URL on connection to the Jetstream instance2020-/// or sent as an options update message after connection.1717+/// Can either be appended to the `/subscribe` URL on connection to the1818+/// Jetstream instance or sent as an options update message after connection.2119///2220/// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#options-updates>2323-///2421#[derive(Clone, Debug, Default, Deserialize, Serialize)]2522#[serde(rename_all = "camelCase")]2623pub struct SubscriberOptions {···35343635 /// Maximum message size in bytes the subscriber wants to receive.3736 ///3838- /// Zero means no limit, negative values are treated as zero by Jetstream, and3939- /// will be normalized to zero when serialized.3737+ /// Zero means no limit, negative values are treated as zero by Jetstream,3838+ /// and will be normalized to zero when serialized.4039 #[serde(with = "max_message_size")]4140 pub max_message_size_bytes: i64,4241···4645impl SubscriberOptions {4746 /// Add a collection NSID to the subscription options.4847 ///4949- /// Returns an error if the maximum number of subscribed collections has been reached; `Ok(true)`5050- /// if the collection was newly added to the set, or `Ok(false)` if the colletion was already in the5151- /// the set.4848+ /// Returns an error if the maximum number of subscribed collections has4949+ /// been reached; `Ok(true)` if the collection was newly added to the5050+ /// set, or `Ok(false)` if the colletion was already in the the set.5251 ///5352 /// # Errors5453 ///5555- /// Returns an error if adding `collection` would cause [`SubscriberOptions`] to exceed the maximum5656- /// number of subscribed collections.5757- ///5454+ /// Returns an error if adding `collection` would cause5555+ /// [`SubscriberOptions`] to exceed the maximum number of subscribed5656+ /// collections.5857 pub fn add_collection(&mut self, collection: Box<Nsid>) -> Result<bool, Box<Nsid>> {5958 if self.wanted_collections.len() == MAX_WANTED_COLLECTIONS6059 && !self.wanted_collections.contains(&collection)···71707271 /// Add a DID to the subscription options.7372 ///7474- /// Returns an error if the maximum number of subscribed DIDs has been reached; `Ok(true)`7575- /// if the DID was newly added to the set, or `Ok(false)` if the DID was already in the7676- /// the set.7373+ /// Returns an error if the maximum number of subscribed DIDs has been7474+ /// reached; `Ok(true)` if the DID was newly added to the set, or7575+ /// `Ok(false)` if the DID was already in the the set.7776 ///7877 /// # Errors7978 ///8080- /// Returns an error if adding `did` would cause [`SubscriberOptions`] to exceed the maximum8181- /// number of subscribed DIDs.8282- ///7979+ /// Returns an error if adding `did` would cause [`SubscriberOptions`] to8080+ /// exceed the maximum number of subscribed DIDs.8381 pub fn add_did(&mut self, did: DidBuf) -> Result<bool, DidBuf> {8482 if self.wanted_dids.len() == MAX_WANTED_DIDS && !self.wanted_dids.contains(&did) {8583 return Err(did);···9797 normalize_max_message_size(self.max_message_size_bytes)9898 }9999100100- /// Construct the Jetstream subscribe URL, returning a tuple of the URL and a boolean101101- /// indicating whether the client should send an options update message on connect.100100+ /// Construct the Jetstream subscribe URL, returning a tuple of the URL and101101+ /// a boolean indicating whether the client should send an options102102+ /// update message on connect.102103 #[must_use]103104 pub fn subscribe_url(&self, url: &url::Url) -> (url::Url, bool) {104105 let mut url = url.to_owned();···136135 (url, false)137136 }138137139139- /// Present the `SubscriberOptions` as a [`SubscriberSourcedMessage`] for serialization.138138+ /// Present the `SubscriberOptions` as a [`SubscriberSourcedMessage`] for139139+ /// serialization.140140 #[must_use]141141 pub fn as_subscriber_sourced_message(&self) -> SubscriberSourcedMessage<'_> {142142 SubscriberSourcedMessage::OptionsUpdate(self.into())···170168}171169172170mod max_message_size {173173- use serde::{Deserialize, Deserializer, Serializer};171171+ use serde::Deserialize;172172+ use serde::Deserializer;173173+ use serde::Serializer;174174175175 pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error>176176 where···198194/// Subscriber sourced message.199195///200196/// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#subscriber-sourced-messages>201201-///202197#[derive(Debug, Serialize)]203198#[serde(tag = "type", content = "payload", rename_all = "snake_case")]204199pub enum SubscriberSourcedMessage<'a> {···210207 /// # Panics211208 ///212209 /// Panics if [`SubscriberSourcedMessage`] cannot be serialized as JSON.213213- ///214210 #[must_use]215211 pub fn to_json(&self) -> String {216212 serde_json::to_string(self).expect("SubscriberSourcedMessage should be serializable")···245243mod tests {246244 use std::collections::HashSet;247245248248- use gordian_types::{Did, Nsid};246246+ use gordian_types::Did;247247+ use gordian_types::Nsid;249248250249 use super::SubscriberOptions;251250
+19-15
crates/gordian-jetstream/src/task.rs
···11-use crate::{22- client::ClientCommand, client_options::ClientOptions, metrics::Metrics,33- subscriber_options::SubscriberOptions,44-};11+use std::pin::Pin;22+use std::sync::Arc;33+use std::sync::Mutex;44+use std::time::Duration;55+56use bytes::Bytes;66-use futures_util::{SinkExt, StreamExt};77+use futures_util::SinkExt;88+use futures_util::StreamExt;79use serde::Deserialize;88-use std::{99- pin::Pin,1010- sync::{Arc, Mutex},1111- time::Duration,1212-};1310use tokio::time::timeout;1414-use tokio_tungstenite::{1515- connect_async,1616- tungstenite::{ClientRequestBuilder, Error as TungsteniteError, Message, http::Uri},1717-};1111+use tokio_tungstenite::connect_async;1212+use tokio_tungstenite::tungstenite::ClientRequestBuilder;1313+use tokio_tungstenite::tungstenite::Error as TungsteniteError;1414+use tokio_tungstenite::tungstenite::Message;1515+use tokio_tungstenite::tungstenite::http::Uri;1816use tokio_util::sync::CancellationToken;1917use url::Url;1818+1919+use crate::client::ClientCommand;2020+use crate::client_options::ClientOptions;2121+use crate::metrics::Metrics;2222+use crate::subscriber_options::SubscriberOptions;20232124#[cfg(feature = "zstd")]2225const ZSTD_DICTIONARY: &[u8] = include_bytes!("dictionary");···281278 }282279 #[cfg(feature = "zstd")]283280 Message::Binary(compressed_payload) => {284284- use bytes::Buf as _;285281 use std::io::Read as _;282282+283283+ use bytes::Buf as _;286284287285 let compressed_bytes = compressed_payload.len();288286 let mut payload = Vec::with_capacity(compressed_payload.len());
+6-1
crates/gordian-knot/Cargo.toml
···2424tracing.workspace = true2525url.workspace = true26262727-aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] }2727+aws-lc-rs.workspace = true2828axum = { workspace = true, features = ["ws"] }2929axum-extra = { version = "0.12.1", features = ["async-read-body"] }3030bytes = "1.10.1"···4949tower-http = { version = "0.6.6", features = ["decompression-gzip", "request-id", "trace", "tracing", "util"] }5050tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }5151clap_complete = "4.5.65"5252+maud = { version = "0.27.0", features = ["axum"] }5353+exn.workspace = true5454+5555+[build-dependencies]5656+anyhow.workspace = true52575358[dev-dependencies]5459gordian-pds = { workspace = true }
···22pub mod hook;33pub mod serve;4455-use clap::{Parser, Subcommand};55+use clap::Parser;66+use clap::Subcommand;6778pub trait RunCommand {89 type Error;
+2-1
crates/gordian-knot/src/cli/generate.rs
···11-use clap::{Args, CommandFactory as _};11+use clap::Args;22+use clap::CommandFactory as _;23use clap_complete::Shell;3445/// Generate shell completions.
···11-use std::{env, ffi, io, net::ToSocketAddrs as _, path, time::Duration};11+use std::env;22+use std::ffi;33+use std::io;44+use std::net::ToSocketAddrs as _;55+use std::path;66+use std::time::Duration;2738use anyhow::Context as _;44-use axum::http::{Request, Response};55-use clap::{ArgAction, Args, ValueEnum, ValueHint};99+use axum::http::Request;1010+use axum::http::Response;1111+use clap::ArgAction;1212+use clap::Args;1313+use clap::ValueEnum;1414+use clap::ValueHint;615use futures_util::FutureExt as _;716use gix::bstr::BString;817use gordian_identity::HttpClient;99-use gordian_knot::{1010- model::{1111- Knot, KnotState,1212- config::{self, KnotConfiguration},1313- },1414- services::database::DataStore,1515-};1818+use gordian_knot::model::Knot;1919+use gordian_knot::model::KnotState;2020+use gordian_knot::model::config::KnotConfiguration;2121+use gordian_knot::model::config::{self};2222+use gordian_knot::services::database::DataStore;1623use gordian_types::DidBuf;1717-use tokio::{net::TcpListener, signal, task::JoinSet};2424+use tokio::net::TcpListener;2525+use tokio::signal;2626+use tokio::task::JoinSet;1827use tokio_util::sync::CancellationToken;1928use tower::ServiceBuilder;2020-use tower_http::{2121- ServiceBuilderExt as _,2222- decompression::RequestDecompressionLayer,2323- request_id::{MakeRequestUuid, RequestId},2424- trace::{MakeSpan, OnResponse, TraceLayer},2525-};2626-use tracing::{Span, field::Empty};2929+use tower_http::ServiceBuilderExt as _;3030+use tower_http::decompression::RequestDecompressionLayer;3131+use tower_http::request_id::MakeRequestUuid;3232+use tower_http::request_id::RequestId;3333+use tower_http::trace::MakeSpan;3434+use tower_http::trace::OnResponse;3535+use tower_http::trace::TraceLayer;3636+use tracing::Span;3737+use tracing::field::Empty;2738use url::Url;28392940const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));···8978 #[arg(default_value = "service-auth,public-key")]9079 pub auth_methods: Vec<AuthenticationMethods>,91809292- /// Require git pushes to be signed by a public key from a 'sh.tangled.publicKey'.8181+ /// Require git pushes to be signed by a public key from a8282+ /// 'sh.tangled.publicKey'.9383 ///9484 /// See: <https://git-scm.com/docs/git-push#Documentation/git-push.txt---signed>9585 #[arg(long, action = ArgAction::Set, require_equals = true)]···249237 }250238251239 let database = {252252- use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};240240+ use sqlx::sqlite::SqliteConnectOptions;241241+ use sqlx::sqlite::SqlitePoolOptions;253242254243 let pool = {255244 let connect_options = SqliteConnectOptions::new()
+10-8
crates/gordian-knot/src/extractors.rs
···11mod git_protocol;22-pub use git_protocol::{GitProtocol, GitProtocolRejection};33-42use core::fmt;5366-use axum::{77- extract::{FromRequestParts, OptionalFromRequestParts},88- http::{HeaderMap, StatusCode},99- response::IntoResponse,1010-};44+use axum::extract::FromRequestParts;55+use axum::extract::OptionalFromRequestParts;66+use axum::http::HeaderMap;77+use axum::http::StatusCode;88+use axum::response::IntoResponse;99+pub use git_protocol::GitProtocol;1010+pub use git_protocol::GitProtocolRejection;1111use reqwest::header::IF_NONE_MATCH;12121313pub mod request_id {1414 use std::ffi::OsStr;15151616- use axum::{extract::OptionalFromRequestParts, http::StatusCode, response::IntoResponse};1616+ use axum::extract::OptionalFromRequestParts;1717+ use axum::http::StatusCode;1818+ use axum::response::IntoResponse;17191820 pub struct RequestId(pub String);1921
···11use std::ops;2233-use axum::{extract::OptionalFromRequestParts, http::request::Parts, response::IntoResponse};33+use axum::extract::OptionalFromRequestParts;44+use axum::http::request::Parts;55+use axum::response::IntoResponse;46use reqwest::header::ToStrError;5768/// Extract the "Git-Protocol" header from a request.
+6-3
crates/gordian-knot/src/hooks.rs
···11-use std::{env, fs, io, path};11+use std::env;22+use std::fs;33+use std::io;44+use std::path;2536use crate::cli::hook::HookName;4755-/// Install the knot-global git-hooks in `path`. If `path` does not exist it will be created.88+/// Install the knot-global git-hooks in `path`. If `path` does not exist it99+/// will be created.610///711/// # Panics812///913/// Panics if the path to the currently running executable is not utf8.1010-///1114#[cfg(unix)]1215pub fn install_global_hooks<P: AsRef<path::Path>>(path: P) -> io::Result<()> {1316 use std::os::unix::fs::PermissionsExt as _;
+1-2
crates/gordian-knot/src/lib.rs
···77pub mod services;88pub mod sync;99pub mod types;1010+pub mod ui;10111112#[cfg(test)]1213pub(crate) mod mock;1314#[cfg(test)]1415mod tests;1515-1616-mod macros;17161817pub use gordian_lexicon as lexicon;1918pub use model::Knot;
···11-use std::{22- collections::HashMap,33- io::{self, ErrorKind},44- net::SocketAddr,55- ops,66- path::PathBuf,77- process::Stdio,88- sync::{Arc, Mutex},99- time::Duration,1010-};11+use std::collections::HashMap;22+use std::io::ErrorKind;33+use std::io::{self};44+use std::net::SocketAddr;55+use std::ops;66+use std::path::PathBuf;77+use std::process::Stdio;88+use std::sync::Arc;99+use std::sync::Mutex;1010+use std::time::Duration;11111212-use futures_util::{FutureExt, future::BoxFuture};1212+use futures_util::FutureExt;1313+use futures_util::future::BoxFuture;1314use gordian_auth::jwt;1414-use gordian_identity::{HttpClient, Resolver};1515-use gordian_lexicon::{1616- com::atproto::repo::list_records::Record,1717- sh_tangled::{git::RefUpdate, repo::Repo},1818-};1919-use gordian_types::{Did, aturi::AtUri};2020-use moka::future::{Cache, CacheBuilder};2121-use rayon::{ThreadPool, ThreadPoolBuilder};1515+use gordian_identity::HttpClient;1616+use gordian_identity::Resolver;1717+use gordian_lexicon::com::atproto::repo::list_records::Record;1818+use gordian_lexicon::sh_tangled::git::RefUpdate;1919+use gordian_lexicon::sh_tangled::repo::Repo;2020+use gordian_types::Did;2121+use gordian_types::aturi::AtUri;2222+use moka::future::Cache;2323+use moka::future::CacheBuilder;2424+use rayon::ThreadPool;2525+use rayon::ThreadPoolBuilder;2226use serde::Serialize;2327use time::OffsetDateTime;2428use tokio::process::Command;2529use url::Url;26302727-use crate::{2828- release_or_debug,2929- services::{3030- atrepo,3131- authorization::{AuthorizationClaimsStore, AuthorizationClaimsStoreError},3232- database::{DataStore, DataStoreError},3333- },3434- types::{3535- RecordKey,3636- repository_key::RepositoryKey,3737- repository_path::{self, RepositoryPath},3838- },3939-};4040-4131use super::config::KnotConfiguration;3232+use crate::services::atrepo;3333+use crate::services::authorization::AuthorizationClaimsStore;3434+use crate::services::authorization::AuthorizationClaimsStoreError;3535+use crate::services::database::DataStore;3636+use crate::services::database::DataStoreError;3737+use crate::types::RecordKey;3838+use crate::types::repository_key::RepositoryKey;3939+use crate::types::repository_path::RepositoryPath;4040+use crate::types::repository_path::{self};42414342/// Default number of ({handle,did},{name,rkey}) -> (did,rkey) mappings to4443/// keep in cache.···6667 resolver: Resolver,67686869 /// Reqwest client.6969- ///7070 // @TODO Wrap this so prevent requests to sensitive/private endpoints.7171 http: HttpClient,7272···182184 )183185 }184186185185- /// Resolve a repository path ({handle,did},{rkey,name}) to a repository key (did, rkey).186186- ///187187+ /// Resolve a repository path ({handle,did},{rkey,name}) to a repository key188188+ /// (did, rkey).187189 pub async fn resolve_repo_key(188190 &self,189191 repo_path: &RepositoryPath,···249251 }250252251253 pub async fn can_push(&self, repo: &RepositoryKey, did: &Did) -> bool {252252- use crate::services::rbac::{Action, Policy, PolicyResult, RepositoryPushPolicy};254254+ use crate::services::rbac::Action;255255+ use crate::services::rbac::Policy;256256+ use crate::services::rbac::PolicyResult;257257+ use crate::services::rbac::RepositoryPushPolicy;253258 let policy = RepositoryPushPolicy;254259 let result = policy255260 .evaluate_access(&did, &Action::RepositoryPush, repo, self)···278277279278 // We're going to receive the jetstream event and the xrpc request.280279 //281281- // If the other is already in progress, wait here. The database insert should return282282- // Ok(false), and repository creation will be skipped.280280+ // If the other is already in progress, wait here. The database insert should281281+ // return Ok(false), and repository creation will be skipped.283282 let _guard = self.get_repo_mutex(&repo_key).lock_owned().await;284283285284 let mut tx = self.database().begin().await?;···348347 source: &str,349348 ) -> anyhow::Result<()> {350349 // Release build: only clone over https; Debug builds: try https then http.351351- release_or_debug!(const CLONE_SCHEMES: &[&str] = &["https"], &["https", "http"]);350350+ const CLONE_SCHEMES: &[&str] = if cfg!(debug_assertions) {351351+ &["https", "http"]352352+ } else {353353+ &["https"]354354+ };352355353356 let path = self.path_for_repository(repo_key);354357 tracing::debug!(?path, "forking into");···447442 Ok(())448443 }449444450450- /// Get or generate a new nonce seed for signed pushes to the specified repository.445445+ /// Get or generate a new nonce seed for signed pushes to the specified446446+ /// repository.451447 pub fn generate_push_seed(&self, repository: &RepositoryKey) -> Box<str> {452448 const PUSH_SEED_NONCE_LEN: usize = 16;453449
+17-14
crates/gordian-knot/src/model/nicediff.rs
···11use std::borrow::Cow;2233-use crate::types::sh_tangled::repo::diff::{44- Commit, Diff, Line, Name, NiceDiff, Stat, TextFragment,55-};66-use gix::{77- Repository,88- diff::blob::{99- Algorithm, UnifiedDiff,1010- pipeline::{Mode, WorktreeRoots},1111- unified_diff::{ConsumeHunk, ContextSize, DiffLineKind},1212- },1313- object::tree::diff::Action,1414-};33+use gix::Repository;44+use gix::diff::blob::Algorithm;55+use gix::diff::blob::UnifiedDiff;66+use gix::diff::blob::pipeline::Mode;77+use gix::diff::blob::pipeline::WorktreeRoots;88+use gix::diff::blob::unified_diff::ConsumeHunk;99+use gix::diff::blob::unified_diff::ContextSize;1010+use gix::diff::blob::unified_diff::DiffLineKind;1111+use gix::object::tree::diff::Action;1212+1313+use crate::types::sh_tangled::repo::diff::Commit;1414+use crate::types::sh_tangled::repo::diff::Diff;1515+use crate::types::sh_tangled::repo::diff::Line;1616+use crate::types::sh_tangled::repo::diff::Name;1717+use crate::types::sh_tangled::repo::diff::NiceDiff;1818+use crate::types::sh_tangled::repo::diff::Stat;1919+use crate::types::sh_tangled::repo::diff::TextFragment;15201621#[derive(Debug, thiserror::Error)]1722enum Error {···110105}111106112107/// Produce a unified diff from the first parent of the specified commit.113113-///114108pub fn unified_diff_from_parent(commit: gix::Commit<'_>) -> anyhow::Result<NiceDiff> {115109 let current_tree = commit.tree()?;116110 let parent_tree = match commit.parent_ids().next() {···146142}147143148144/// Produce a unified diff between two trees.149149-///150145pub fn unified_diff<'r>(151146 repo: &'r Repository,152147 this_tree: &gix::Tree<'r>,
+51-40
crates/gordian-knot/src/model/repository.rs
···11mod merge_check;2233-use core::{error, fmt};44-use std::{55- collections::{BTreeMap, HashSet, VecDeque},66- io, ops,77- path::{Path, PathBuf},88- process::{Command, Stdio},99-};33+use core::error;44+use core::fmt;55+use std::collections::BTreeMap;66+use std::collections::HashSet;77+use std::collections::VecDeque;88+use std::io;99+use std::ops;1010+use std::path::Path;1111+use std::path::PathBuf;1212+use std::process::Command;1313+use std::process::Stdio;10141111-use axum::{1212- Json,1313- extract::{FromRef, FromRequestParts},1414-};1515-use gix::{1616- ObjectId,1717- bstr::{BString, ByteSlice},1818- submodule::config::Branch,1919-};2020-use gordian_lexicon::sh_tangled::repo::{2121- blob::Submodule, branch, get_default_branch, languages, refs, tree,2222-};1515+use axum::Json;1616+use axum::extract::FromRef;1717+use axum::extract::FromRequestParts;1818+use gix::ObjectId;1919+use gix::bstr::BString;2020+use gix::bstr::ByteSlice;2121+use gix::submodule::config::Branch;2222+use gordian_lexicon::sh_tangled::repo::blob::Submodule;2323+use gordian_lexicon::sh_tangled::repo::branch;2424+use gordian_lexicon::sh_tangled::repo::get_default_branch;2525+use gordian_lexicon::sh_tangled::repo::languages;2626+use gordian_lexicon::sh_tangled::repo::refs;2727+use gordian_lexicon::sh_tangled::repo::tree;2328use rustc_hash::FxHashSet;2429use serde::Deserialize;25302626-use crate::{2727- command::{SetOptionArg as _, SetOptionEnv as _},2828- model::{convert, errors, nicediff},2929- public::xrpc::{XrpcError, XrpcQuery, XrpcResponse, XrpcResult},3030- types::{3131- repository_key::RepositoryKey,3232- repository_path::RepositoryPath,3333- sh_tangled::repo::{branches, compare, diff, log, tags},3434- },3535-};3636-3731use super::Knot;3232+use crate::command::SetOptionArg as _;3333+use crate::command::SetOptionEnv as _;3434+use crate::model::convert;3535+use crate::model::errors;3636+use crate::model::nicediff;3737+use crate::public::xrpc::XrpcError;3838+use crate::public::xrpc::XrpcQuery;3939+use crate::public::xrpc::XrpcResponse;4040+use crate::public::xrpc::XrpcResult;4141+use crate::types::repository_key::RepositoryKey;4242+use crate::types::repository_path::RepositoryPath;4343+use crate::types::sh_tangled::repo::branches;4444+use crate::types::sh_tangled::repo::compare;4545+use crate::types::sh_tangled::repo::diff;4646+use crate::types::sh_tangled::repo::log;4747+use crate::types::sh_tangled::repo::tags;38483949#[derive(Debug)]4050pub struct TangledRepository {···548538 where549539 Knot: axum::extract::FromRef<S>,550540 {551551- use crate::public::git::NotFound;552541 use axum::extract::Path;542542+543543+ use crate::public::git::NotFound;553544554545 let knot = Knot::from_ref(state);555546 let Path(repo_path) = Path::<RepositoryPath>::from_request_parts(parts, &()).await?;···571560}572561573562impl TangledRepository {574574- /// Initialise a [`Command`] for running git with the appropriate environment and working575575- /// directory for the repository.576576- ///563563+ /// Initialise a [`Command`] for running git with the appropriate564564+ /// environment and working directory for the repository.577565 pub fn git(&self) -> Command {578578- use crate::private::{ENV_PRIVATE_ENDPOINTS, ENV_REPO_DID, ENV_REPO_RKEY};566566+ use crate::private::ENV_PRIVATE_ENDPOINTS;567567+ use crate::private::ENV_REPO_DID;568568+ use crate::private::ENV_REPO_RKEY;579569580570 let mut command = Command::new("/usr/bin/git");581571 command···591579 }592580}593581594594-/// A temporary detached worktree generated with a randomised name, and deleted when595595-/// the object is dropped.582582+/// A temporary detached worktree generated with a randomised name, and deleted583583+/// when the object is dropped.596584/// 597585#[derive(Debug)]598586struct TempWorktree<'repo> {···684672 ///685673 /// # Errors686674 ///687687- /// Returns an error if the git subprocess could not be spawned or exits with a non-zero688688- /// exit code.675675+ /// Returns an error if the git subprocess could not be spawned or exits676676+ /// with a non-zero exit code.689677 ///690678 /// # Panics691679 ///692680 /// Panics if `repo` is not a bare repository.693693- ///694681 fn build<'repo>(&self, repo: &'repo gix::Repository) -> io::Result<TempWorktree<'repo>> {695682 assert!(repo.is_bare(), "repository should be bare");696683
···11use core::fmt;22-use std::{borrow::Cow, io, process::Stdio, sync::Arc};22+use std::borrow::Cow;33+use std::io;44+use std::process::Stdio;55+use std::sync::Arc;3644-use axum::{55- extract::{FromRequestParts, Path, State},66- http::{HeaderMap, StatusCode, request::Parts},77- response::IntoResponse,88-};99-use gordian_lexicon::sh_tangled::git::{1010- CommitCount, CommitCountBreakdown, Language, LanguageBreakdown, Meta, RefUpdate,1111-};77+use axum::extract::FromRequestParts;88+use axum::extract::Path;99+use axum::extract::State;1010+use axum::http::HeaderMap;1111+use axum::http::StatusCode;1212+use axum::http::request::Parts;1313+use axum::response::IntoResponse;1414+use gordian_lexicon::sh_tangled::git::CommitCount;1515+use gordian_lexicon::sh_tangled::git::CommitCountBreakdown;1616+use gordian_lexicon::sh_tangled::git::Language;1717+use gordian_lexicon::sh_tangled::git::LanguageBreakdown;1818+use gordian_lexicon::sh_tangled::git::Meta;1919+use gordian_lexicon::sh_tangled::git::RefUpdate;1220use gordian_types::DidBuf;1313-use serde::{Deserialize, Serialize};2121+use serde::Deserialize;2222+use serde::Serialize;1423use time::OffsetDateTime;1524use tokio_rayon::AsyncThreadPool as _;16251717-use crate::{1818- model::{1919- Knot, errors,2020- knot_state::Event,2121- repository::{RepositoryStatsExt as _, TangledRepository},2222- },2323- public::xrpc::XrpcError,2424- types::{push_certificate::PushCertificate, repository_key::RepositoryKey},2525-};2626+use crate::model::Knot;2727+use crate::model::errors;2828+use crate::model::knot_state::Event;2929+use crate::model::repository::RepositoryStatsExt as _;3030+use crate::model::repository::TangledRepository;3131+use crate::public::xrpc::XrpcError;3232+use crate::types::push_certificate::PushCertificate;3333+use crate::types::repository_key::RepositoryKey;26342727-/// Environment variable containing one or more whitespace separated URLs for the internal API.3535+/// Environment variable containing one or more whitespace separated URLs for3636+/// the internal API.2837///2929-/// By default, knot will serve the internal API on all the addresses resolved from `localhost`3030-/// bound to a OS assigned port.3838+/// By default, knot will serve the internal API on all the addresses resolved3939+/// from `localhost` bound to a OS assigned port.3140///3241/// # Example3342///3443/// `"http://[::1]:44269/ http://127.0.0.1:36413/"`3535-///3644pub const ENV_PRIVATE_ENDPOINTS: &str = "GORDIAN_PRIVATE_ENDPOINTS";37453838-/// Environment variable containing the DID of the account that triggered the hook.4646+/// Environment variable containing the DID of the account that triggered the4747+/// hook.3948pub const ENV_USER_DID: &str = "GORDIAN_USER_DID";40494141-/// Environment variable containing the DID that owns the repository the hook has be triggered on.5050+/// Environment variable containing the DID that owns the repository the hook5151+/// has be triggered on.4252pub const ENV_REPO_DID: &str = "GORDIAN_REPO_DID";43534444-/// Environment variable containing the rkey of the repository the hook has be triggered on.5454+/// Environment variable containing the rkey of the repository the hook has be5555+/// triggered on.4556pub const ENV_REPO_RKEY: &str = "GORDIAN_REPO_RKEY";46574747-/// Prefix to add when converting an environment variable from the hook to a HTTP header.5858+/// Prefix to add when converting an environment variable from the hook to a5959+/// HTTP header.4860pub const ENV_HEADER_PREFIX: &str = "X-Gordian";49615062/// Build a new router for the internal API.···198186) -> Result<impl IntoResponse, XrpcError> {199187 let repo_key = RepositoryKey { owner, rkey };200188201201- // Our hook refers to the repository using DID and rkey, but Tangled needs DID and name, so we202202- // need to lookup the repository's name in the database.189189+ // Our hook refers to the repository using DID and rkey, but Tangled needs DID190190+ // and name, so we need to lookup the repository's name in the database.203191 let (_, repo_name) = knot204192 .database()205193 .resolve_repository(&repo_key.owner, repo_key.rkey())
+76-4
crates/gordian-knot/src/public.rs
···11//! Public API for the knot server.22-//!22+33+use axum::extract::State;44+55+pub mod authorize;36pub mod events;47pub mod git;58pub mod xrpc;6977-use crate::model::Knot;1010+pub fn router() -> axum::Router<crate::Knot> {1111+ use axum::routing::get;81299-pub fn router() -> axum::Router<Knot> {1013 axum::Router::new()1114 .without_v07_checks()1215 .nest("/xrpc", xrpc::router())1316 .nest("/{owner}/{name}", git::router())1414- .route("/events", axum::routing::get(events::handler))1717+ .route("/", get(index))1818+ .route("/gordian.css", get(stylesheet))1919+ .route("/events", get(events::handler))2020+ .merge(authorize::router())2121+}2222+2323+async fn index(State(knot): State<crate::Knot>) -> maud::Markup {2424+ layout(2525+ maud::html! { title { "The Gordian Knot (server)" } },2626+ maud::html! {2727+ div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" {2828+ (maud::PreEscaped("<!-- todo -->"))2929+ }3030+ },3131+ foot(&knot),3232+ )3333+}3434+3535+async fn stylesheet() -> impl axum::response::IntoResponse {3636+ use axum::http::header::CONTENT_TYPE;3737+3838+ (3939+ [(CONTENT_TYPE, "text/css")],4040+ include_str!(env!("TAILWIND_STYLESHEET")),4141+ )4242+}4343+4444+fn layout(head: maud::Markup, body: maud::Markup, foot: maud::Markup) -> maud::Markup {4545+ maud::html! {4646+ (maud::DOCTYPE)4747+ html class="h-full bg-slate-100 dark:bg-slate-900" {4848+ head {4949+ meta5050+ charset="UTF-8";5151+ meta5252+ name="viewport"5353+ content="width=device-width, initial-scale=1.0" ;5454+ script5555+ src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"5656+ integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"5757+ crossorigin="anonymous" { }5858+ link5959+ rel="stylesheet"6060+ href="/gordian.css" ;6161+ (head)6262+ }6363+ body class="h-full flex flex-col" {6464+ main class="flex-grow" {6565+ (body)6666+ }6767+ footer {6868+ (foot)6969+ }7070+ }7171+ }7272+ }7373+}7474+7575+fn foot(knot: &crate::Knot) -> maud::Markup {7676+ maud::html! {7777+ div class="flex" {7878+ div class="flex-grow" { }7979+ div class="mt-3 p-3 text-sm text-slate-500" {8080+ "at://"8181+ a href={"https://tangled.org/" (knot.owner()) } { (knot.owner()) }8282+ "/sh.tangled.knot/"8383+ a href=(knot.base) class="knot-ident" { (knot.instance_ident()) }8484+ }8585+ }8686+ }1587}
+757
crates/gordian-knot/src/public/authorize.rs
···11+use std::collections::HashMap;22+use std::sync::Arc;33+use std::sync::Mutex;44+use std::sync::OnceLock;55+use std::time::Duration;66+77+use aws_lc_rs::digest::SHA256;88+use aws_lc_rs::signature::EcdsaKeyPair;99+use axum::Form;1010+use axum::Json;1111+use axum::extract::Query;1212+use axum::extract::State;1313+use axum::extract::WebSocketUpgrade;1414+use axum::extract::ws::Message;1515+use axum::extract::ws::WebSocket;1616+use axum::http::HeaderMap;1717+use axum::http::HeaderValue;1818+use axum::http::StatusCode;1919+use axum::http::header;2020+use axum::response::IntoResponse;2121+use axum::response::Response;2222+use futures_util::SinkExt;2323+use gordian_auth::IntoVerificationKey as _;2424+use gordian_auth::MultibaseKey;2525+use gordian_auth::client::Client;2626+use gordian_auth::client::PushedAuthorization;2727+use gordian_auth::jwk::JsonWebKeySet;2828+use gordian_auth::resources::ClientMetadata;2929+use gordian_auth::types::Callback;3030+use gordian_auth::types::CallbackError;3131+use gordian_auth::types::CallbackSuccess;3232+use gordian_auth::types::GrantType;3333+use gordian_auth::types::ResponseType;3434+use gordian_auth::types::SigningAlgorithm;3535+use gordian_auth::types::TokenEndpointAuthMethod;3636+use gordian_types::DidBuf;3737+use serde::Deserialize;3838+use tokio::sync::broadcast;3939+use url::Url;4040+4141+use crate::Knot;4242+use crate::types::repository_key::RepositoryKey;4343+4444+const WELL_KNOWN_CLIENT_METADATA: &str = "/oauth-client-metadata.json";4545+const WELL_KNOWN_JWKS: &str = "/.well-known/jwks";4646+const WELL_KNOWN_KNOTSERVER: &str = "/.well-known/knotserver.json";4747+4848+const PATH_AUTHORIZE: &str = "/oauth/login";4949+const PATH_AUTHORIZE_RESULT: &str = "/oauth/authorize-result";5050+const PATH_AUTHORIZE_EVENT: &str = "/oauth/authorize-event";5151+const PATH_AUTHORIZE_VALIDATE_REPO: &str = "/oauth/login/validate-repository";5252+const PATH_AUTHORIZE_VALIDATE_KEY: &str = "/oauth/login/validate-public-key";5353+5454+/// Verification keys for the knot. Published at "/.well-known/jwks.json".5555+///5656+/// For now these are generated on first use.5757+static KNOT_KEY_PAIR: OnceLock<Arc<EcdsaKeyPair>> = OnceLock::new();5858+5959+fn init_knot_key_pair() -> Arc<EcdsaKeyPair> {6060+ use aws_lc_rs::signature::ECDSA_P256_SHA256_FIXED_SIGNING;6161+6262+ EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING)6363+ .expect("key-pair generation must succeed")6464+ .into()6565+}6666+6767+enum AuthorizationRequestState {6868+ Pending(RepositoryKey, PushedAuthorization),6969+ Verifying,7070+ Complete,7171+}7272+7373+/// Inflight OAuth requests.7474+// @TODO Put these in the db!7575+//7676+static OAUTH_REQUESTS: OnceLock<Mutex<HashMap<Box<str>, AuthorizationRequestState>>> =7777+ OnceLock::new();7878+7979+static AUTHORIZATIONS: OnceLock<Mutex<HashMap<Box<str>, broadcast::Sender<bool>>>> =8080+ OnceLock::new();8181+8282+pub fn router() -> axum::Router<Knot> {8383+ use axum::routing::get;8484+ use axum::routing::post;8585+8686+ axum::Router::new()8787+ .without_v07_checks()8888+ .route(WELL_KNOWN_CLIENT_METADATA, get(client_metadata))8989+ .route(WELL_KNOWN_JWKS, get(jwks))9090+ .route(WELL_KNOWN_KNOTSERVER, get(knotserver_meta))9191+ .route(PATH_AUTHORIZE_RESULT, get(callback))9292+ .route(PATH_AUTHORIZE, get(login).post(authorize_post))9393+ .route(PATH_AUTHORIZE_EVENT, get(authorize_event))9494+ .route(PATH_AUTHORIZE_VALIDATE_REPO, post(validate_repo_fragment))9595+ .route(PATH_AUTHORIZE_VALIDATE_KEY, post(validate_key_fragment))9696+}9797+9898+async fn client_metadata(State(knot): State<Knot>) -> Json<ClientMetadata> {9999+ Json(raw_client_metadata(&knot))100100+}101101+102102+fn raw_client_metadata(knot: &Knot) -> ClientMetadata {103103+ ClientMetadata {104104+ redirect_uris: vec![redirect_uri(knot.base.clone())],105105+ grant_types: vec![GrantType::AuthorizationCode],106106+ token_endpoint_auth_method: Some(TokenEndpointAuthMethod::PrivateKeyJwt),107107+ token_endpoint_auth_signing_alg: Some(SigningAlgorithm::ES256),108108+ response_types: vec![ResponseType::Code],109109+ jwks_uri: Some(jwks_uri(knot.base.clone())),110110+ client_name: Some(knot.instance_ident().to_string()),111111+ client_uri: Some(knot.base.clone()),112112+ ..client_id(knot.base.clone()).into()113113+ }114114+}115115+116116+async fn jwks() -> Json<JsonWebKeySet> {117117+ let key_pair = KNOT_KEY_PAIR.get_or_init(init_knot_key_pair);118118+ let jwk = key_pair119119+ .as_ref()120120+ .try_into()121121+ .expect("ECDSA key pair should serialize as a JWK");122122+123123+ Json(JsonWebKeySet { keys: vec![jwk] })124124+}125125+126126+#[derive(Debug, serde::Serialize)]127127+pub struct KnotServerMetadata {128128+ pub owner: DidBuf,129129+130130+ /// Base URL for the knot server.131131+ pub knotserver: Url,132132+133133+ pub jwks_uri: Url,134134+135135+ /// URL for user login/authorization.136136+ pub login_endpoint: Url,137137+}138138+139139+async fn knotserver_meta(State(knot): State<Knot>) -> Json<KnotServerMetadata> {140140+ let knotserver = knot.base.clone();141141+142142+ let mut jwks_uri = knotserver.clone();143143+ jwks_uri.set_path(WELL_KNOWN_JWKS);144144+145145+ let mut login_endpoint = knotserver.clone();146146+ login_endpoint.set_path(PATH_AUTHORIZE);147147+148148+ Json(KnotServerMetadata {149149+ owner: knot.owner().to_owned(),150150+ knotserver,151151+ jwks_uri,152152+ login_endpoint,153153+ })154154+}155155+156156+fn normalise_str<S>(s: S) -> Option<S>157157+where158158+ S: AsRef<str>,159159+{160160+ match s.as_ref().trim() {161161+ "" => None,162162+ _ => Some(s),163163+ }164164+}165165+166166+/// Parameters required to begin authorization.167167+#[derive(Debug, serde::Deserialize)]168168+pub struct AuthorizeParams {169169+ /// Login hint. Maybe a handle, DID, PDS url, or authorization server url.170170+ #[serde(default)]171171+ hint: String,172172+173173+ /// Repository string.174174+ #[serde(default)]175175+ repo: String,176176+177177+ /// Public key to authorize.178178+ #[serde(default)]179179+ key: String,180180+}181181+182182+/// Render the authorization page.183183+async fn render_login(184184+ knot: &Knot,185185+ hint: impl AsRef<str>,186186+ repo: impl AsRef<str>,187187+ key: impl AsRef<str>,188188+ error: Option<String>,189189+) -> maud::Markup {190190+ let hint = hint.as_ref();191191+ let repo = repo.as_ref();192192+ let key = key.as_ref();193193+194194+ let repo = render_repo_input(repo, validate_repo(&knot, repo).await);195195+ let key = render_key_input(key, validate_key(&knot, key).await);196196+197197+ let atproto = || {198198+ maud::html! {199199+ a href="https://atproto.com" class="text-black dark:text-white" { "Atmosphere" }200200+ }201201+ };202202+203203+ super::layout(204204+ maud::html! {205205+ title { "Authorize" }206206+ },207207+ maud::html! {208208+ div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" {209209+ div class="sm:mx-auto sm:w-full sm:max-w-md" {210210+ h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" {211211+ "Authorize a push key using your Atmosphere handle"212212+ }213213+ }214214+ form id="authorize-form" method="POST" class="space-y-6 mt-6" {215215+ //216216+ // First pane with repository specifier and key.217217+ //218218+ (crate::ui::section_pane(maud::html! {219219+ div class="mt-4" { (repo) }220220+ div class="mt-4" { (key) }221221+ }))222222+ //223223+ // Second pane with handle/login hint and submit button.224224+ //225225+ (crate::ui::section_pane(maud::html! {226226+ div {227227+ div {228228+ label for="hint" { "Atmosphere Handle" }229229+ input230230+ id="hint"231231+ name="hint"232232+ type="text"233233+ autocapitalize="off"234234+ autocorrect="off"235235+ autocomplete="username"236236+ value=(hint)237237+ class="mt-3"238238+ required[(true)]239239+ { }240240+ (crate::ui::script(241241+ r##"242242+ hint.addEventListener("input", (event) => {243243+ hint.setCustomValidity("");244244+ hint.checkValidity();245245+ });246246+247247+ hint.addEventListener("invalid", (event) => {248248+ hint.setCustomValidity("Please enter an Atmosphere handle, PDS, or authorization server");249249+ });250250+ "##251251+ ))252252+ }253253+ p class="mt-4 px-3 text-slate-500 text-sm" {254254+ "You will need an "255255+ (atproto())256256+ " account with sufficient access rights to the knot and repository to authorize a push key."257257+ }258258+ button type="submit" class="mt-4" { "Sign in" }259259+ }260260+ @if let Some(error) = error {261261+ div class="mt-4 py-4 outline-2 outline-red-600 bg-slate-800/25 dark:bg-slate-700/50 rounded-xs" {262262+ h3 class="px-4 text-slate-900 dark:text-white text-sm" { "Authorization failed" }263263+ p class="mt-3 px-4 text-slate-800 dark:text-white text-sm" {264264+ (error)265265+ }266266+ }267267+ }268268+ }))269269+ }270270+ }271271+ },272272+ super::foot(knot),273273+ )274274+}275275+276276+/// Render the repository input with any validation errors.277277+fn render_repo_input<T>(value: &str, validation: Option<Result<T, String>>) -> maud::Markup {278278+ maud::html! {279279+ div hx-target="this" hx-swap="outerHTML" {280280+ label for="repo" { "Repository" }281281+ div class="relative" {282282+ input283283+ id="repo"284284+ name="repo"285285+ type="text"286286+ value=(value)287287+ aria-invalid=(validation.as_ref().is_some_and(|res| res.is_err()))288288+ aria-describedby="repo-error"289289+ hx-post=(PATH_AUTHORIZE_VALIDATE_REPO)290290+ class="mt-3"291291+ required[(true)]292292+ { }293293+ @match &validation {294294+ Some(Ok(_)) => {295295+ div class="absolute top-3 right-3 text-lime-600" {296296+ (crate::ui::tick())297297+ }298298+ }299299+ Some(Err(message)) => {300300+ p id="repo-error" class="mt-3 px-3 text-red-500 text-sm" { (message) }301301+ (crate::ui::script(r#"repo.setCustomValidity("Please enter a valid repository");"#))302302+ }303303+ None => {}304304+ }305305+ }306306+ }307307+ }308308+}309309+310310+fn render_key_input<T>(value: &str, validation: Option<Result<T, String>>) -> maud::Markup {311311+ maud::html! {312312+ div hx-target="this" hx-swap="outerHTML" {313313+ label for="key" { "Push Key" }314314+ div class="relative" {315315+ input316316+ id="key"317317+ name="key"318318+ type="text"319319+ value=(value)320320+ aria-invalid=(validation.as_ref().is_some_and(|res| res.is_err()))321321+ aria-describedby="key-description"322322+ hx-post=(PATH_AUTHORIZE_VALIDATE_KEY)323323+ class="mt-3"324324+ required[(true)]325325+ { }326326+ @match &validation {327327+ Some(Ok(_)) => {328328+ div class="absolute top-3 right-3 text-lime-600" {329329+ (crate::ui::tick())330330+ }331331+ }332332+ Some(Err(message)) => {333333+ p id="key-error" class="mt-3 px-3 text-red-500 text-sm" { (message) }334334+ (crate::ui::script(r#"key.setCustomValidity("Please enter multibase-encoded ES256 or ES256K public key");"#))335335+ }336336+ None => {}337337+ }338338+ }339339+ }340340+ }341341+}342342+343343+#[derive(Deserialize)]344344+struct LoginError {345345+ error: Option<String>,346346+}347347+348348+/// Render the authorization page.349349+async fn login(350350+ State(knot): State<Knot>,351351+ Query(AuthorizeParams { hint, repo, key }): Query<AuthorizeParams>,352352+ Query(LoginError { error }): Query<LoginError>,353353+) -> impl IntoResponse {354354+ (355355+ [(header::CACHE_CONTROL, "no-store")],356356+ render_login(&knot, hint, repo, key, error).await,357357+ )358358+}359359+360360+#[tracing::instrument]361361+async fn authorize_post(362362+ State(knot): State<Knot>,363363+ Form(AuthorizeParams { hint, repo, key }): Form<AuthorizeParams>,364364+) -> Response {365365+ let (hint, repo_key, key_id) = {366366+ let validated_repo = validate_repo(&knot, &repo).await;367367+ let validated_key = validate_key(&knot, &key).await;368368+369369+ match (normalise_str(&hint), validated_repo, validated_key) {370370+ (Some(hint), Some(Ok(repo_key)), Some(Ok(key))) => (hint, repo_key, key),371371+ (_, _, _) => {372372+ return login(373373+ State(knot),374374+ Query(AuthorizeParams { hint, repo, key }),375375+ Query(LoginError { error: None }),376376+ )377377+ .await378378+ .into_response();379379+ }380380+ }381381+ };382382+383383+ let metadata = raw_client_metadata(&knot);384384+ let client = Client::new_with(metadata.clone(), knot.http(), knot.resolver())385385+ .with_client_assertion_key(Arc::clone(&KNOT_KEY_PAIR.get_or_init(init_knot_key_pair)));386386+387387+ let try_login = async || {388388+ let pushed_authorization_client = client.prepare_pushed_authorization(hint).await?;389389+ let response = pushed_authorization_client390390+ .push_authorization(&key_id, &metadata.redirect_uris[0], &["atproto"])391391+ .await?;392392+393393+ Ok::<_, Box<dyn core::error::Error>>(response)394394+ };395395+396396+ let mut headers = HeaderMap::new();397397+ match try_login().await {398398+ Ok(authorization_response) => {399399+ let location = authorization_response.authorization_endpoint(&metadata.client_id);400400+ let location_header = HeaderValue::from_str(location.as_str()).unwrap();401401+ headers.insert(header::LOCATION, location_header.clone());402402+403403+ // Save the state so we can retrieve it in the callback.404404+ OAUTH_REQUESTS405405+ .get_or_init(|| Default::default())406406+ .lock()407407+ .expect("oauth request state should not be poisoned")408408+ .insert(409409+ key_id.into_boxed_str(),410410+ AuthorizationRequestState::Pending(repo_key, authorization_response),411411+ );412412+413413+ (414414+ StatusCode::FOUND,415415+ headers,416416+ maud::html! {417417+ "Navigate to: " (location) " to continue"418418+ },419419+ )420420+ }421421+ Err(error) => {422422+ let mut url: Url = format!("http://localhost").parse().unwrap();423423+ {424424+ let mut query = url.query_pairs_mut();425425+ if let Some(repo) = normalise_str(&repo) {426426+ query.append_pair("repo", &repo);427427+ }428428+ if let Some(key) = normalise_str(&key) {429429+ query.append_pair("key", &key);430430+ }431431+ // query.append_pair("error", &error.to_string());432432+ query.append_pair("error", &format!("{error:#?}"));433433+ }434434+435435+ let url = format!("{PATH_AUTHORIZE}?{}", url.query().unwrap());436436+ headers.insert(header::LOCATION, url.parse().unwrap());437437+438438+ (StatusCode::FOUND, headers, maud::html! {})439439+ }440440+ }441441+ .into_response()442442+}443443+444444+async fn callback(State(knot): State<Knot>, Query(params): Query<Callback>) -> impl IntoResponse {445445+ match params {446446+ Callback::Success(success) => match process_callback(&knot, success).await {447447+ Ok(()) => super::layout(448448+ maud::html! { title { "Success" }},449449+ maud::html! {450450+ div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" {451451+ div class="sm:mx-auto sm:w-full sm:max-w-md" {452452+ h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" {453453+ "Authorized"454454+ }455455+ }456456+ (crate::ui::section_pane(maud::html! {457457+ p class="text-center text-slate-800 dark:text-slate-100" {458458+ "You may now close this window"459459+ }460460+ }))461461+ }462462+ },463463+ super::foot(&knot),464464+ ),465465+ Err(error) => super::layout(466466+ maud::html! { title { "Authorization Error" }},467467+ maud::html! {468468+ div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" {469469+ div class="sm:mx-auto sm:w-full sm:max-w-md" {470470+ h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" {471471+ "Authorization Error"472472+ }473473+ }474474+ (crate::ui::section_pane(maud::html! {475475+ code {476476+ pre class="text-slate-800 dark:text-slate-100" {477477+ (format!("{error:#?}"))478478+ }479479+ }480480+ }))481481+ }482482+ },483483+ super::foot(&knot),484484+ ),485485+ },486486+ Callback::Error(CallbackError {487487+ state: _,488488+ issuer: _,489489+ error,490490+ }) => super::layout(491491+ maud::html! { title { "Authorization Error" }},492492+ maud::html! {493493+ div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8" {494494+ div class="sm:mx-auto sm:w-full sm:max-w-md" {495495+ h2 class="mt-6 text-center text-2xl/9 tracking-loose text-slate-900 dark:text-white" {496496+ "Authorization Error"497497+ }498498+ }499499+ (crate::ui::section_pane(maud::html! {500500+ code class="space-y-6 mt-6" {501501+ h3 class="text-lg/9 tracking-loose text-slate-900 dark:text-white" {502502+ (error.error)503503+ }504504+ p class="mt-6 text-slate-800 dark:text-slate-100" {505505+ (error.description)506506+ }507507+ }508508+ }))509509+ }510510+ },511511+ super::foot(&knot),512512+ ),513513+ }514514+}515515+516516+async fn process_callback(517517+ knot: &Knot,518518+ success: CallbackSuccess,519519+) -> Result<(), Box<dyn core::error::Error>> {520520+ let auth_request = {521521+ let mut requests = OAUTH_REQUESTS522522+ .get_or_init(|| Default::default())523523+ .lock()524524+ .expect("oauth requests map lock should not be poisoned");525525+526526+ let req = requests.insert(success.state.clone(), AuthorizationRequestState::Verifying);527527+ if let Some(AuthorizationRequestState::Complete) = req {528528+ requests.insert(success.state.clone(), AuthorizationRequestState::Complete);529529+ }530530+531531+ req532532+ };533533+534534+ match auth_request {535535+ None => Err("request not found")?,536536+ Some(AuthorizationRequestState::Pending(repo_key, request)) => {537537+ // YAY!538538+ if request.authorization_meta.issuer != success.issuer {539539+ return Err("Issuer does not match original authorization server")?;540540+ }541541+542542+ // 1. Request a token to verify DID.543543+ let metadata = raw_client_metadata(&knot);544544+ let client = Client::new_with(metadata.clone(), knot.http(), knot.resolver())545545+ .with_client_assertion_key(Arc::clone(546546+ &KNOT_KEY_PAIR.get_or_init(init_knot_key_pair),547547+ ));548548+549549+ let token = client550550+ .request_token(&request, &success, &metadata.redirect_uris[0])551551+ .await?;552552+553553+ if !knot.can_push(&repo_key, &token.sub).await {554554+ return Err("DID does not have access to repository")?;555555+ }556556+557557+ // Notify any waiters.558558+ if let Some(channel) = AUTHORIZATIONS559559+ .get_or_init(|| Mutex::new(HashMap::new()))560560+ .lock()561561+ .unwrap()562562+ .remove(&success.state)563563+ {564564+ let _ = channel.send(true);565565+ } else {566566+ tracing::error!("No waiters for key authorization");567567+ }568568+569569+ OAUTH_REQUESTS570570+ .get_or_init(|| Default::default())571571+ .lock()572572+ .expect("oauth requests map lock should not be poisoned")573573+ .insert(success.state.clone(), AuthorizationRequestState::Complete);574574+575575+ // Schedule for deletion.576576+ let state = success.state.clone();577577+ tokio::spawn(async move {578578+ tokio::time::sleep(Duration::from_secs(300)).await;579579+ tracing::info!(?state, "deleting completed request");580580+ if let Some(requests) = OAUTH_REQUESTS.get() {581581+ if let Ok(mut requests) = requests.lock() {582582+ requests.remove(&state);583583+ }584584+ }585585+ });586586+587587+ Ok(())588588+ }589589+ Some(AuthorizationRequestState::Verifying) => {590590+ // This request is already being processed.591591+ Err("Already processing")?592592+ }593593+ Some(AuthorizationRequestState::Complete) => {594594+ // This request has already been processed.595595+ Err("Already processed")?596596+ }597597+ }598598+}599599+600600+/// Validate `repo` and render just the repo input field and validation errors.601601+///602602+/// This route is triggered by the `hx-post=` attribute on the `<input603603+/// id="repo">` element.604604+async fn validate_repo_fragment(605605+ State(knot): State<Knot>,606606+ Form(AuthorizeParams {607607+ hint: _,608608+ repo,609609+ key: _,610610+ }): Form<AuthorizeParams>,611611+) -> maud::Markup {612612+ render_repo_input(&repo, validate_repo(&knot, &repo).await)613613+}614614+615615+/// Validate `key` and render just the key input field and any validation616616+/// errors.617617+///618618+/// This route is triggered by the `hx-post=` attribute on the `<input619619+/// id="key">` element.620620+async fn validate_key_fragment(621621+ State(knot): State<Knot>,622622+ Form(AuthorizeParams {623623+ hint: _,624624+ repo: _,625625+ key,626626+ }): Form<AuthorizeParams>,627627+) -> maud::Markup {628628+ render_key_input(&key, validate_key(&knot, &key).await)629629+}630630+631631+/// Parse and resolve the repository specifier.632632+async fn validate_repo(knot: &Knot, repo: &str) -> Option<Result<RepositoryKey, String>> {633633+ let inner = async |repo: &str| -> Result<RepositoryKey, Box<dyn core::error::Error>> {634634+ let repo_path = repo.parse()?;635635+ let repo_key = knot.resolve_repo_key(&repo_path).await?;636636+ Ok(repo_key)637637+ };638638+639639+ let repo = normalise_str(repo)?;640640+ Some(inner(repo).await.map_err(|error| error.to_string()))641641+}642642+643643+async fn validate_key(_: &Knot, key: &str) -> Option<Result<String, String>> {644644+ let inner = |key: &str| -> Result<String, Box<dyn core::error::Error>> {645645+ let multibase = MultibaseKey(key).to_verification_key()?;646646+ let digest = aws_lc_rs::digest::digest(&SHA256, multibase.as_ref());647647+ Ok(data_encoding::BASE64URL_NOPAD.encode(digest.as_ref()))648648+ };649649+650650+ let key = normalise_str(key)?;651651+ Some(inner(key).map_err(|error| error.to_string()))652652+}653653+654654+/// Generate the Bluesky oauth client_id parameter.655655+fn client_id(mut base: Url) -> Url {656656+ if base.scheme() == "http" && base.host_str() == Some("localhost") {657657+ let redirect_base = base.clone();658658+ base.set_port(None).expect("knot base url must be a base");659659+ {660660+ let mut query = base.query_pairs_mut();661661+ query.append_pair("redirect_uri", redirect_uri(redirect_base).as_str());662662+ query.append_pair("scope", "atproto");663663+ }664664+ } else {665665+ base.set_path(WELL_KNOWN_CLIENT_METADATA);666666+ }667667+ base668668+}669669+670670+/// Generate the Bluesky oauth redirect parameter.671671+fn redirect_uri(mut base: Url) -> Url {672672+ use std::net::IpAddr;673673+ use std::net::Ipv4Addr;674674+675675+ if base.scheme() == "http" && base.host_str() == Some("localhost") {676676+ base.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST)).expect("");677677+ }678678+ base.set_path(PATH_AUTHORIZE_RESULT);679679+ base680680+}681681+682682+/// Get the URL for the knot's JWKs.683683+fn jwks_uri(mut base: Url) -> Url {684684+ base.set_path(WELL_KNOWN_JWKS);685685+ base686686+}687687+688688+#[derive(Debug, serde::Deserialize)]689689+struct EventParameters {690690+ /// base64url-encoded key fingerprint.691691+ key_id: String,692692+}693693+694694+async fn authorize_event(695695+ Query(EventParameters { key_id }): Query<EventParameters>,696696+ ws: WebSocketUpgrade,697697+) -> Result<impl IntoResponse, StatusCode> {698698+ async fn wait_for_authorization(699699+ key_id: &str,700700+ mut socket: WebSocket,701701+ ) -> Result<(), Box<dyn core::error::Error>> {702702+ use tokio::time::timeout;703703+704704+ const TIMEOUT: Duration = Duration::from_mins(10);705705+ const RECEIVER_THRESHOLD: usize = 10;706706+707707+ let mut rx = {708708+ let mut guard = AUTHORIZATIONS709709+ .get_or_init(|| Mutex::new(HashMap::new()))710710+ .lock()711711+ .unwrap();712712+713713+ let tx = guard714714+ .entry(key_id.into())715715+ .or_insert_with(|| broadcast::channel(1).0);716716+717717+ if tx.receiver_count() >= RECEIVER_THRESHOLD {718718+ tracing::error!(719719+ ?key_id,720720+ "too many receivers for key authorization notification"721721+ );722722+ return Err(format!("too many recievers for key id: '{key_id}'").into());723723+ }724724+725725+ tx.subscribe()726726+ };727727+728728+ let message = match timeout(TIMEOUT, rx.recv()).await {729729+ Ok(Ok(true)) => Message::text(format!("ACCEPTED {key_id}")),730730+ Ok(Ok(false)) => Message::text(format!("REJECTED {key_id}")),731731+ Ok(Err(_)) => Message::text(format!("ABORTED")),732732+ Err(_) => Message::text(format!("TIMEOUT")),733733+ };734734+735735+ socket.send(message).await?;736736+ socket.flush().await?;737737+ socket.close().await?;738738+739739+ Ok(())740740+ }741741+742742+ // `key_id` should be a base64-url encoded sha256 digest.743743+744744+ const OUTPUT_LEN: usize = SHA256.output_len;745745+ let Ok(OUTPUT_LEN) = data_encoding::BASE64URL_NOPAD746746+ .decode(key_id.as_bytes())747747+ .map(|bytes| bytes.len())748748+ else {749749+ return Err(StatusCode::BAD_REQUEST);750750+ };751751+752752+ Ok(ws.on_upgrade(async move |socket| {753753+ if let Err(error) = wait_for_authorization(&key_id, socket).await {754754+ tracing::error!(?error, ?key_id);755755+ }756756+ }))757757+}
+13-12
crates/gordian-knot/src/public/events.rs
···11use std::time::Duration;2233-use axum::{44- extract::{55- Query, State, WebSocketUpgrade,66- ws::{Message, WebSocket},77- },88- http::StatusCode,99- response::IntoResponse,1010-};1111-use futures_util::{SinkExt as _, StreamExt as _, TryStreamExt as _};33+use axum::extract::Query;44+use axum::extract::State;55+use axum::extract::WebSocketUpgrade;66+use axum::extract::ws::Message;77+use axum::extract::ws::WebSocket;88+use axum::http::StatusCode;99+use axum::response::IntoResponse;1010+use futures_util::SinkExt as _;1111+use futures_util::StreamExt as _;1212+use futures_util::TryStreamExt as _;1213use gordian_types::Tid;1313-use serde::{Deserialize, Serialize};1414+use serde::Deserialize;1515+use serde::Serialize;1416use time::OffsetDateTime;1517use tokio::time::Instant;16181717-use crate::model::Knot;1818-1919use super::xrpc::XrpcError;2020+use crate::model::Knot;20212122const KEEP_ALIVE: Duration = Duration::from_secs(45);2223
+14-12
crates/gordian-knot/src/public/git.rs
···55mod upload_pack;6677pub use authorization::GitAuthorization;88-pub use error::{Error, NotFound};88+use axum::extract::Query;99+use axum::extract::Request;1010+use axum::extract::State;1111+use axum::response::IntoResponse as _;1212+use axum::response::Response;1313+pub use error::Error;1414+pub use error::NotFound;1515+use tokio::io::AsyncWrite;1616+use tokio::io::AsyncWriteExt as _;9171010-use axum::{1111- extract::{Query, Request, State},1212- response::{IntoResponse as _, Response},1313-};1414-use tokio::io::{AsyncWrite, AsyncWriteExt as _};1515-1616-use crate::{1717- extractors::{GitProtocol, request_id::RequestId},1818- model::Knot,1919-};1818+use crate::extractors::GitProtocol;1919+use crate::extractors::request_id::RequestId;2020+use crate::model::Knot;20212122pub fn router() -> axum::Router<Knot> {2222- use axum::routing::{get, post};2323+ use axum::routing::get;2424+ use axum::routing::post;2325 axum::Router::new()2426 .route("/info/refs", get(info_refs))2527 .route("/git-upload-archive", post(upload_archive::upload_archive))
···11-use axum::{22- extract::{FromRef, FromRequestParts},33- http::{header::AUTHORIZATION, request::Parts},44-};55-use gordian_auth::{66- IntoVerificationKey, OpenSshKey,77- jwt::{Claims, Token, decode},88-};11+use axum::extract::FromRef;22+use axum::extract::FromRequestParts;33+use axum::http::header::AUTHORIZATION;44+use axum::http::request::Parts;55+use gordian_auth::IntoVerificationKey;66+use gordian_auth::OpenSshKey;77+use gordian_auth::jwt::Claims;88+use gordian_auth::jwt::Token;99+use gordian_auth::jwt::decode;910use gordian_identity::Resolver;1011use gordian_types::Nsid;1112use time::OffsetDateTime;12131313-use crate::{1414- model::Knot,1515- nsid::SH_TANGLED_REPO_GITRECEIVEPACK,1616- services::authorization::{1717- AuthorizationClaimsStore as _, Verification, VerificationError, extract_token,1818- },1919-};2020-2114use super::Error;1515+use crate::model::Knot;1616+use crate::nsid::SH_TANGLED_REPO_GITRECEIVEPACK;1717+use crate::services::authorization::AuthorizationClaimsStore as _;1818+use crate::services::authorization::Verification;1919+use crate::services::authorization::VerificationError;2020+use crate::services::authorization::extract_token;22212322#[derive(Debug)]2423struct GitVerification;···5455 GitVerification::verify(&knot, now, knot.instance(), &unverified_claims)5556 .await5657 .map_err(|error| match error {5757- // Git re-uses the token from the credential helper for each request in a single push.5858+ // Git re-uses the token from the credential helper for each request in a single5959+ // push.5860 //5959- // Returning 'Forbidden' here will make git abort. Instead, we return an Unauthorized6060- // which will force git to get a new token from the credential helper.6161+ // Returning 'Forbidden' here will make git abort. Instead, we return an6262+ // Unauthorized which will force git to get a new token from the6363+ // credential helper.6164 VerificationError::Reused => Error::unauthorized(&knot, "authorization re-used"),6265 error => Error::forbidden(&knot, error.to_string()),6366 })?;···7776 let verification_keys = doc7877 .verification_method7978 .into_iter()8080- .filter_map(|vm| vm.into_verification_key().ok());7979+ .filter_map(|vm| vm.to_verification_key().ok());81808281 // Try to decode and verify the JWT using any one of the verification keys8382 // we have for the DID.···9796 .await9897 .unwrap_or_default()9998 .into_iter()100100- .filter_map(|public_key| OpenSshKey(public_key.key).into_verification_key().ok());9999+ .filter_map(|public_key| OpenSshKey(public_key.key).to_verification_key().ok());101100102101 // Try to decode and verify the JWT using any one of the public keys103102 // we have for the DID.
···11use std::process::Stdio;2233-use axum::{44- extract::{Request, State},55- http::header::CONTENT_TYPE,66- response::IntoResponse,77-};33+use axum::extract::Request;44+use axum::extract::State;55+use axum::http::header::CONTENT_TYPE;66+use axum::response::IntoResponse;87use axum_extra::body::AsyncReadBody;99-use tokio::{io::AsyncWriteExt as _, net::unix::pipe::Sender};88+use tokio::io::AsyncWriteExt as _;99+use tokio::net::unix::pipe::Sender;10101111-use crate::{1212- command::{SetOptionEnv as _, TraceProcessCompletion as _},1313- extractors::{GitProtocol, request_id::RequestId},1414- model::{Knot, repository::TangledRepository},1515-};1111+use crate::command::SetOptionEnv as _;1212+use crate::command::TraceProcessCompletion as _;1313+use crate::extractors::GitProtocol;1414+use crate::extractors::request_id::RequestId;1515+use crate::model::Knot;1616+use crate::model::repository::TangledRepository;16171718const UPLOAD_ARCHIVE_RESULT: &str = "application/x-git-upload-archive-result";1819
+19-16
crates/gordian-knot/src/public/git/upload_pack.rs
···11-use axum::{22- extract::{Request, State},33- http::header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},44- response::IntoResponse,55-};66-use axum_extra::body::AsyncReadBody;71use std::process::Stdio;88-use tokio::{99- io::AsyncWriteExt as _,1010- net::unix::pipe::{Sender, pipe as async_pipe},1111-};1221313-use crate::{1414- command::{SetOptionEnv as _, TraceProcessCompletion},1515- extractors::{GitProtocol, request_id::RequestId},1616- model::{Knot, repository::TangledRepository},1717-};33+use axum::extract::Request;44+use axum::extract::State;55+use axum::http::header::CACHE_CONTROL;66+use axum::http::header::CONNECTION;77+use axum::http::header::CONTENT_TYPE;88+use axum::response::IntoResponse;99+use axum_extra::body::AsyncReadBody;1010+use tokio::io::AsyncWriteExt as _;1111+use tokio::net::unix::pipe::Sender;1212+use tokio::net::unix::pipe::pipe as async_pipe;1313+1414+use crate::command::SetOptionEnv as _;1515+use crate::command::TraceProcessCompletion;1616+use crate::extractors::GitProtocol;1717+use crate::extractors::request_id::RequestId;1818+use crate::model::Knot;1919+use crate::model::repository::TangledRepository;18201921const UPLOAD_PACK_ADVERTISEMENT: &str = "application/x-git-upload-pack-advertisement";2022const UPLOAD_PACK_RESULT: &str = "application/x-git-upload-pack-result";2123const NO_CACHE: &str = "no-cache, max-age=0, must-revalidate";2224const KEEP_ALIVE: &str = "keep-alive";23252424-/// Serve the "/info/refs?service=git-upload-pack" phase of a `git fetch` operation.2626+/// Serve the "/info/refs?service=git-upload-pack" phase of a `git fetch`2727+/// operation.2528pub async fn advertise_upload_pack(2629 State(knot): State<Knot>,2730 protocol: Option<GitProtocol>,
+18-14
crates/gordian-knot/src/public/xrpc.rs
···11-use crate::model::{22- Knot, errors,33- repository::{BlobError, RevspecError, TreeError},44-};55-use axum::{66- Json, Router,77- extract::{FromRef, FromRequestParts},88- http::StatusCode,99- response::IntoResponse,1010-};11+use std::borrow::Cow;22+33+use axum::Json;44+use axum::Router;55+use axum::extract::FromRef;66+use axum::extract::FromRequestParts;77+use axum::http::StatusCode;88+use axum::response::IntoResponse;119use gordian_identity::Resolver;1210use serde::de::DeserializeOwned;1313-use std::borrow::Cow;1111+1212+use crate::model::Knot;1313+use crate::model::errors;1414+use crate::model::repository::BlobError;1515+use crate::model::repository::RevspecError;1616+use crate::model::repository::TreeError;14171518pub mod sh_tangled;1619···7673 T: IntoResponse,7774{7875 fn into_response(self) -> axum::response::Response {7979- use axum::http::header::{CACHE_CONTROL, HeaderValue};7676+ use axum::http::header::CACHE_CONTROL;7777+ use axum::http::header::HeaderValue;80788179 let Self {8280 response,···202198ise!(gix::reference::find::existing::Error, StatusCode::NOT_FOUND);203199ise!(gix::repository::commit_graph_if_enabled::Error);204200205205-/// Wraps [`axum::extract::Query`] to customize the rejection type to [`XrpcError`].206206-///201201+/// Wraps [`axum::extract::Query`] to customize the rejection type to202202+/// [`XrpcError`].207203pub struct XrpcQuery<T>(pub T);208204209205impl<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for XrpcQuery<T> {
+9-8
crates/gordian-knot/src/public/xrpc/sh_tangled.rs
···66/// Get the owner of a service.77///88/// <https://tangled.org/tangled.org/core/blob/master/lexicons/owner.json>99-///109pub fn owner<S>() -> axum::Router<S>1110where1211 S: Clone + Send + Sync + 'static,1312 Knot: axum::extract::FromRef<S>,1413{1515- use impl_owner::{LXM, owner_query};1414+ use impl_owner::LXM;1515+ use impl_owner::owner_query;1616 axum::Router::new().route(LXM, axum::routing::get(owner_query))1717}18181919mod impl_owner {2020- use axum::{2121- Json,2222- extract::{FromRef, State},2323- response::{IntoResponse, Response},2424- };2020+ use axum::Json;2121+ use axum::extract::FromRef;2222+ use axum::extract::State;2323+ use axum::response::IntoResponse;2424+ use axum::response::Response;25252626- use crate::{lexicon::sh_tangled::owner::Output, model::Knot};2626+ use crate::lexicon::sh_tangled::owner::Output;2727+ use crate::model::Knot;27282829 pub const LXM: &str = "/sh.tangled.owner";2930
···33/// Get the version of a knot.44///55/// <https://tangled.org/tangled.org/core/blob/master/lexicons/knot/version.json>66-///76pub fn version<S>() -> axum::Router<S>87where98 S: Clone + Send + Sync + 'static,109 Knot: axum::extract::FromRef<S>,1110{1212- use impl_version::{LXM, handle};1111+ use impl_version::LXM;1212+ use impl_version::handle;1313 axum::Router::new().route(LXM, axum::routing::get(handle))1414}1515
···11// mod pg_impl;22pub mod types;3344-use futures_util::{StreamExt, stream::BoxStream};44+use futures_util::StreamExt;55+use futures_util::stream::BoxStream;56use gordian_auth::jwt;67use gordian_jetstream::Value;77-use gordian_lexicon::sh_tangled::{PublicKey, knot::Member, repo::Repo};88-use gordian_types::{Did, DidBuf};88+use gordian_lexicon::sh_tangled::PublicKey;99+use gordian_lexicon::sh_tangled::knot::Member;1010+use gordian_lexicon::sh_tangled::repo::Repo;1111+use gordian_types::Did;1212+use gordian_types::DidBuf;913use serde::Serialize;1010-use sqlx::{SqlitePool, error::ErrorKind};1414+use sqlx::SqlitePool;1515+use sqlx::error::ErrorKind;1116use time::OffsetDateTime;1212-use types::{DeletedRecord, EventRow};1717+use types::DeletedRecord;1818+use types::EventRow;13191420use crate::types::RecordKey;1521···7569 .boxed()7670 }77717878- /// Get all the knot members and repository collaborators associated with the knot.7272+ /// Get all the knot members and repository collaborators associated with7373+ /// the knot.7974 pub fn members(&self) -> BoxStream<'_, Result<DidBuf, DataStoreError>> {8075 sqlx::query!(r#"SELECT DISTINCT subject AS "subject: DidBuf" FROM knot_member UNION SELECT DISTINCT subject AS "subject: DidBuf" FROM repository_collaborator"#)8176 .fetch(&self.db)···86798780 /// Upsert a knot member.8881 ///8989- /// Returns `true` if the member record was newly inserted/updated, or `false` if9090- /// the member was already present in the database.9191- ///8282+ /// Returns `true` if the member record was newly inserted/updated, or8383+ /// `false` if the member was already present in the database.9284 pub async fn upsert_knot_member(9385 &self,9486 rkey: &str,···141135142136 /// Upsert a repository collaborator.143137 ///144144- /// Returns `true` if the collaborator record was newly inserted/updated, or `false` if145145- /// the collaborator was already present in the database.146146- ///138138+ /// Returns `true` if the collaborator record was newly inserted/updated, or139139+ /// `false` if the collaborator was already present in the database.147140 pub async fn upsert_repository_collaborator(148141 &self,149142 did: &Did,···486481 Ok(self.tx.commit().await?)487482 }488483489489- /// Insert a new repository entry from a jetstream commit, returning `true` if the repository490490- /// appears to be new.484484+ /// Insert a new repository entry from a jetstream commit, returning `true`485485+ /// if the repository appears to be new.491486 ///492487 /// # Note493488 ///494489 /// This is *not* an UPSERT.495495- ///496490 pub async fn insert_repository(497491 &mut self,498492 did: &Did,
···11+use axum::body::Body;22+use axum::http::Request;33+use axum::http::StatusCode;14use gordian_auth::jwt::Claims;25use gordian_lexicon::sh_tangled;33-use gordian_types::{Did, Tid};44-55-use axum::{66- body::Body,77- http::{Request, StatusCode},88-};99-use time::{OffsetDateTime, format_description::well_known::Rfc3339};66+use gordian_types::Did;77+use gordian_types::Tid;88+use time::OffsetDateTime;99+use time::format_description::well_known::Rfc3339;1010use tower::ServiceExt;11111212use crate::model::Knot;···103103}104104105105mod sh_tangled_repo_create {106106- use crate::nsid::{SH_TANGLED_REPO_CREATE, SH_TANGLED_REPO_DELETE};106106+ use axum::http::HeaderValue;107107+ use axum::http::Method;108108+ use axum::http::Response;109109+ use axum::http::header;110110+ use gordian_pds::Pds;107111108112 use super::super::public;109113 use super::*;110110- use axum::http::{HeaderValue, Method, Response, header};111111- use gordian_pds::Pds;114114+ use crate::nsid::SH_TANGLED_REPO_CREATE;115115+ use crate::nsid::SH_TANGLED_REPO_DELETE;112116113117 fn make_claims<F>(iss: &Did, aud: &Did, modify_claims: F) -> Claims114118 where
+3-1
crates/gordian-knot/src/types.rs
···11use core::fmt;2233+use gordian_types::Did;44+use gordian_types::RecordUri;55+36use crate::lexicon::com::atproto::repo::list_records::Record;44-use gordian_types::{Did, RecordUri};5768pub mod push_certificate;79pub mod repository_key;
+6-4
crates/gordian-knot/src/types/push_certificate.rs
···5151 Bad,5252 /// `git push --signed` sent the nonce we asked it to send5353 Ok,5454- /// `git push --signed` send a nonce different from what we asked it to send now, but5555- /// in a previous session.5454+ /// `git push --signed` send a nonce different from what we asked it to send5555+ /// now, but in a previous session.5656 Slop,5757}5858···7070 }7171}72727373-/// Push certificate assembled from environment variables passed to a git pre-receive hook.7373+/// Push certificate assembled from environment variables passed to a git7474+/// pre-receive hook.7475///7576/// See: `man 1 git-receive-pack`7677#[derive(Debug)]7778pub struct PushCertificate<'a> {7879 /// Object ID of the push certificate.7980 ///8080- /// The certificate may be read from the git repository as a blob using this ID.8181+ /// The certificate may be read from the git repository as a blob using this8282+ /// ID.8183 pub cert: &'a str,8284 pub key: &'a str,8385 pub signer: &'a str,
···132132mod tests {133133 use std::str::FromStr;134134135135- use super::{Error, RepositoryPath};135135+ use super::Error;136136+ use super::RepositoryPath;136137137138 #[test]138139 fn can_parse_from_string() {
+12-11
crates/gordian-knot/src/types/sh_tangled.rs
···11pub mod repo {22 pub mod branches {33- use crate::lexicon::sh_tangled::repo::refs;43 use serde::Serialize;5465 pub use crate::lexicon::sh_tangled::repo::branches::Input;66+ use crate::lexicon::sh_tangled::repo::refs;7788 /// Output of `sh.tangled.repo.branches` query.99 #[derive(Debug, Default, Serialize)]···2626 }27272828 pub mod compare {2929- use crate::lexicon::extra::objectid::ObjectId;3029 use serde::Serialize;31303131+ use crate::lexicon::extra::objectid::ObjectId;3232 pub use crate::lexicon::sh_tangled::repo::compare::Input;33333434 #[derive(Debug, Serialize)]···4545 }46464747 pub mod diff {4848- use crate::lexicon::{extra::objectid::ObjectId, sh_tangled::repo::refs};4949- use serde::Serialize;5048 use std::borrow::Cow;51495050+ use serde::Serialize;5151+5252+ use crate::lexicon::extra::objectid::ObjectId;5253 pub use crate::lexicon::sh_tangled::repo::diff::Input;5454+ use crate::lexicon::sh_tangled::repo::refs;53555456 #[derive(Debug, Serialize)]5557 pub struct Output {···158156 }159157160158 pub mod log {161161- use crate::lexicon::sh_tangled::repo::refs;162159 use serde::Serialize;163160164161 pub use crate::lexicon::sh_tangled::repo::log::Input;162162+ use crate::lexicon::sh_tangled::repo::refs;165163166164 #[derive(Debug, Serialize)]167165 pub struct Output {···174172 }175173176174 pub mod tags {177177- use crate::lexicon::{178178- extra::objectid::{Array, ObjectId},179179- sh_tangled::repo::refs,180180- };181175 use serde::Serialize;182176177177+ use crate::lexicon::extra::objectid::Array;178178+ use crate::lexicon::extra::objectid::ObjectId;179179+ use crate::lexicon::sh_tangled::repo::refs;183180 pub use crate::lexicon::sh_tangled::repo::tags::Input;184181185182 /// Output of `sh.tangled.repo.tags` query.186183 ///187187- /// This is not defined in the lexicon, but models what knotserver currently188188- /// produces.184184+ /// This is not defined in the lexicon, but models what knotserver185185+ /// currently produces.189186 #[derive(Debug, Serialize)]190187 pub struct Output {191188 pub tags: Vec<Tag>,
···44 //! collection. Does not require auth.55 //!66 //! <https://docs.bsky.app/docs/api/com-atproto-repo-list-records>77- //!87 use gordian_types::RecordUri;98109 #[derive(Debug, serde::Deserialize, serde::Serialize)]
+7-3
crates/gordian-lexicon/src/extra/objectid.rs
···11-use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeSeq};22-use std::{marker::PhantomData, str::FromStr};11+use std::marker::PhantomData;22+use std::str::FromStr;33+44+use serde::Deserialize;55+use serde::Serialize;66+use serde::de::Visitor;77+use serde::ser::SerializeSeq;3849#[doc(hidden)]510#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]···1510/// An object ID that can serialized as a hex-string or an array of integers.1611///1712/// This only exists because knotserver uses both representations.1818-///1913#[derive(Clone, Hash, PartialEq, Eq)]2014pub struct ObjectId<E = Hex> {2115 inner: gix_hash::ObjectId,
-1
crates/gordian-lexicon/src/lib.rs
···33//!44//! When you're too lazy to setup codegen, not lazy enough to not write them55//! all out manually.66-//!76pub mod com;87pub mod sh_tangled;98
+6-4
crates/gordian-lexicon/src/sh_tangled.rs
···11//!22//! <https://tangled.org/@tangled.org/core/tree/master/lexicons>33-//!43pub mod actor;54pub mod feed;65pub mod git;···910pub mod spindle;1011pub mod string;11121212-use gordian_types::Did;1313-use serde::{Deserialize, Serialize};1413use std::borrow::Cow;1414+1515+use gordian_types::Did;1616+use serde::Deserialize;1717+use serde::Serialize;1518use time::OffsetDateTime;16191720pub mod owner {1821 use gordian_types::Did;1919- use serde::{Deserialize, Serialize};2222+ use serde::Deserialize;2323+ use serde::Serialize;20242125 /// XRPC query `sh.tangled.owner` output.2226 ///
···11-use gordian_types::Did;22-use serde::{Deserialize, Serialize};31use std::borrow::Cow;22+33+use gordian_types::Did;44+use serde::Deserialize;55+use serde::Serialize;46use time::OffsetDateTime;5768pub mod version {77- use serde::{Deserialize, Serialize};89 use std::borrow::Cow;1010+1111+ use serde::Deserialize;1212+ use serde::Serialize;9131014 /// XRPC query `sh.tangled.knot.version` output.1115 #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)]
+12-4
crates/gordian-lexicon/src/sh_tangled/repo.rs
···1616pub mod tags;1717pub mod tree;18181919-use gordian_types::{Did, DidBuf, RecordUri};2020-use serde::{Deserialize, Serialize};2119use std::borrow::Cow;2020+2121+use gordian_types::Did;2222+use gordian_types::DidBuf;2323+use gordian_types::RecordUri;2424+use serde::Deserialize;2525+use serde::Serialize;2226use time::OffsetDateTime;23272428use crate::extra::objectid::ObjectId;···155151156152/// Lexicon sub-types.157153pub mod refs {158158- use crate::extra::objectid::{Array, ObjectId};154154+ use std::borrow::Cow;155155+ use std::collections::HashMap;156156+159157 use serde::Serialize;160160- use std::{borrow::Cow, collections::HashMap};161158 use time::OffsetDateTime;159159+160160+ use crate::extra::objectid::Array;161161+ use crate::extra::objectid::ObjectId;162162163163 #[derive(Debug, Default, Serialize)]164164 #[serde(rename_all = "camelCase")]
···11//!22//! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/compare.json>33-//!4354/// Parameters for the `sh.tangled.repo.compare` query.65///
···11//!22//! <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/create.json>33-//!4355-use serde::{Deserialize, Serialize};44+use serde::Deserialize;55+use serde::Serialize;6677/// Parameters for the `sh.tangled.repo.create` procedure.88///···1717 #[serde(skip_serializing_if = "Option::is_none")]1818 pub default_branch: Option<String>,19192020- /// A source URL to clone from , populate this when forking or importing a repository.2020+ /// A source URL to clone from , populate this when forking or importing a2121+ /// repository.2122 #[serde(skip_serializing_if = "Option::is_none")]2223 pub source: Option<String>,2324}
···991010pub mod com_atproto {1111 pub mod repo {1212- use axum::{1313- Json, Router,1414- extract::{FromRef, Query, State},1515- http::StatusCode,1616- response::IntoResponse,1717- };1212+ use axum::Json;1313+ use axum::Router;1414+ use axum::extract::FromRef;1515+ use axum::extract::Query;1616+ use axum::extract::State;1717+ use axum::http::StatusCode;1818+ use axum::response::IntoResponse;1819 use gordian_types::DidBuf;1920 use serde_json::Value;2021 use sqlx::Row as _;
+28-44
crates/gordian-pds/src/state.rs
···11-use std::{fmt::Debug, net::SocketAddr, sync::Arc};11+use std::fmt::Debug;22+use std::net::SocketAddr;33+use std::sync::Arc;2433-use aws_lc_rs::{44- encoding::{AsBigEndian as _, EcPublicKeyCompressedBin},55- rand::SystemRandom,66- signature::{ECDSA_P256K1_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _},77-};55+use aws_lc_rs::encoding::AsBigEndian as _;66+use aws_lc_rs::encoding::EcPublicKeyCompressedBin;77+use aws_lc_rs::signature::ECDSA_P256K1_SHA256_FIXED_SIGNING;88+use aws_lc_rs::signature::EcdsaKeyPair;99+use aws_lc_rs::signature::KeyPair as _;810use futures_util::FutureExt as _;911use gordian_auth::jwt;1012use gordian_identity::DidDocument;1111-use gordian_types::{DidBuf, Tid};1212-use sqlx::{1313- SqlitePool,1414- sqlite::{SqliteConnectOptions, SqlitePoolOptions},1515- types::time::OffsetDateTime,1616-};1717-use tokio::{1818- net::TcpListener,1919- sync::broadcast::{self, Receiver, Sender},2020-};1313+use gordian_types::DidBuf;1414+use gordian_types::Tid;1515+use sqlx::SqlitePool;1616+use sqlx::sqlite::SqliteConnectOptions;1717+use sqlx::sqlite::SqlitePoolOptions;1818+use sqlx::types::time::OffsetDateTime;1919+use tokio::net::TcpListener;2020+use tokio::sync::broadcast::Receiver;2121+use tokio::sync::broadcast::Sender;2222+use tokio::sync::broadcast::{self};21232224pub type Event = ();2325···4442 .expect("service endpoint should be a valid URL")4543 }46444747- /// Add a DID document created from `did`, `handle`, and a random ecdsa key-pair to the PDS.4545+ /// Add a DID document created from `did`, `handle`, and a random ecdsa4646+ /// key-pair to the PDS.4847 ///4949- /// The internal address of the mock PDS will be set as the "#atproto_pds" service for5050- /// the new identity.5151- ///4848+ /// The internal address of the mock PDS will be set as the "#atproto_pds"4949+ /// service for the new identity.5250 pub async fn insert_identity(&self, did: &gordian_types::Did, handle: &str) {5351 let mut doc = DidDocument::new(did, handle).expect("valid did for did document");5452 doc.service.push(gordian_identity::Service::atproto_pds(···123121124122 // Create an inter-service auth header for an account in the fake PDS.125123 pub async fn service_auth(&self, claims: &jwt::Claims) -> String {126126- use data_encoding::BASE64URL_NOPAD as Encoding;127124 use sqlx::Row as _;128125129129- let mut token = String::new();130130- let header = Encoding.encode(131131- &serde_json::to_vec(&jwt::Header {132132- typ: jwt::Type::JWT,133133- alg: jwt::Algorithm::ES256K,134134- crv: None,135135- })136136- .unwrap(),137137- );138138-139139- token.push_str(&header);140140-141141- let claims_enc = Encoding.encode(&serde_json::to_vec(claims).unwrap());142142- token.push('.');143143- token.push_str(&claims_enc);126126+ let header = jwt::Header {127127+ typ: jwt::Type::Jwt,128128+ alg: jwt::Algorithm::ES256K,129129+ ..Default::default()130130+ };144131145132 let result = sqlx::query("SELECT key FROM identity WHERE did = ?")146133 .bind(claims.iss.as_ref())···141150 let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256K1_SHA256_FIXED_SIGNING, pkcs8)142151 .expect("PKCSv8 key must be valid");143152144144- let signature = key_pair145145- .sign(&SystemRandom::new(), token.as_bytes())146146- .unwrap();147147-148148- let signature = Encoding.encode(signature.as_ref());149149- token.push('.');150150- token.push_str(&signature);151151-153153+ let token = jwt::encode_and_sign(header, claims, &key_pair).unwrap();152154 format!("Bearer {token}")153155 }154156}
+8-5
crates/gordian-types/src/aturi.rs
···11//! AT-protocol specific URI.22//!33//! <https://atproto.com/specs/at-uri-scheme>44-//!54use core::fmt;6576#[cfg(feature = "serde")]88-use serde::{Deserialize, Serialize};77+use serde::Deserialize;88+#[cfg(feature = "serde")]99+use serde::Serialize;9101010-use crate::{did::Did, handle::Handle, nsid::Nsid};1111+use crate::did::Did;1212+use crate::handle::Handle;1313+use crate::nsid::Nsid;11141215#[derive(Debug, Hash, PartialEq, Eq)]1316pub struct AtUri<'a> {···9491 /// # Errors9592 ///9693 /// Returns an error if `uri` is not a valid AT-URI.9797- ///9894 pub fn parse(uri: &'a str) -> Result<Self, Error> {9995 let uri = uri.strip_prefix("at://").ok_or(Error::InvalidScheme)?;10096 let mut parts = uri.split('/');···155153156154#[cfg(test)]157155mod tests {158158- use super::{AtUri, Error};156156+ use super::AtUri;157157+ use super::Error;159158160159 #[test]161160 fn valid_samples() {
+10-13
crates/gordian-types/src/did.rs
···11-use core::{borrow, fmt, ops};11+use core::borrow;22+use core::fmt;33+use core::ops;2435#[cfg(feature = "serde")]46pub(crate) use __serde_impl::Visitor as DidVisitor;5768/// An Atmosphere DID.79///88-/// This is an _unsized_ type similiar to [`str`], so must always be used behind a pointer like99-/// `&` or [`Box`].1010+/// This is an _unsized_ type similiar to [`str`], so must always be used behind1111+/// a pointer like `&` or [`Box`].1012///1113/// For an owned variant use [`DidBuf`].1214///···2523 /// # Panics2624 ///2725 /// Panics if `did` is not a valid DID.2828- ///2926 #[must_use]3027 pub fn from_static(did: &'static str) -> &'static Self {3128 validate_did(did).expect("Hard-coded did should be valid");···4746 /// # Errors4847 ///4948 /// Returns an error if `did` is not a valid DID.5050- ///5149 pub fn parse<D: AsRef<str> + ?Sized>(did: &D) -> Result<&Self, Error> {5250 validate_did(did.as_ref())?;5351 Ok(Self::new(did))···6262 /// let did = Did::parse("did:plc:65gha4t3avpfpzmvpbwovss7").unwrap();6363 /// assert_eq!(did.typ(), "did");6464 /// ```6565- ///6665 #[must_use]6766 #[allow(clippy::missing_panics_doc)]6867 pub fn typ(&self) -> &'static str {···8687 /// let did = Did::parse("did:web:tjh.dev").unwrap();8788 /// assert_eq!(did.method(), "web");8889 /// ```8989- ///9090 #[must_use]9191 pub fn method(&self) -> &str {9292 &self.inner[4..self.method_terminator()]···104106 /// let did = Did::parse("did:web:tjh.dev").unwrap();105107 /// assert_eq!(did.ident(), "tjh.dev");106108 /// ```107107- ///108109 #[must_use]109110 pub fn ident(&self) -> &str {110111 &self.inner[1 + self.method_terminator()..]···174177}175178176179/// Length in bytes an [`DidBuf`] can be before being allocated on the heap.177177-///178180// Assume most DIDs are PLC method, and this DID is a typical length.179181const DID_PLC_LEN: usize = "did:plc:65gha4t3avpfpzmvpbwovss7".len();180182···196200 /// # Panics197201 ///198202 /// Panics if `did` is not a valid DID.199199- ///200203 #[must_use]201204 pub fn from_static(did: &'static str) -> Self {202205 validate_did(did).expect("hard-coded did should be valid");···219224 /// # Errors220225 ///221226 /// Returns an error if `did` is not a valid DID.222222- ///223227 pub fn parse<D: AsRef<str> + Into<SmallDid>>(did: D) -> Result<Self, Error> {224228 validate_did(did.as_ref())?;225229 Ok(Self::new(did))···326332327333#[cfg(feature = "sqlx")]328334mod sqlx_impl {329329- use super::{Did, DidBuf};335335+ use super::Did;336336+ use super::DidBuf;330337 impl_str_wrapper_sqlx!(ref Did);331338 impl_str_wrapper_sqlx!(Box<Did>);332339 impl_str_wrapper_sqlx!(DidBuf);···335340336341#[cfg(test)]337342mod tests {338338- use super::{Did, DidBuf, Error};343343+ use super::Did;344344+ use super::DidBuf;345345+ use super::Error;339346340347 /// An example DID.341348 const EXAMPLE_DID: &str = "did:plc:65gha4t3avpfpzmvpbwovss7";
+5-6
crates/gordian-types/src/handle.rs
···11/// An Atmosphere handle.22///33-/// This is an _unsized_ type similiar to [`str`], so must always be used behind a pointer like44-/// `&` or [`Box`].33+/// This is an _unsized_ type similiar to [`str`], so must always be used behind44+/// a pointer like `&` or [`Box`].55///66/// See: <https://atproto.com/specs/handle>77-///87#[derive(Hash, PartialEq, Eq, PartialOrd, Ord)]98#[repr(transparent)]109pub struct Handle {···1617 /// # Panics1718 ///1819 /// Panics if `handle` is not a valid DID.1919- ///2020 #[must_use]2121 pub fn from_static(handle: &'static str) -> &'static Self {2222 validate_handle(handle).expect("Hard-coded handle should be valid");···3638 /// # Errors3739 ///3840 /// Returns an error if `handle` is not a valid handle.3939- ///4041 pub fn parse<S: AsRef<str> + ?Sized>(handle: &S) -> Result<&Self, Error> {4142 validate_handle(handle.as_ref())?;4243 Ok(Self::new(handle))···171174172175 #[cfg(feature = "serde")]173176 mod serde_tests {177177+ use serde::Deserialize;178178+ use serde::Serialize;179179+174180 use super::super::Handle;175175- use serde::{Deserialize, Serialize};176181177182 #[test]178183 fn serde_handle_buf() {
+4-3
crates/gordian-types/src/lib.rs
···11//!22//! Primitive types in the atmosphere.33-//!43#[macro_use]54mod macros;65···1011pub mod tid;1112pub mod uri;12131313-pub use did::{Did, DidBuf};1414+pub use did::Did;1515+pub use did::DidBuf;1416pub use handle::Handle;1517pub use nsid::Nsid;1616-pub use tid::{Tid, TidClock};1818+pub use tid::Tid;1919+pub use tid::TidClock;1720pub use uri::RecordUri;18211922#[cfg(feature = "serde")]
+2-6
crates/gordian-types/src/nsid.rs
···2233/// An Atmosphere Namespaced Identifer.44///55-/// This is an _unsized_ type similiar to [`str`], so must always be used behind a pointer like66-/// `&` or [`Box`].55+/// This is an _unsized_ type similiar to [`str`], so must always be used behind66+/// a pointer like `&` or [`Box`].77///88/// See: <https://atproto.com/specs/nsid>99-///109#[derive(Hash, PartialEq, Eq, PartialOrd, Ord)]1110#[repr(transparent)]1211pub struct Nsid {···1819 /// # Panics1920 ///2021 /// Panics if `nsid` is not a valid NSID.2121- ///2222 #[must_use]2323 pub fn from_static(nsid: &'static str) -> &'static Self {2424 validate_nsid(nsid).expect("hard-coded NSID should be valid");···3941 /// # Safety4042 ///4143 /// It is the callers responsibility to ensure the NSID is valid.4242- ///4344 #[must_use]4445 pub const unsafe fn from_static_unchecked(nsid: &'static str) -> &'static Self {4546 unsafe { &*(ptr::from_ref::<str>(nsid) as *const Self) }···5760 /// # Errors5861 ///5962 /// Returns an error if `nsid` is not a valid NSID.6060- ///6163 pub fn parse<S: AsRef<str> + ?Sized>(nsid: &S) -> Result<&Self, Error> {6264 validate_nsid(nsid.as_ref())?;6365 Ok(Self::new(nsid))
+4-3
crates/gordian-types/src/serde.rs
···1616 //! ```1717 //!1818 //! [with]: https://serde.rs/field-attrs.html#with1919- //!2019 use std::borrow::Cow;21202222- use serde::{Deserializer, Serializer};2121+ use serde::Deserializer;2222+ use serde::Serializer;23232424- use crate::{Did, did::DidVisitor};2424+ use crate::Did;2525+ use crate::did::DidVisitor;25262627 #[allow(clippy::missing_errors_doc)]2728 pub fn deserialize<'a, 'de: 'a, D>(deserializer: D) -> Result<Cow<'a, Did>, D::Error>
+5-17
crates/gordian-types/src/tid.rs
···5454 /// # Panics5555 ///5656 /// Panics if `micros` is greater than [`Self::MAX_TIMESTAMP`].5757- ///5857 pub const fn set_micros(&mut self, micros: u64) {5958 assert!(6059 micros <= Self::MAX_TIMESTAMP,···6768 /// # Panics6869 ///6970 /// Panics if `clock_id` is greater than [`Self::MAX_CLOCK_ID`].7070- ///7171 pub const fn set_clock_id(&mut self, clock_id: u16) {7272 assert!(7373 clock_id <= Self::MAX_CLOCK_ID,···8385 ///8486 /// * `micros` exceeds [`Self::MAX_TIMESTAMP`].8587 /// * `clock_id` exceeds [`Self::MAX_CLOCK_ID`]8686- ///8788 #[must_use]8889 pub const fn new(micros: u64, clock_id: u16) -> Self {8990 let mut new = Self(0);···106109 ///107110 /// Panics under the following conditions:108111 ///109109- /// * `seconds` exceeds [`Self::MAX_TIMESTAMP`] when converted to microseconds.112112+ /// * `seconds` exceeds [`Self::MAX_TIMESTAMP`] when converted to113113+ /// microseconds.110114 /// * `clock_id` exceeds [`Self::MAX_CLOCK_ID`]111111- ///112115 #[must_use]113116 pub const fn from_secs(seconds: u64, clock_id: u16) -> Self {114117 Self::new(seconds * 1_000_000, clock_id)···136139 /// # Errors137140 ///138141 /// Returns an error if `tid` is not a valid TID.139139- ///140142 pub fn parse(tid: &str) -> Result<Self, Error> {141143 parse(tid)142144 }···147151 /// assert_eq!(Tid::MIN.micros(), 0);148152 /// assert_eq!(Tid::MAX.micros(), 9007199254740991);149153 /// ```150150- ///151154 #[must_use]152155 pub const fn micros(&self) -> u64 {153156 (self.0 >> BITS_CLOCK_ID) & Self::MAX_TIMESTAMP···159164 /// assert_eq!(Tid::MIN.clock_id(), 0);160165 /// assert_eq!(Tid::MAX.clock_id(), 1023);161166 /// ```162162- ///163167 #[must_use]164168 pub const fn clock_id(&self) -> u16 {165169 // CONVERSION: Clock ID mask ensures this will always produce an equivalent u16.···175181 /// ```rust176182 /// # use gordian_types::tid::Tid;177183 /// # use time::OffsetDateTime;178178- /// const DT: OffsetDateTime = time::macros::datetime!(2025-11-25 10:28:43.234 UTC);184184+ /// const DT: OffsetDateTime = time::macros::datetime!(2025-11-25 10:28:43.234 UTC);179185 ///180186 /// let mut t = Tid::default();181187 /// t.set_datetime(DT);···191197 /// # Panics192198 ///193199 /// Panics if `dt` is later than 2255-06-05 23:47:34.740991 +00:00:00.194194- ///195200 pub fn set_datetime(&mut self, dt: time::OffsetDateTime) {196201 let micros = dt.unix_timestamp_nanos() / 1000;197202 self.set_micros(micros.try_into().unwrap());···201208 /// # Panics202209 ///203210 /// Panics if `dt` is later than 2255-06-05 23:47:34.740991 +00:00:00.204204- ///205211 #[must_use]206212 pub fn from_datetime(dt: time::OffsetDateTime, clock_id: u16) -> Self {207213 let micros = dt.unix_timestamp_nanos() / 1000;···212220 /// # Panics213221 ///214222 /// *Will* panic if called later than 2255-06-05 23:47:34.740991 +00:00:00.215215- ///216223 #[must_use]217224 pub fn now_utc() -> Self {218225 use time::OffsetDateTime;···220229 }221230222231 /// Convert the TID to a [`time::OffsetDateTime`]223223- ///224232 #[must_use]225233 #[allow(clippy::missing_panics_doc)]226234 pub fn as_datetime(&self) -> time::OffsetDateTime {···299309/// # Errors300310///301311/// Returns an error if `tid` is not a valid TID.302302-///303312pub fn parse(tid: &str) -> Result<Tid, Error> {304313 let bytes = tid.as_bytes();305314 if bytes.len() != 13 {···343354 /// # Panics344355 ///345356 /// Panics if `clock_id` is greater than [`Tid::MAX_CLOCK_ID`].346346- ///347357 #[must_use]348358 pub const fn with_id(clock_id: u16) -> Self {349359 assert!(···360372 ///361373 /// # Panics362374 ///363363- /// Panics if the current date is later than 2255-06-05 23:47:34.740991 +00:00:00.375375+ /// Panics if the current date is later than 2255-06-05 23:47:34.740991376376+ /// +00:00:00.364377 ///365378 /// [`SystemTime::now()`]: std::time::SystemTime366366- ///367379 pub fn next(&self) -> Tid {368380 use std::time::SystemTime;369381
+4-3
crates/gordian-types/src/uri.rs
···11-use crate::{Did, did::DidBuf};11+use crate::Did;22+use crate::did::DidBuf;2334/// A fully defined Atmosphere URI pointing to a record.45///55-/// For example: "at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22"66-///66+/// For example:77+/// "at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22"78#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]89pub struct RecordUri {910 pub authority: DidBuf,