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

Configure Feed

Select the types of activity you want to include in your feed.

wip (more modest goals)

+314 -40
+46
who-am-i/readme.md
··· 1 + # who am i 2 + 3 + a little auth service for microcosm demos 4 + 5 + **you probably SHOULD NOT USE THIS in any serious environment** 6 + 7 + for now the deployment is restricted to microcosm -- expanding it for wider use likely requires solving a number of challenges that oauth exists for. 8 + 9 + 10 + ## a little auth service 11 + 12 + - you drop an iframe and a short few lines of JS on your web page, and get a nice-ish atproto login prompt. 13 + - if the user has ever authorized this service before (and within some expiration), they will be presented with an in-frame one-click option to proceed. 14 + - otherwise they get bounced over to the normal atproto oauth flow (in a popup or new tab) 15 + - you get a callback containing 16 + - a verified DID and handle 17 + - a JWT containing the same that can be verified by public key 18 + - **no write permissions** or any atproto permissions at all, just a verified identity 19 + 20 + **you probably SHOULD NOT USE THIS in any serious environment** 21 + 22 + 23 + ### problems 24 + 25 + - clickjacking: if this were allowed on arbitrary domains, malicious sites could trick users into proving their atproto identity. 26 + - all the other problems oauth exists to solve: it's a little tricky to hook around the oauth flow so there are probably some annoying attacks. 27 + - auth in front of auth: it's just a bit awkward to run an auth service that acts as an intermediary for a more-real auth behind it, but that's worse, less secure, and doesn't conform to any standards. 28 + 29 + so, **you probably SHOULD NOT USE THIS in any serious environment** 30 + 31 + 32 + ## why 33 + 34 + sometimes you want to make a thing that people can use with an atproto identity, and you might not want to let them put in any else's identity. apps that operate on public data like skircle, cred.blue, and the microcosm spacedust notifications demo don't require any special permission to operate for any user, and that's sometimes fine, but sometimes creepy/stalker-y/etc. 35 + 36 + to avoid building a small torment nexus for a microcosm demo (while also not wanting to get deep into oauth or operate a demo-specific auth backend), i made this little service to just get a verified identity. 37 + 38 + note: **you probably SHOULD NOT USE THIS in any serious environment** 39 + 40 + --- 41 + 42 + since the requirements (read-only, just verifying identity) seem modest, i was hoping that a fairly simple implementation could be Good Enough, but in the time that i was willing to spend on it, the simple version without major obvious weaknesses i was hoping for didn't emerge. 43 + 44 + it's still nice to have an explicit opt-in on a per-demo basis for microcosm so it will be used for that. it's allow-listed for the microcosm domain however (so not deployed on any adversarial hosting pages), so it's simultaenously overkill and restrictive. 45 + 46 + i will get back to oauth eventually and hopefully roll out a microcosm service to make it easy for clients, but there are a few more things in the pipeline to get to first.
+1
who-am-i/src/expiring_task_map.rs
··· 49 49 .run_until_cancelled(sleep(expiration)) 50 50 .await 51 51 .is_some() 52 + // is Some if the (sleep) task completed first 52 53 { 53 54 map.remove(&k); 54 55 cancel.cancel();
+6 -6
who-am-i/src/main.rs
··· 21 21 /// Hosts who are allowed to one-click auth 22 22 /// 23 23 /// Pass this argument multiple times to allow multiple hosts 24 - #[arg(long, short = 'o', action = ArgAction::Append)] 25 - one_click: Vec<String>, 24 + #[arg(long = "allow_host", short = 'a', action = ArgAction::Append)] 25 + allowed_hosts: Vec<String>, 26 26 } 27 27 28 28 #[tokio::main] ··· 34 34 35 35 let args = Args::parse(); 36 36 37 - if args.one_click.is_empty() { 37 + if args.allowed_hosts.is_empty() { 38 38 panic!("at least one --one-click host must be set"); 39 39 } 40 40 41 - println!("starting with allowed hosts:"); 42 - for host in &args.one_click { 41 + println!("starting with allowed_hosts hosts:"); 42 + for host in &args.allowed_hosts { 43 43 println!(" - {host}"); 44 44 } 45 45 46 - serve(shutdown, args.app_secret, args.one_click, args.dev).await; 46 + serve(shutdown, args.app_secret, args.allowed_hosts, args.dev).await; 47 47 }
+57 -24
who-am-i/src/server.rs
··· 14 14 use handlebars::{Handlebars, handlebars_helper}; 15 15 16 16 use serde::Deserialize; 17 - use serde_json::json; 17 + use serde_json::{Value, json}; 18 18 use std::collections::HashSet; 19 19 use std::sync::Arc; 20 20 use std::time::Duration; ··· 34 34 #[derive(Clone)] 35 35 struct AppState { 36 36 pub key: Key, 37 - pub one_clicks: Arc<HashSet<String>>, 37 + pub allowed_hosts: Arc<HashSet<String>>, 38 38 pub engine: AppEngine, 39 39 pub oauth: Arc<OAuth>, 40 - pub resolving: ExpiringTaskMap<Result<String, ResolveHandleError>>, 40 + pub resolve_handles: ExpiringTaskMap<Result<String, ResolveHandleError>>, 41 41 pub shutdown: CancellationToken, 42 42 } 43 43 ··· 50 50 pub async fn serve( 51 51 shutdown: CancellationToken, 52 52 app_secret: String, 53 - one_click: Vec<String>, 53 + allowed_hosts: Vec<String>, 54 54 dev: bool, 55 55 ) { 56 56 let mut hbs = Handlebars::new(); ··· 58 58 hbs.register_templates_directory("templates", Default::default()) 59 59 .unwrap(); 60 60 61 - handlebars_helper!(json: |v: String| serde_json::to_string(&v).unwrap()); 61 + handlebars_helper!(json: |v: Value| serde_json::to_string(&v).unwrap()); 62 62 hbs.register_helper("json", Box::new(json)); 63 63 64 64 // clients have to pick up their identity-resolving tasks within this period ··· 69 69 let state = AppState { 70 70 engine: Engine::new(hbs), 71 71 key: Key::from(app_secret.as_bytes()), // TODO: via config 72 - one_clicks: Arc::new(HashSet::from_iter(one_click)), 72 + allowed_hosts: Arc::new(HashSet::from_iter(allowed_hosts)), 73 73 oauth: Arc::new(oauth), 74 - resolving: ExpiringTaskMap::new(task_pickup_expiration), 74 + resolve_handles: ExpiringTaskMap::new(task_pickup_expiration), 75 75 shutdown: shutdown.clone(), 76 76 }; 77 77 ··· 96 96 97 97 async fn prompt( 98 98 State(AppState { 99 - one_clicks, 99 + allowed_hosts, 100 100 engine, 101 101 oauth, 102 - resolving, 102 + resolve_handles, 103 103 shutdown, 104 104 .. 105 105 }): State<AppState>, ··· 118 118 let Some(parent_host) = url.host_str() else { 119 119 return "could nto get host from url".into_response(); 120 120 }; 121 - if !one_clicks.contains(parent_host) { 122 - return format!("host {parent_host:?} not in one_clicks, disallowing for now") 121 + if !allowed_hosts.contains(parent_host) { 122 + return format!("host {parent_host:?} not in allowed_hosts, disallowing for now") 123 123 .into_response(); 124 124 } 125 125 if let Some(did) = jar.get(DID_COOKIE_KEY) { ··· 127 127 return "did from cookie failed to parse".into_response(); 128 128 }; 129 129 130 - let fetch_key = resolving.dispatch( 130 + let fetch_key = resolve_handles.dispatch( 131 131 { 132 132 let oauth = oauth.clone(); 133 133 let did = did.clone(); ··· 164 164 fetch_key: String, 165 165 } 166 166 async fn user_info( 167 - State(AppState { resolving, .. }): State<AppState>, 167 + State(AppState { 168 + resolve_handles, .. 169 + }): State<AppState>, 168 170 Query(params): Query<UserInfoParams>, 169 171 ) -> impl IntoResponse { 170 - let Some(task_handle) = resolving.take(&params.fetch_key) else { 172 + let Some(task_handle) = resolve_handles.take(&params.fetch_key) else { 171 173 return "oops, task does not exist or is gone".into_response(); 172 174 }; 173 175 if let Ok(handle) = task_handle.await.unwrap() { ··· 180 182 #[derive(Debug, Deserialize)] 181 183 struct BeginOauthParams { 182 184 handle: String, 185 + flow: String, 183 186 } 184 187 async fn start_oauth( 185 188 State(AppState { oauth, .. }): State<AppState>, 186 189 Query(params): Query<BeginOauthParams>, 187 190 jar: SignedCookieJar, 191 + headers: HeaderMap, 188 192 ) -> (SignedCookieJar, Redirect) { 189 193 // if any existing session was active, clear it first 190 194 let jar = jar.remove(DID_COOKIE_KEY); 191 195 196 + if let Some(referrer) = headers.get(REFERER) { 197 + if let Ok(referrer) = referrer.to_str() { 198 + println!("referrer: {referrer}"); 199 + } else { 200 + eprintln!("referer contained opaque bytes"); 201 + }; 202 + } else { 203 + eprintln!("no referrer"); 204 + }; 205 + 192 206 let auth_url = oauth.begin(&params.handle).await.unwrap(); 207 + let flow = params.flow; 208 + if !flow.chars().all(|c| char::is_ascii_alphanumeric(&c)) { 209 + panic!("invalid flow (injection attempt?)"); // should probably just url-encode it instead.. 210 + } 211 + eprintln!("auth_url {auth_url}"); 212 + 193 213 (jar, Redirect::to(&auth_url)) 194 214 } 195 215 196 216 impl OAuthCompleteError { 197 217 fn to_error_response(&self, engine: AppEngine) -> Response { 198 - let (_level, _desc) = match self { 199 - OAuthCompleteError::Denied { .. } => { 200 - let status = StatusCode::FORBIDDEN; 201 - return (status, RenderHtml("auth-fail", engine, json!({}))).into_response(); 218 + let (level, desc) = match self { 219 + OAuthCompleteError::Denied { description, .. } => { 220 + ("warn", format!("asdf: {description:?}")) 202 221 } 203 222 OAuthCompleteError::Failed { .. } => ( 204 223 "error", 205 - "Something went wrong while requesting permission, sorry!", 224 + "Something went wrong while requesting permission, sorry!".to_string(), 206 225 ), 207 226 OAuthCompleteError::CallbackFailed(_) => ( 208 227 "error", 209 - "Something went wrong after permission was granted, sorry!", 228 + "Something went wrong after permission was granted, sorry!".to_string(), 210 229 ), 211 230 OAuthCompleteError::NoDid => ( 212 231 "error", 213 - "Something went wrong when trying to confirm your identity, sorry!", 232 + "Something went wrong when trying to confirm your identity, sorry!".to_string(), 214 233 ), 215 234 }; 216 - todo!(); 235 + ( 236 + if level == "warn" { 237 + StatusCode::FORBIDDEN 238 + } else { 239 + StatusCode::INTERNAL_SERVER_ERROR 240 + }, 241 + RenderHtml( 242 + "auth-fail", 243 + engine, 244 + json!({ 245 + "reason": desc, 246 + }), 247 + ), 248 + ) 249 + .into_response() 217 250 } 218 251 } 219 252 220 253 async fn complete_oauth( 221 254 State(AppState { 222 255 engine, 223 - resolving, 256 + resolve_handles, 224 257 oauth, 225 258 shutdown, 226 259 .. ··· 241 274 242 275 let jar = jar.add(cookie); 243 276 244 - let fetch_key = resolving.dispatch( 277 + let fetch_key = resolve_handles.dispatch( 245 278 { 246 279 let oauth = oauth.clone(); 247 280 let did = did.clone();
+11 -5
who-am-i/templates/auth-fail.hbs
··· 1 1 {{#*inline "main"}} 2 2 <p> 3 - Share your identity with 4 - <span class="parent-host">{{ parent_host }}</span>? 3 + Auth failed: {{ reason }} 5 4 </p> 6 5 7 6 <div id="user-info"> 8 - <div id="action"> 9 7 auth failed. 10 - </form> 11 8 </div> 12 9 10 + <script> 11 + // TODO: tie this back to its source........... 12 + 13 + localStorage.setItem("who-am-i", JSON.stringify({ 14 + result: "fail", 15 + reason: "alskfjlaskdjf", 16 + })); 17 + window.close(); 18 + </script> 13 19 {{/inline}} 14 20 15 - {{#> prompt-base}}{{/prompt-base}} 21 + {{#> return-base}}{{/return-base}}
+1
who-am-i/templates/authorized.hbs
··· 6 6 // TODO: tie this back to its source........... 7 7 8 8 localStorage.setItem("who-am-i", JSON.stringify({ 9 + result: "success", 9 10 did: {{{json did}}}, 10 11 fetch_key: {{{json fetch_key}}}, 11 12 }));
+21 -4
who-am-i/templates/prompt-anon.hbs
··· 1 1 {{#*inline "main"}} 2 2 <p> 3 - Share your identity with 4 - <span class="parent-host">{{ parent_host }}</span>? 3 + Connect your ATmosphere 4 + </p> 5 + 6 + <p class="detail"> 7 + <span class="parent-host">{{ parent_host }}</span> would like to confirm your handle 5 8 </p> 6 9 7 10 <div id="loader" class="hidden"> ··· 23 26 const formEl = document.getElementById('action'); 24 27 const handleEl = document.getElementById('handle'); 25 28 29 + function err(msg) { 30 + 31 + } 32 + 26 33 formEl.onsubmit = e => { 27 34 e.preventDefault(); 28 35 // TODO: include expected referer! (..this system is probably bad) 29 36 // maybe a random localstorage key that we specifically listen for? 30 37 var url = new URL('/auth', window.location); 31 38 url.searchParams.set('handle', handleEl.value); 39 + url.searchParams.set('flow', {{{json flow}}}); 32 40 var flow = window.open(url, '_blank'); 33 41 window.f = flow; 34 42 ··· 37 45 if (!details) { 38 46 console.error("hmm, heard from localstorage but did not get DID"); 39 47 } 40 - var parsed = JSON.parse(details); 48 + loaderEl.classList.remove('hidden'); 49 + 50 + try { 51 + var parsed = JSON.parse(details); 52 + } catch (e) { 53 + return err("something went wrong getting the details back"); 54 + } 55 + 56 + if (parsed.result === "fail") { 57 + return err(`something went wrong getting permission to share: ${parsed.reason}`); 58 + } 41 59 42 60 infoEl.classList.add('hidden'); 43 - loaderEl.classList.remove('hidden'); 44 61 lookUpAndShare(parsed.fetch_key); 45 62 }); 46 63 }
+3 -1
who-am-i/templates/prompt-base.hbs
··· 57 57 margin: 1rem 0 0; 58 58 text-align: center; 59 59 } 60 + p.detail { 61 + font-size: 0.8rem; 62 + } 60 63 .parent-host { 61 64 font-weight: bold; 62 65 color: #48c; ··· 162 165 {{> main}} 163 166 </main> 164 167 </div> 165 -
+168
who-am-i/templates/return-base.hbs
··· 1 + <!doctype html> 2 + 3 + <style> 4 + body { 5 + color: #434; 6 + font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif; 7 + margin: 0; 8 + min-height: 100vh; 9 + padding: 0; 10 + } 11 + .wrap { 12 + border: 2px solid #221828; 13 + border-radius: 0.5rem; 14 + box-sizing: border-box; 15 + overflow: hidden; 16 + display: flex; 17 + flex-direction: column; 18 + height: 100vh; 19 + } 20 + .wrap.unframed { 21 + border-radius: 0; 22 + border-width: 0.4rem; 23 + } 24 + header { 25 + background: #221828; 26 + display: flex; 27 + justify-content: space-between; 28 + padding: 0 0.25rem; 29 + color: #c9b; 30 + display: flex; 31 + gap: 0.5rem; 32 + align-items: baseline; 33 + } 34 + header > * { 35 + flex-basis: 33%; 36 + } 37 + header > .empty { 38 + font-size: 0.8rem; 39 + opacity: 0.5; 40 + } 41 + header > .title { 42 + text-align: center; 43 + } 44 + header > a.micro { 45 + text-decoration: none; 46 + font-size: 0.8rem; 47 + text-align: right; 48 + opacity: 0.5; 49 + } 50 + header > a.micro:hover { 51 + opacity: 1; 52 + } 53 + main { 54 + background: #ccc; 55 + display: flex; 56 + flex-direction: column; 57 + flex-grow: 1; 58 + padding: 0.25rem 0.5rem; 59 + } 60 + p { 61 + margin: 1rem 0 0; 62 + text-align: center; 63 + } 64 + .parent-host { 65 + font-weight: bold; 66 + color: #48c; 67 + display: inline-block; 68 + padding: 0 0.125rem; 69 + border-radius: 0.25rem; 70 + border: 1px solid #aaa; 71 + font-size: 0.8rem; 72 + } 73 + 74 + #loader { 75 + display: flex; 76 + flex-grow: 1; 77 + justify-content: center; 78 + align-items: center; 79 + } 80 + .spinner { 81 + animation: rotation 1.618s ease-in-out infinite; 82 + border-radius: 50%; 83 + border: 3px dashed #434; 84 + box-sizing: border-box; 85 + display: inline-block; 86 + height: 1.5em; 87 + width: 1.5em; 88 + } 89 + @keyframes rotation { 90 + 0% { transform: rotate(0deg) } 91 + 100% { transform: rotate(360deg) } 92 + } 93 + 94 + #user-info { 95 + flex-grow: 1; 96 + display: flex; 97 + flex-direction: column; 98 + justify-content: center; 99 + } 100 + #action { 101 + background: #eee; 102 + display: flex; 103 + justify-content: space-between; 104 + padding: 0.5rem 0.25rem 0.5rem 0.5rem; 105 + font-size: 0.8rem; 106 + align-items: baseline; 107 + border-radius: 0.5rem; 108 + border: 1px solid #bbb; 109 + cursor: pointer; 110 + } 111 + #action:hover { 112 + background: #fff; 113 + } 114 + #allow { 115 + background: transparent; 116 + border: none; 117 + border-left: 1px solid #bbb; 118 + padding: 0 0.5rem; 119 + color: #375; 120 + font: inherit; 121 + cursor: pointer; 122 + } 123 + #action:hover #allow { 124 + color: #285; 125 + } 126 + 127 + #or { 128 + font-size: 0.8rem; 129 + text-align: center; 130 + } 131 + #or p { 132 + margin: 0 0 1rem; 133 + } 134 + 135 + input#handle { 136 + border: none; 137 + border-bottom: 1px dashed #aaa; 138 + background: transparent; 139 + } 140 + 141 + .hidden { 142 + display: none !important; 143 + } 144 + 145 + </style> 146 + 147 + <div class="wrap unframed"> 148 + <header> 149 + <div class="empty">🔒</div> 150 + <code class="title" style="font-family: monospace;" 151 + >who-am-i</code> 152 + <a href="https://microcosm.blue" target="_blank" class="micro" 153 + ><span style="color: #f396a9">m</span 154 + ><span style="color: #f49c5c">i</span 155 + ><span style="color: #c7b04c">c</span 156 + ><span style="color: #92be4c">r</span 157 + ><span style="color: #4ec688">o</span 158 + ><span style="color: #51c2b6">c</span 159 + ><span style="color: #54bed7">o</span 160 + ><span style="color: #8fb1f1">s</span 161 + ><span style="color: #ce9df1">m</span 162 + ></a> 163 + </header> 164 + 165 + <main> 166 + {{> main}} 167 + </main> 168 + </div>