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

desparate attempts for cross-browser compat

safari might be a lost cause

who-am-i may have been a mistake

Changed files
+123 -10
who-am-i
+44 -4
who-am-i/src/server.rs
··· 10 10 response::{IntoResponse, Json, Redirect, Response}, 11 11 routing::{get, post}, 12 12 }; 13 - use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar}; 13 + use axum_extra::extract::cookie::{Cookie, Expiration, Key, SameSite, SignedCookieJar}; 14 14 use axum_template::{RenderHtml, engine::Engine}; 15 15 use handlebars::{Handlebars, handlebars_helper}; 16 16 use jose_jwk::JwkSet; ··· 20 20 use serde_json::{Value, json}; 21 21 use std::collections::HashSet; 22 22 use std::sync::Arc; 23 - use std::time::Duration; 23 + use std::time::{Duration, SystemTime}; 24 24 use tokio::net::TcpListener; 25 25 use tokio_util::sync::CancellationToken; 26 26 use url::Url; ··· 32 32 const FAVICON: &[u8] = include_bytes!("../static/favicon.ico"); 33 33 const STYLE_CSS: &str = include_str!("../static/style.css"); 34 34 35 + const HELLO_COOKIE_KEY: &str = "hello-who-am-i"; 35 36 const DID_COOKIE_KEY: &str = "did"; 36 37 37 38 const COOKIE_EXPIRATION: Duration = Duration::from_secs(30 * 86_400); ··· 113 114 .unwrap(); 114 115 } 115 116 117 + #[derive(Debug, Deserialize)] 118 + struct HelloQuery { 119 + auth_reload: Option<String>, 120 + auth_failed: Option<String>, 121 + } 116 122 async fn hello( 117 123 State(AppState { 118 124 engine, ··· 121 127 oauth, 122 128 .. 123 129 }): State<AppState>, 130 + Query(params): Query<HelloQuery>, 124 131 mut jar: SignedCookieJar, 125 132 ) -> Response { 133 + let is_auth_reload = params.auth_reload.is_some(); 134 + let auth_failed = params.auth_failed.is_some(); 135 + let no_cookie = jar.get(HELLO_COOKIE_KEY).is_none(); 136 + jar = jar.add(hello_cookie()); 137 + 126 138 let info = if let Some(did) = jar.get(DID_COOKIE_KEY) { 127 139 if let Ok(did) = Did::new(did.value_trimmed().to_string()) { 128 140 // push cookie expiry ··· 138 150 json!({ 139 151 "did": did, 140 152 "fetch_key": fetch_key, 153 + "is_auth_reload": is_auth_reload, 154 + "auth_failed": auth_failed, 155 + "no_cookie": no_cookie, 141 156 }) 142 157 } else { 143 158 jar = jar.remove(DID_COOKIE_KEY); 144 - json!({}) 159 + json!({ 160 + "is_auth_reload": is_auth_reload, 161 + "auth_failed": auth_failed, 162 + "no_cookie": no_cookie, 163 + }) 145 164 } 146 165 } else { 147 - json!({}) 166 + json!({ 167 + "is_auth_reload": is_auth_reload, 168 + "auth_failed": auth_failed, 169 + "no_cookie": no_cookie, 170 + }) 148 171 }; 149 172 let frame_headers = [(CONTENT_SECURITY_POLICY, "frame-ancestors 'none'")]; 150 173 (frame_headers, jar, RenderHtml("hello", engine, info)).into_response() ··· 162 185 ([(CONTENT_TYPE, "image/x-icon")], FAVICON) 163 186 } 164 187 188 + fn hello_cookie() -> Cookie<'static> { 189 + Cookie::build((HELLO_COOKIE_KEY, "hiiii")) 190 + .http_only(true) 191 + .secure(true) 192 + .same_site(SameSite::None) 193 + .expires(Expiration::DateTime( 194 + (SystemTime::now() + COOKIE_EXPIRATION).into(), 195 + )) // wtf safari needs this to not be a session cookie?? 196 + .max_age(COOKIE_EXPIRATION.try_into().unwrap()) 197 + .path("/") 198 + .into() 199 + } 200 + 165 201 fn cookie(did: &Did) -> Cookie<'static> { 166 202 Cookie::build((DID_COOKIE_KEY, did.to_string())) 167 203 .http_only(true) 168 204 .secure(true) 169 205 .same_site(SameSite::None) 206 + .expires(Expiration::DateTime( 207 + (SystemTime::now() + COOKIE_EXPIRATION).into(), 208 + )) // wtf safari needs this to not be a session cookie?? 170 209 .max_age(COOKIE_EXPIRATION.try_into().unwrap()) 210 + .path("/") 171 211 .into() 172 212 } 173 213
+11
who-am-i/static/style.css
··· 165 165 color: #285; 166 166 } 167 167 168 + #need-storage { 169 + font-size: 0.8rem; 170 + } 171 + .problem { 172 + color: #a31; 173 + } 174 + 168 175 #or { 169 176 font-size: 0.8rem; 170 177 text-align: center; ··· 182 189 .hidden { 183 190 display: none !important; 184 191 } 192 + 193 + .hello-connect-plz { 194 + margin: 1.667rem 0 0.667rem; 195 + }
+5 -1
who-am-i/templates/authorized.hbs
··· 1 1 <!doctype html> 2 + <meta charset="utf-8" /> 3 + <title>great job!</title> 2 4 3 - <p>oh sick. hey {{ did }}. you can close this window now.</p> 5 + <h1>oauth success!</h1> 6 + <p>this window should automatically close itself (probably a bug if it hasn't)</p> 4 7 5 8 <script> 6 9 // TODO: tie this back to its source........... ··· 11 14 token: {{{json token}}}, 12 15 fetch_key: {{{json fetch_key}}}, 13 16 })); 17 + // TODO: probably also wait for a reply from the frame and show an error if not 14 18 window.close(); 15 19 </script>
+45 -4
who-am-i/templates/hello.hbs
··· 4 4 <div class="mini-content"> 5 5 <div class="explain"> 6 6 <p>This is a little identity-verifying service for microcosm demos.</p> 7 + <p>Only <strong>read access to your public data</strong> is required to connect: connecting does not grant any ability to modify your account or data.</p> 7 8 </div> 8 9 9 10 {{#if did}} ··· 50 51 } catch (e) { 51 52 err(e, 'failed to clear session, sorry'); 52 53 } 53 - window.location.reload(); 54 + window.location.replace(location.pathname); 55 + window.location.reload(); // backup, in case there is no query? 54 56 }); 55 57 })(); 56 58 ··· 71 73 } 72 74 </script> 73 75 {{else}} 74 - <p id="prompt" class="detail no"> 75 - No identity connected. 76 - </p> 76 + 77 + <p class="hello-connect-plz">Connect your handle</p> 78 + 79 + {{#if is_auth_reload}} 80 + {{#if no_cookie}} 81 + <p id="prompt" class="detail no"> 82 + No identity connected. Your browser may be blocking access for connecting. 83 + </p> 84 + {{else}} 85 + {{#if auth_failed}} 86 + <p id="prompt" class="detail no"> 87 + No identity connected. Connecting failed or was denied. 88 + </p> 89 + {{else}} 90 + <p id="prompt" class="detail no"> 91 + No identity connected. 92 + </p> 93 + {{/if}} 94 + {{/if}} 95 + {{/if}} 96 + 97 + <div id="user-info"> 98 + <form id="form-action" action="/auth" target="_blank" method="GET" class="action {{#if did}}hidden{{/if}}"> 99 + <label> 100 + @<input id="handle-input" class="handle" name="handle" placeholder="example.bsky.social" /> 101 + </label> 102 + <button id="connect" type="submit">connect</button> 103 + </form> 104 + </div> 77 105 {{/if}} 106 + 78 107 </div> 108 + <script> 109 + window.addEventListener('storage', e => { 110 + console.log('eyyy got storage', e); 111 + if (e.key !== 'who-am-i') return; 112 + if (!e.newValue) return; 113 + if (e.newValue.result === 'success') { 114 + window.location = '/?auth_reload=1'; 115 + } else { 116 + window.location = '/?auth_reload=1&auth_failed=1'; 117 + } 118 + }); 119 + </script> 79 120 {{/inline}} 80 121 81 122 {{#> base-full}}{{/base-full}}
+18 -1
who-am-i/templates/prompt.hbs
··· 27 27 </div> 28 28 </div> 29 29 30 + <div id="need-storage" class="hidden"> 31 + <p class="problem">Sorry, your browser is blocking access.</p> 32 + <p>Try <a href="/" target="_blank">connecting directly</a> first (but no promises).</p> 33 + </div> 34 + 30 35 31 36 32 37 <script> ··· 39 44 const formEl = document.getElementById('form-action'); // for anon 40 45 const allowEl = document.getElementById('handle-action'); // for known-did 41 46 const connectEl = document.getElementById('connect'); // for anon 47 + const needStorageEl = document.getElementById('need-storage'); // for safari/frame isolation 42 48 43 49 function err(e, msg) { 44 50 loaderEl.classList.add('hidden'); ··· 66 72 window.open(url, '_blank'); 67 73 }; 68 74 75 + // check if we may be partitioned, preventing access after auth completion 76 + // this should only happen if on a browser that implements storage access api 77 + if ('hasStorageAccess' in document) { 78 + document.hasStorageAccess().then((hasAccess) => { 79 + if (!hasAccess) { 80 + promptEl.classList.add('hidden'); 81 + infoEl.classList.add('hidden'); 82 + needStorageEl.classList.remove('hidden'); 83 + } 84 + }); 85 + } 86 + 69 87 window.addEventListener('storage', async e => { 70 88 // here's a fun minor vuln: we can't tell which flow triggers the storage event. 71 89 // so if you have two flows going, it grants for both (or the first responder?) if you grant for either. ··· 79 97 console.error("hmm, heard from localstorage but did not get DID", details, e); 80 98 err('sorry, something went wrong getting your details'); 81 99 } 82 - localStorage.removeItem(e.key); 83 100 84 101 let parsed; 85 102 try {