Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

look up identity from did-cookie

+161
Cargo.lock
···
]
[[package]]
+
name = "axum-template"
+
version = "3.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3df50f7d669bfc3a8c348f08f536fe37e7acfbeded3cfdffd2ad3d76725fc40c"
+
dependencies = [
+
"axum",
+
"handlebars",
+
"serde",
+
"thiserror 2.0.12",
+
]
+
+
[[package]]
name = "backtrace"
version = "0.3.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "derive_builder"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+
dependencies = [
+
"derive_builder_macro",
+
]
+
+
[[package]]
+
name = "derive_builder_core"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+
dependencies = [
+
"darling",
+
"proc-macro2",
+
"quote",
+
"syn",
+
]
+
+
[[package]]
+
name = "derive_builder_macro"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+
dependencies = [
+
"derive_builder_core",
+
"syn",
+
]
+
+
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"tokio",
"tokio-util",
"tracing",
+
]
+
+
[[package]]
+
name = "handlebars"
+
version = "6.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098"
+
dependencies = [
+
"derive_builder",
+
"log",
+
"num-order",
+
"pest",
+
"pest_derive",
+
"serde",
+
"serde_json",
+
"thiserror 2.0.12",
+
"walkdir",
[[package]]
···
[[package]]
+
name = "num-modular"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f"
+
+
[[package]]
+
name = "num-order"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
+
dependencies = [
+
"num-modular",
+
]
+
+
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
+
name = "pest"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323"
+
dependencies = [
+
"memchr",
+
"thiserror 2.0.12",
+
"ucd-trie",
+
]
+
+
[[package]]
+
name = "pest_derive"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc"
+
dependencies = [
+
"pest",
+
"pest_generator",
+
]
+
+
[[package]]
+
name = "pest_generator"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966"
+
dependencies = [
+
"pest",
+
"pest_meta",
+
"proc-macro2",
+
"quote",
+
"syn",
+
]
+
+
[[package]]
+
name = "pest_meta"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5"
+
dependencies = [
+
"pest",
+
"sha2",
+
]
+
+
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
+
name = "same-file"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+
dependencies = [
+
"winapi-util",
+
]
+
+
[[package]]
name = "schannel"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
+
name = "ucd-trie"
+
version = "0.1.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+
[[package]]
name = "ufos"
version = "0.1.0"
dependencies = [
···
[[package]]
+
name = "walkdir"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+
dependencies = [
+
"same-file",
+
"winapi-util",
+
]
+
+
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.1.0"
dependencies = [
"atrium-api 0.25.4",
+
"atrium-common 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
"atrium-identity",
"atrium-oauth",
"axum",
"axum-extra",
+
"axum-template",
"clap",
+
"ctrlc",
+
"dashmap",
+
"handlebars",
"hickory-resolver",
"metrics",
+
"rand 0.9.1",
"serde",
+
"serde_json",
"tokio",
"tokio-util",
+
"url",
[[package]]
···
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+
[[package]]
+
name = "winapi-util"
+
version = "0.1.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+
dependencies = [
+
"windows-sys 0.48.0",
+
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
+8
who-am-i/Cargo.toml
···
[dependencies]
atrium-api = { version = "0.25.4", default-features = false }
+
atrium-common = "0.1.2"
atrium-identity = "0.1.5"
atrium-oauth = "0.1.3"
axum = "0.8.4"
axum-extra = { version = "0.10.1", features = ["cookie-signed", "typed-header"] }
+
axum-template = { version = "3.0.0", features = ["handlebars"] }
clap = { version = "4.5.40", features = ["derive"] }
+
ctrlc = "3.4.7"
+
dashmap = "6.1.0"
+
handlebars = { version = "6.3.2", features = ["dir_source"] }
hickory-resolver = "0.25.2"
metrics = "0.24.2"
+
rand = "0.9.1"
serde = { version = "1.0.219", features = ["derive"] }
+
serde_json = "1.0.140"
tokio = { version = "1.45.1", features = ["full", "macros"] }
tokio-util = "0.7.15"
+
url = "2.5.4"
+1 -3
who-am-i/demo/index.html
···
<h1>hey</h1>
-
<iframe src="http://127.0.0.1:9997/prompt" style="border: 2px solid #000; border-radius: 0.5em;" />
-
-
+
<iframe src="http://127.0.0.1:9997/prompt" style="border: none" height="140" width="280" />
+53
who-am-i/src/expiring_task_map.rs
···
+
use dashmap::DashMap;
+
use rand::{Rng, distr::Alphanumeric};
+
use std::sync::Arc;
+
use std::time::Duration;
+
use tokio::task::{JoinHandle, spawn};
+
use tokio::time::sleep; // 0.8
+
+
#[derive(Clone)]
+
pub struct ExpiringTaskMap<T>(Arc<TaskMap<T>>);
+
+
impl<T: Send + 'static> ExpiringTaskMap<T> {
+
pub fn new(expiration: Duration) -> Self {
+
let map = TaskMap {
+
map: DashMap::new(),
+
expiration,
+
};
+
Self(Arc::new(map))
+
}
+
+
pub fn dispatch(&self, task: impl Future<Output = T> + Send + 'static) -> String {
+
let task_key: String = rand::rng()
+
.sample_iter(&Alphanumeric)
+
.take(24)
+
.map(char::from)
+
.collect();
+
+
// spawn a tokio task and put the join handle in the map for later retrieval
+
self.0.map.insert(task_key.clone(), spawn(task));
+
+
// spawn a second task to clean up the map in case it doesn't get claimed
+
spawn({
+
let me = self.0.clone();
+
let key = task_key.clone();
+
async move {
+
sleep(me.expiration).await;
+
let _ = me.map.remove(&key);
+
// TODO: also use a cancellation token so taking and expiring can mutually cancel
+
}
+
});
+
+
task_key
+
}
+
+
pub fn take(&self, key: &str) -> Option<JoinHandle<T>> {
+
eprintln!("trying to take...");
+
self.0.map.remove(key).map(|(_, handle)| handle)
+
}
+
}
+
+
struct TaskMap<T> {
+
map: DashMap<String, JoinHandle<T>>,
+
expiration: Duration,
+
}
+20
who-am-i/src/identity_resolver.rs
···
+
use atrium_api::types::string::Did;
+
use atrium_common::resolver::Resolver;
+
use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL};
+
use atrium_oauth::DefaultHttpClient;
+
use std::sync::Arc;
+
+
pub async fn resolve_identity(did: String) -> String {
+
let http_client = Arc::new(DefaultHttpClient::default());
+
let resolver = CommonDidResolver::new(CommonDidResolverConfig {
+
plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
+
http_client: Arc::clone(&http_client),
+
});
+
let doc = resolver.resolve(&Did::new(did).unwrap()).await.unwrap(); // TODO: this is only half the resolution? or is atrium checking dns?
+
if let Some(aka) = doc.also_known_as {
+
if let Some(f) = aka.first() {
+
return f.to_string();
+
}
+
}
+
"who knows".to_string()
+
}
+4
who-am-i/src/lib.rs
···
mod dns_resolver;
+
mod expiring_task_map;
+
mod identity_resolver;
mod oauth;
mod server;
pub use dns_resolver::HickoryDnsTxtResolver;
+
pub use expiring_task_map::ExpiringTaskMap;
+
pub use identity_resolver::resolve_identity;
pub use oauth::{Client, authorize, client};
pub use server::serve;
+27 -2
who-am-i/src/main.rs
···
+
use clap::Parser;
use tokio_util::sync::CancellationToken;
use who_am_i::serve;
+
/// Aggregate links in the at-mosphere
+
#[derive(Parser, Debug, Clone)]
+
#[command(version, about, long_about = None)]
+
struct Args {
+
/// secret key from which the cookie-signing key is derived
+
///
+
/// must have at least 512 bits (64 bytes) of randomness
+
///
+
/// eg: `cat /dev/urandom | head -c 64 | base64`
+
#[arg(long)]
+
app_secret: String,
+
/// Enable dev mode
+
///
+
/// enables automatic template reloading
+
#[arg(long, action)]
+
dev: bool,
+
}
+
#[tokio::main]
async fn main() {
-
let server_shutdown = CancellationToken::new();
-
serve(server_shutdown).await;
+
let shutdown = CancellationToken::new();
+
+
let ctrlc_shutdown = shutdown.clone();
+
ctrlc::set_handler(move || ctrlc_shutdown.cancel()).expect("failed to set ctrl-c handler");
+
+
let args = Args::parse();
+
+
serve(shutdown, args.app_secret, args.dev).await;
}
+98 -20
who-am-i/src/server.rs
···
use axum::{
Router,
extract::{FromRef, Query, State},
+
http::header::{HeaderMap, REFERER},
response::{Html, IntoResponse, Redirect},
routing::get,
};
use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar};
+
use axum_template::{RenderHtml, engine::Engine};
+
use handlebars::{Handlebars, handlebars_helper};
-
use serde::Deserialize;
+
use serde::{Deserialize, Serialize};
+
use serde_json::Value;
use std::sync::Arc;
+
use std::time::Duration;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
+
use url::Url;
-
use crate::{Client, authorize, client};
+
use crate::{Client, ExpiringTaskMap, authorize, client, resolve_identity};
const FAVICON: &[u8] = include_bytes!("../static/favicon.ico");
const INDEX_HTML: &str = include_str!("../static/index.html");
const LOGIN_HTML: &str = include_str!("../static/login.html");
-
pub async fn serve(shutdown: CancellationToken) {
+
const DID_COOKIE_KEY: &str = "did";
+
+
type AppEngine = Engine<Handlebars<'static>>;
+
+
#[derive(Clone)]
+
struct AppState {
+
pub key: Key,
+
pub engine: AppEngine,
+
pub client: Arc<Client>,
+
pub resolving: ExpiringTaskMap<String>,
+
}
+
+
impl FromRef<AppState> for Key {
+
fn from_ref(state: &AppState) -> Self {
+
state.key.clone()
+
}
+
}
+
+
pub async fn serve(shutdown: CancellationToken, app_secret: String, dev: bool) {
+
let mut hbs = Handlebars::new();
+
hbs.set_dev_mode(dev);
+
hbs.register_templates_directory("templates", Default::default())
+
.unwrap();
+
+
handlebars_helper!(json: |v: Value| serde_json::to_string(&v).unwrap());
+
hbs.register_helper("json", Box::new(json));
+
+
// clients have to pick up their identity-resolving tasks within this period
+
let task_pickup_expiration = Duration::from_secs(15);
+
let state = AppState {
-
key: Key::generate(), // TODO: via config
+
engine: Engine::new(hbs),
+
key: Key::from(app_secret.as_bytes()), // TODO: via config
client: Arc::new(client()),
+
resolving: ExpiringTaskMap::new(task_pickup_expiration),
};
let app = Router::new()
.route("/", get(|| async { Html(INDEX_HTML) }))
.route("/favicon.ico", get(|| async { FAVICON })) // todo MIME
.route("/prompt", get(prompt))
+
.route("/user-info", get(user_info))
.route("/auth", get(start_oauth))
.route("/authorized", get(complete_oauth))
.with_state(state);
···
.unwrap();
}
-
#[derive(Clone)]
-
struct AppState {
-
pub key: Key,
-
pub client: Arc<Client>,
+
#[derive(Debug, Serialize)]
+
struct Known {
+
did: Value,
+
fetch_key: Value,
+
parent_host: String,
}
+
async fn prompt(
+
State(AppState {
+
engine, resolving, ..
+
}): State<AppState>,
+
jar: SignedCookieJar,
+
headers: HeaderMap,
+
) -> impl IntoResponse {
+
let Some(referrer) = headers.get(REFERER) else {
+
return Html::<&'static str>("missing referrer, sorry").into_response();
+
};
+
let Ok(referrer) = referrer.to_str() else {
+
return "referer contained opaque bytes".into_response();
+
};
+
let Ok(url) = Url::parse(referrer) else {
+
return "referrer was not a url".into_response();
+
};
+
let Some(parent_host) = url.host_str() else {
+
return "could nto get host from url".into_response();
+
};
+
let m = if let Some(did) = jar.get(DID_COOKIE_KEY) {
+
let did = did.value_trimmed().to_string();
-
impl FromRef<AppState> for Key {
-
fn from_ref(state: &AppState) -> Self {
-
state.key.clone()
-
}
+
let fetch_key = resolving.dispatch(resolve_identity(did.clone()));
+
+
let json_did = Value::String(did);
+
let json_fetch_key = Value::String(fetch_key);
+
let known = Known {
+
did: json_did,
+
fetch_key: json_fetch_key,
+
parent_host: parent_host.to_string(),
+
};
+
return (jar, RenderHtml("prompt-known", engine, known)).into_response();
+
} else {
+
LOGIN_HTML.into_response()
+
};
+
(jar, Html(m)).into_response()
}
-
async fn prompt(jar: SignedCookieJar) -> impl IntoResponse {
-
let m = if let Some(did) = jar.get("did") {
-
format!("oh i know you: {did}")
-
} else {
-
LOGIN_HTML.into()
+
#[derive(Debug, Deserialize)]
+
#[serde(rename_all = "kebab-case")]
+
struct UserInfoParams {
+
fetch_key: String,
+
}
+
async fn user_info(
+
State(AppState { resolving, .. }): State<AppState>,
+
Query(params): Query<UserInfoParams>,
+
) -> impl IntoResponse {
+
// let fetch_key: [char; 16] = params.fetch_key.chars().collect::<Vec<_>>().try_into().unwrap();
+
let Some(handle) = resolving.take(&params.fetch_key) else {
+
return "oops, task does not exist or is gone".into_response();
};
-
(jar, Html(m))
+
let s = handle.await.unwrap();
+
format!("sup: {s}").into_response()
}
#[derive(Debug, Deserialize)]
···
jar: SignedCookieJar,
) -> (SignedCookieJar, Redirect) {
// if any existing session was active, clear it first
-
let jar = jar.remove("did");
+
let jar = jar.remove(DID_COOKIE_KEY);
let auth_url = authorize(&state.client, &params.handle).await;
(jar, Redirect::to(&auth_url))
···
panic!("failed to do client callback");
};
let did = oauth_session.did().await.expect("a did to be present");
-
let cookie = Cookie::build(("did", did.to_string()))
+
let cookie = Cookie::build((DID_COOKIE_KEY, did.to_string()))
.http_only(true)
.secure(true)
.same_site(SameSite::None)
+96
who-am-i/templates/prompt-known.hbs
···
+
<!doctype html>
+
+
<style>
+
body {
+
color: #434;
+
font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif;
+
margin: 0;
+
min-height: 100vh;
+
padding: 0;
+
}
+
.wrap {
+
border: 2px solid #221828;
+
border-radius: 0.5rem;
+
box-sizing: border-box;
+
overflow: hidden;
+
display: flex;
+
flex-direction: column;
+
height: 100vh;
+
}
+
header {
+
background: #221828;
+
display: flex;
+
justify-content: space-between;
+
padding: 0 0.25rem;
+
color: #c9b;
+
display: flex;
+
gap: 0.5rem;
+
align-items: baseline;
+
}
+
header > * {
+
flex-basis: 33%;
+
}
+
header > .title {
+
text-align: center;
+
}
+
header > a.micro {
+
text-decoration: none;
+
font-size: 0.8rem;
+
text-align: right;
+
opacity: 0.5;
+
}
+
header > a.micro:hover {
+
opacity: 1;
+
}
+
main {
+
padding: 0.25rem 0.5rem;
+
background: #ccc;
+
flex-grow: 1;
+
}
+
p {
+
margin: 0.5rem 0;
+
}
+
</style>
+
+
<div class="wrap">
+
<header>
+
<div class="empty"></div>
+
<code class="title" style="font-family: monospace;"
+
>who-am-i</code>
+
<a href="https://microcosm.blue" target="_blank" class="micro"
+
><span style="color: #f396a9">m</span
+
><span style="color: #f49c5c">i</span
+
><span style="color: #c7b04c">c</span
+
><span style="color: #92be4c">r</span
+
><span style="color: #4ec688">o</span
+
><span style="color: #51c2b6">c</span
+
><span style="color: #54bed7">o</span
+
><span style="color: #8fb1f1">s</span
+
><span style="color: #ce9df1">m</span
+
></a>
+
</header>
+
+
<main>
+
<p>Share your identity with {{ parent_host }}?</p>
+
<div id="user-info">Loading&hellip;</div>
+
</main>
+
</div>
+
+
+
<script>
+
const infoEl = document.getElementById('user-info');
+
var DID = {{{json did}}};
+
let user_info = new URL('/user-info', window.location);
+
user_info.searchParams.set('fetch-key', {{{json fetch_key}}});
+
fetch(user_info).then(
+
info => {
+
infoEl.textContent = 'yay';
+
console.log(info);
+
},
+
err => {
+
infoEl.textContent = 'ohno';
+
console.error(err);
+
},
+
);
+
+
</script>