//! Sign-in routes for one OAuth client, for any server embedding atlibs: `login?handle=`, //! `callback`, `logout`, and a confidential client's `client-metadata.json` and //! `jwks.json`. Sessions are saved in `store` per DID (oauth_client::setting), so a store per //! client keeps one client's sessions apart from another's: per composition, `composition_store`. use std::path::Path; use serde_json::{Map, Value, json}; use crate::compose::Composition; use crate::oauth::scope_list; use crate::oauth_client::{self, Client}; use crate::pretty; use crate::registry::Net; use crate::store::Store; pub const SESSION_COOKIE: &str = "atlibs_session"; pub const STATE_COOKIE: &str = "atlibs_oauth_state"; pub fn cookie<'a, B>(req: &'a http::Request, name: &str) -> Option<&'a str> { req.headers().get_all("cookie").iter().filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';')).find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('=')) } /// The session handle a request carries: `Authorization: Bearer `, else the atlibs_session cookie. pub fn handle_of(req: &http::Request) -> Option<&str> { let bearer = req.headers().get("authorization").and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))); bearer.or_else(|| cookie(req, SESSION_COOKIE)).map(str::trim).filter(|h| !h.is_empty()) } pub fn json_reply(status: u16, value: &Value) -> http::Response { http::Response::builder().status(status).header("content-type", "application/json").body(format!("{}\n", pretty(value))).expect("valid response") } /// What a route did: its response, and the DID whose session it replaced or ended (drop any cached one). pub struct Reply { pub response: http::Response, pub changed: Option, } /// One client's sign-in routes under `prefix` ("/oauth/"). pub struct SignIn { pub client: Client, pub store: Store, pub net: Net, pub prefix: String, /// client_name in its metadata document pub name: String, } impl SignIn { fn set_cookie(&self, name: &str, value: &str, max_age: u64) -> String { let secure = if self.client.id.starts_with("https://") { "; Secure" } else { "" }; format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}") } /// A route of this client's, or None (not one of its paths). pub async fn handle(&self, req: &http::Request) -> Option { let route = req.uri().path().strip_prefix(&self.prefix)?; let reply = |response| Some(Reply { response, changed: None }); let fail = |status: u16, e: String| reply(json_reply(status, &json!({ "error": "AuthFailed", "message": e }))); let query: Map = url::form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes()).map(|(k, v)| (k.into_owned(), json!(v))).collect(); let fetch = self.net.fetch.as_ref(); let store = &self.store; match (req.method().as_str(), route) { ("GET", "client-metadata.json") if self.client.key.is_some() => reply(json_reply(200, &self.client.metadata(&self.name))), ("GET", "jwks.json") if self.client.key.is_some() => reply(json_reply(200, &self.client.jwks())), ("GET", "login") => { let Some(who) = query.get("handle").and_then(Value::as_str).map(|h| h.trim().trim_start_matches('@')).filter(|h| !h.is_empty()) else { return fail(400, format!("GET {}login?handle=", self.prefix)); }; let begun = async { let did = if who.starts_with("did:") { who.to_string() } else { self.net.resolve_handle(who).await? }; let pds = self.net.pds(&did).await?; oauth_client::begin(fetch, &self.client, &did, &pds, who).await }; match begun.await { Ok((url, pending)) => { let state = pending["state"].as_str().unwrap_or_default().to_string(); if let Err(e) = store.set_setting(&format!("oauth-state:{state}"), &pending) { return fail(500, e); } reply(http::Response::builder().status(302).header("location", url).header("set-cookie", self.set_cookie(STATE_COOKIE, &state, 600)).body(String::new()).ok()?) } Err(e) => fail(400, e), } } ("GET", "callback") => { let state = query.get("state").and_then(Value::as_str).unwrap_or_default(); // the browser that left must be the one back (its state cookie), and a state is used once if state.is_empty() || cookie(req, STATE_COOKIE) != Some(state) { return fail(403, "this sign-in was not started by this browser".into()); } let name = format!("oauth-state:{state}"); let pending = match store.setting(&name) { Ok(Some(p)) => p, Ok(None) => return fail(403, "no such sign-in (or it was used)".into()), Err(e) => return fail(500, e), }; let _ = store.delete(&format!("{}:settings", store.id), &name); let saved = match oauth_client::complete(fetch, &self.client, &pending, &query).await { Ok(saved) => saved, Err(e) => return fail(403, e), }; let did = saved["did"].as_str().unwrap_or_default().to_string(); match store.set_setting(&oauth_client::setting(&did), &saved).and_then(|_| oauth_client::new_handle(store, &did)) { Ok(handle) => Some(Reply { response: http::Response::builder() .status(303) .header("location", "/") .header("set-cookie", self.set_cookie(SESSION_COOKIE, &handle, 30 * 86400)) .header("set-cookie", self.set_cookie(STATE_COOKIE, "", 0)) .body(String::new()) .ok()?, changed: Some(did), }), Err(e) => fail(500, e), } } ("POST", "logout") => { let Some(handle) = handle_of(req) else { return fail(401, "not signed in".into()) }; let did = match oauth_client::did_of_handle(store, handle) { Ok(Some(did)) => did, Ok(None) => return fail(401, "unknown or expired session".into()), Err(e) => return fail(500, e), }; let revoked = oauth_client::sign_out(fetch, store, &did).await; let _ = oauth_client::drop_handle(store, handle); let body = json!({ "signedOut": did, "revoked": revoked.as_ref().map_or_else(|e| json!(e), |_| json!(true)) }); let response = http::Response::builder().status(200).header("content-type", "application/json").header("set-cookie", self.set_cookie(SESSION_COOKIE, "", 0)).body(format!("{}\n", pretty(&body))).ok()?; Some(Reply { response, changed: Some(did) }) } _ => None, } } } /// The OAuth scope a composition needs: every instance module's session scope, once each, `atproto` first. pub fn scope_of(comp: &Composition) -> String { let mut scopes = vec!["atproto".to_string()]; let mut names: Vec<&String> = comp.modules.keys().collect(); names.sort(); for name in names { if let Some((_, scope)) = &comp.modules[name].scope { for s in scope_list(scope) { if !scopes.iter().any(|have| have == s) { scopes.push(s.to_string()); } } } } scopes.join(" ") } /// Where composition `cid`'s client lives under an origin: `/oauth/composition//`. pub fn composition_prefix(cid: &str) -> String { format!("/oauth/composition/{cid}/") } /// Composition `cid`'s sessions: a store of their own in `state_dir`, one per (DID, composition). pub fn composition_store(state_dir: &Path, cid: &str) -> Result { Store::open(state_dir, &format!("composition:{cid}")) } #[cfg(test)] mod tests { use super::*; use crate::run::Module; use std::collections::HashMap; #[test] fn a_compositions_scope_is_its_modules_scopes_once_each() { let module = |scope: Option<&str>| { let mut m = Module::from_manifest(b"\0asm\x01\0\0\0".to_vec(), &json!({ "name": "m" })).unwrap(); m.scope = scope.map(|s| ("s".to_string(), s.to_string())); m }; let modules = HashMap::from([("a".into(), module(Some("atproto repo:x.y.z"))), ("b".into(), module(Some("atproto rpc:a.b.c?aud=* repo:x.y.z"))), ("c".into(), module(None))]); let comp = Composition { manifest: json!({}), modules }; assert_eq!(scope_of(&comp), "atproto repo:x.y.z rpc:a.b.c?aud=*"); assert_eq!(composition_prefix("bafy1"), "/oauth/composition/bafy1/"); } }