~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
182 lines · 9.1 KB Raw History
//! Sign-in routes for one OAuth client, for any server embedding atlibs: `<prefix>login?handle=`,
//! `<prefix>callback`, `<prefix>logout`, and a confidential client's `<prefix>client-metadata.json` and
//! `<prefix>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<B>, 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 <it>`, else the atlibs_session cookie.
pub fn handle_of<B>(req: &http::Request<B>) -> 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<String> {
    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<String>,
    pub changed: Option<String>,
}

/// 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<B>(&self, req: &http::Request<B>) -> Option<Reply> {
        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<String, Value> = 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=<handle or DID>", 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/<cid>/`.
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, String> {
    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/");
    }
}