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