Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
8.0 kB · 187 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188//! 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<Vec<u8>>, ) -> Result< atrium_xrpc::http::Response<Vec<u8>>, Box<dyn std::error::Error + Send + Sync + 'static>, > { 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<HttpClient>;pub type HandleResolver = AtprotoHandleResolver<DohDnsTxtResolver<HttpClient>, HttpClient>;pub type Client = OAuthClient<SqliteStateStore, SqliteSessionStore, DidResolver, HandleResolver, HttpClient>;/// One restored session, ready to make authenticated calls to its own PDS.pub type Session = OAuthSession<HttpClient, DidResolver, HandleResolver, SqliteSessionStore>;
/// 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<Scope> { 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<Vec<Jwk>, String> { if let Ok(one) = serde_json::from_str::<Jwk>(jwk) { return Ok(vec![one]); } serde_json::from_str::<Vec<Jwk>>(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<Client, String> { 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<S: SessionStore + Send + Sync + 'static>() {} requires::<SqliteSessionStore>();}