//! Construction of the one concrete OAuthClient, in both shapes. //! //! The shapes differ only in the metadata fed to `OAuthClient::new`: //! loopback (development) encodes its metadata in the client_id itself; //! confidential (production) serves metadata and a JWKS from PUBLIC_URL and //! authenticates to the token endpoint with an ES256 key. use std::sync::Arc; use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}; use atrium_identity::handle::{ AtprotoHandleResolver, AtprotoHandleResolverConfig, DohDnsTxtResolver, DohDnsTxtResolverConfig, }; use atrium_oauth::store::session::SessionStore; use atrium_oauth::{ AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, GrantType, KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, OAuthSession, Scope, }; use jose_jwk::Jwk; use super::store::{SqliteSessionStore, SqliteStateStore}; use crate::config::Config; use crate::db::Db; /// atrium's own DefaultHttpClient means reqwest on native-tls means a system /// OpenSSL; this is the same shim on the rustls build of reqwest instead. #[derive(Clone, Default)] pub struct HttpClient { client: reqwest::Client, } impl atrium_xrpc::HttpClient for HttpClient { async fn send_http( &self, request: atrium_xrpc::http::Request>, ) -> Result< atrium_xrpc::http::Response>, Box, > { let response = self.client.execute(request.try_into()?).await?; let mut builder = atrium_xrpc::http::Response::builder().status(response.status()); for (name, value) in response.headers() { builder = builder.header(name, value); } builder .body(response.bytes().await?.to_vec()) .map_err(Into::into) } } // Handle lookups go to Cloudflare's DoH endpoint: atrium's only // batteries-included DnsTxtResolver is DNS-over-HTTPS, and it behaves the // same in a container as on a laptop. Swapping in a system-DNS resolver later // is a change confined to this module. const DOH_SERVICE_URL: &str = "https://mozilla.cloudflare-dns.com/dns-query"; pub type DidResolver = CommonDidResolver; pub type HandleResolver = AtprotoHandleResolver, HttpClient>; pub type Client = OAuthClient; /// One restored session, ready to make authenticated calls to its own PDS. pub type Session = OAuthSession; /// What sign-in asks the player for. /// /// `atproto` identifies the account and grants nothing on its own. Every /// collection this service writes is then named in full: atproto has no /// partial wildcard, so `repo:blue.lance.*` cannot be asked for, and each new /// record type adds a term here and a re-consent for everyone who signed in /// before it. /// /// `blob:image/png` is separate from the record write and not implied by it. /// Without it `uploadBlob` is refused while `createRecord` would have been /// allowed, which fails halfway through saving a camo rather than at the /// consent screen. /// /// `include:app.userinput.authBasic` is userinput.app's own published /// permission set (discussions, replies, votes, edits — not the moderation /// collections in its `authFull`). It lets a player launch a flare: a /// feedback post written to their repo, aimed at the lance.blue board on /// userinput.app. The consent screen resolves the set and shows its title /// and detail, which is the treatment `repo:blue.lance.camo` will get once /// `blue.lance.authPlayer` is published (lexicons TODO/10-flare.md). /// /// This list has to reach two places. The client metadata is what the /// authorization server is told the client may ask for; `AuthorizeOptions` is /// what a particular request actually asks for, and atrium's default for that /// is bare `atproto` regardless of the metadata. Both come from here. pub fn scopes() -> Vec { vec![ Scope::Known(KnownScope::Atproto), Scope::Unknown(format!("repo:{}", super::CAMO_NSID)), Scope::Unknown("blob:image/png".to_owned()), Scope::Unknown(super::FLARE_SCOPE.to_owned()), ] } pub fn did_resolver(http: &HttpClient) -> DidResolver { CommonDidResolver::new(CommonDidResolverConfig { plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_owned(), http_client: Arc::new(http.clone()), }) } pub fn handle_resolver(http: &HttpClient) -> HandleResolver { AtprotoHandleResolver::new(AtprotoHandleResolverConfig { dns_txt_resolver: DohDnsTxtResolver::new(DohDnsTxtResolverConfig { service_url: DOH_SERVICE_URL.to_owned(), http_client: Arc::new(http.clone()), }), http_client: Arc::new(http.clone()), }) } /// `PRIVATE_KEY_JWK` accepts one JWK object or an array, so a rotation can /// publish a new key in the JWKS before the old one stops signing (plan/complete/sign-in.md). /// Every key must be an EC private key with a `kid` — atrium's Keyset rejects /// anything else at construction. fn private_keys(jwk: &str) -> Result, String> { if let Ok(one) = serde_json::from_str::(jwk) { return Ok(vec![one]); } serde_json::from_str::>(jwk) .map_err(|_| "PRIVATE_KEY_JWK is neither a JWK object nor an array of JWKs".to_owned()) } pub fn build(config: &Config, db: Db, http: &HttpClient) -> Result { let scopes = scopes(); let resolver = OAuthResolverConfig { did_resolver: did_resolver(http), handle_resolver: handle_resolver(http), authorization_server_metadata: Default::default(), protected_resource_metadata: Default::default(), }; let state_store = SqliteStateStore(db.clone()); let session_store = SqliteSessionStore(db); let client = match &config.public_url { None => OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoLocalhostClientMetadata { redirect_uris: Some(vec![config.redirect_uri()]), scopes: Some(scopes), }, keys: None, state_store, session_store, resolver, http_client: http.clone(), }), Some(url) => { let keys = private_keys( config .private_key_jwk .as_deref() .expect("config validated: key iff public"), )?; OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoClientMetadata { client_id: format!("{url}/oauth/client-metadata.json"), client_uri: None, redirect_uris: vec![config.redirect_uri()], token_endpoint_auth_method: AuthMethod::PrivateKeyJwt, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], scopes, jwks_uri: Some(format!("{url}/.well-known/jwks.json")), token_endpoint_auth_signing_alg: Some("ES256".to_owned()), }, keys: Some(keys), state_store, session_store, resolver, http_client: http.clone(), }) } }; // The error's Display can only name what was wrong with our own // configuration; no runtime secret flows through construction messages. client.map_err(|e| format!("oauth client construction: {e}")) } // Satisfy the SessionStore bound's error requirement explicitly, so a future // atrium bump that changes the bound fails here with a readable error. fn _assert_bounds() { fn requires() {} requires::(); }