APIs for links and references in the ATmosphere

more error handling

+204 -306
+1
who-am-i/demo/index.html
··· 19 19 (whoami => { 20 20 const handleMessage = ev => { 21 21 if (ev.source !== whoami.contentWindow) { 22 + // TODO: ALSO CHECK ev.origin!!!! 22 23 console.log('nah'); 23 24 return; 24 25 }
+1 -5
who-am-i/src/oauth.rs
··· 54 54 } 55 55 56 56 #[derive(Debug, Error)] 57 - #[error(transparent)] 58 - pub struct AuthStartError(#[from] atrium_oauth::Error); 59 - 60 - #[derive(Debug, Error)] 61 57 pub enum OAuthCompleteError { 62 58 #[error("the user denied request: {description:?} (from {issuer:?})")] 63 59 Denied { ··· 124 120 }) 125 121 } 126 122 127 - pub async fn begin(&self, handle: &str) -> Result<String, AuthStartError> { 123 + pub async fn begin(&self, handle: &str) -> Result<String, atrium_oauth::Error> { 128 124 let auth_opts = AuthorizeOptions { 129 125 scopes: READONLY_SCOPE.to_vec(), 130 126 ..Default::default()
+35 -21
who-am-i/src/server.rs
··· 145 145 if !allowed_hosts.contains(parent_host) { 146 146 return err("Login is not allowed on this page", false); 147 147 } 148 + let parent_origin = url.origin().ascii_serialization(); 149 + if parent_origin == "null" { 150 + return err("Referer origin is opaque", true); 151 + } 148 152 if let Some(did) = jar.get(DID_COOKIE_KEY) { 149 153 let Ok(did) = Did::new(did.value_trimmed().to_string()) else { 150 154 return err("Bad cookie", false); ··· 166 170 "did": did, 167 171 "fetch_key": fetch_key, 168 172 "parent_host": parent_host, 173 + "parent_origin": parent_origin, 169 174 }), 170 175 ) 171 176 .into_response() 172 177 } else { 173 178 RenderHtml( 174 - "prompt-anon", 179 + "prompt", 175 180 engine, 176 181 json!({ 177 182 "parent_host": parent_host, 183 + "parent_origin": parent_origin, 178 184 }), 179 185 ) 180 186 .into_response() ··· 228 234 #[derive(Debug, Deserialize)] 229 235 struct BeginOauthParams { 230 236 handle: String, 231 - flow: String, 232 237 } 233 238 async fn start_oauth( 234 - State(AppState { oauth, .. }): State<AppState>, 239 + State(AppState { oauth, engine, .. }): State<AppState>, 235 240 Query(params): Query<BeginOauthParams>, 236 241 jar: SignedCookieJar, 237 - headers: HeaderMap, 238 - ) -> (SignedCookieJar, Redirect) { 242 + ) -> Response { 239 243 // if any existing session was active, clear it first 244 + // ...this might help a confusion attack w multiple sign-in flows or smth 240 245 let jar = jar.remove(DID_COOKIE_KEY); 241 246 242 - if let Some(referrer) = headers.get(REFERER) { 243 - if let Ok(referrer) = referrer.to_str() { 244 - println!("referrer: {referrer}"); 245 - } else { 246 - eprintln!("referer contained opaque bytes"); 247 - }; 248 - } else { 249 - eprintln!("no referrer"); 250 - }; 247 + use atrium_identity::Error as IdError; 248 + use atrium_oauth::Error as OAuthError; 251 249 252 - let auth_url = oauth.begin(&params.handle).await.unwrap(); 253 - let flow = params.flow; 254 - if !flow.chars().all(|c| char::is_ascii_alphanumeric(&c)) { 255 - panic!("invalid flow (injection attempt?)"); // should probably just url-encode it instead.. 250 + match oauth.begin(&params.handle).await { 251 + Ok(auth_url) => (jar, Redirect::to(&auth_url)).into_response(), 252 + Err(OAuthError::Identity(IdError::NotFound)) => { 253 + let info = json!({ "reason": "handle not found" }); 254 + (StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response() 255 + } 256 + Err(OAuthError::Identity(IdError::AtIdentifier(r))) => { 257 + let info = json!({ "reason": r }); 258 + (StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response() 259 + } 260 + Err(OAuthError::Identity(IdError::HttpStatus(StatusCode::NOT_FOUND))) => { 261 + let info = json!({ "reason": "handle not found" }); 262 + (StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response() 263 + } 264 + Err(e) => { 265 + eprintln!("begin auth failed: {e:?}"); 266 + let info = json!({ "reason": "unknown" }); 267 + ( 268 + StatusCode::INTERNAL_SERVER_ERROR, 269 + RenderHtml("auth-fail", engine, info), 270 + ) 271 + .into_response() 272 + } 256 273 } 257 - eprintln!("auth_url {auth_url}"); 258 - 259 - (jar, Redirect::to(&auth_url)) 260 274 } 261 275 262 276 impl OAuthCompleteError {
+24 -2
who-am-i/static/style.css
··· 60 60 max-width: 21rem; 61 61 } 62 62 63 + #error-message { 64 + font-size: 0.8rem; 65 + color: #a31; 66 + } 67 + 68 + #error-message:not(.hidden) + #prompt { 69 + display: none !important; 70 + } 71 + 72 + #error-message, 63 73 p { 64 74 margin: 1rem 0 0; 65 75 text-align: center; 76 + } 77 + p.detail { 78 + font-size: 0.8rem; 66 79 } 67 80 .parent-host { 68 81 font-weight: bold; ··· 93 106 0% { transform: rotate(0deg) } 94 107 100% { transform: rotate(360deg) } 95 108 } 109 + /* loader visibility is mutually exclusive with its immediate sibling */ 110 + #loader:not(.hidden) + * { 111 + display: none !important; 112 + } 96 113 97 114 #user-info { 98 115 flex-grow: 1; ··· 100 117 flex-direction: column; 101 118 justify-content: center; 102 119 } 103 - #action { 120 + .action { 104 121 background: #eee; 105 122 display: flex; 106 123 justify-content: space-between; ··· 111 128 border: 1px solid #bbb; 112 129 cursor: pointer; 113 130 } 114 - #action:hover { 131 + .action:hover { 115 132 background: #fff; 116 133 } 134 + #form-action:not(.hidden) + .action { 135 + display: none !important; 136 + } 137 + 138 + #connect, 117 139 #allow { 118 140 background: transparent; 119 141 border: none;
+3 -4
who-am-i/templates/auth-fail.hbs
··· 8 8 </div> 9 9 10 10 <script> 11 - // TODO: tie this back to its source........... 12 - 13 11 localStorage.setItem("who-am-i", JSON.stringify({ 14 12 result: "fail", 15 - reason: "alskfjlaskdjf", 13 + reason: {{{json reason}}}, 16 14 })); 15 + 17 16 window.close(); 18 17 </script> 19 18 {{/inline}} 20 19 21 - {{#> return-base}}{{/return-base}} 20 + {{#> base-framed}}{{/base-framed}}
-94
who-am-i/templates/prompt-anon.hbs
··· 1 - {{#*inline "main"}} 2 - <p> 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 8 - </p> 9 - 10 - <div id="loader" class="hidden"> 11 - <span class="spinner"></span> 12 - </div> 13 - 14 - <div id="user-info"> 15 - <form id="action" action="/auth" method="GET" target="_blank"> 16 - <label> 17 - @<input id="handle" name="handle" placeholder="example.bsky.social" /> 18 - </label> 19 - <button id="allow" type="submit">connect</button> 20 - </form> 21 - </div> 22 - 23 - <script> 24 - var loaderEl = document.getElementById('loader'); 25 - var infoEl = document.getElementById('user-info'); 26 - const formEl = document.getElementById('action'); 27 - const handleEl = document.getElementById('handle'); 28 - 29 - function err(msg) { 30 - 31 - } 32 - 33 - formEl.onsubmit = e => { 34 - e.preventDefault(); 35 - // TODO: include expected referer! (..this system is probably bad) 36 - // maybe a random localstorage key that we specifically listen for? 37 - var url = new URL('/auth', window.location); 38 - url.searchParams.set('handle', handleEl.value); 39 - url.searchParams.set('flow', {{{json flow}}}); 40 - var flow = window.open(url, '_blank'); 41 - window.f = flow; 42 - 43 - window.addEventListener('storage', e => { 44 - var details = localStorage.getItem("who-am-i"); 45 - if (!details) { 46 - console.error("hmm, heard from localstorage but did not get DID"); 47 - } 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 - } 59 - 60 - infoEl.classList.add('hidden'); 61 - lookUpAndShare(parsed.fetch_key); 62 - }); 63 - } 64 - 65 - function lookUpAndShare(fetch_key) { 66 - let user_info = new URL('/user-info', window.location); 67 - user_info.searchParams.set('fetch-key', fetch_key); 68 - fetch(user_info) 69 - .then(resp => { 70 - if (!resp.ok) throw new Error('request failed'); 71 - return resp.json(); 72 - }) 73 - .then( 74 - ({ handle }) => { 75 - loaderEl.remove(); 76 - handleEl.textContent = `@${handle}`; 77 - infoEl.classList.remove('hidden'); 78 - share(handle); 79 - }, 80 - err => { 81 - infoEl.textContent = 'ohno'; 82 - console.error(err); 83 - }, 84 - ); 85 - } 86 - 87 - function share(handle) { 88 - top.postMessage({ source: 'whoami', handle }, '*'); // TODO: pass the referrer back from server 89 - } 90 - 91 - </script> 92 - {{/inline}} 93 - 94 - {{#> prompt-base}}{{/prompt-base}}
+12 -12
who-am-i/templates/prompt-error.hbs
··· 1 1 {{#*inline "main"}} 2 - <div class="prompt-error"> 3 - <p class="went-wrong">Something went wrong :(</p> 4 - <p class="reason">{{ reason }}</p> 5 - <p id="maybe-not-in-iframe" class="hidden"> 6 - Possibly related: this prompt is meant to be shown in an iframe, but it seems like it's not. 7 - </p> 8 - </div> 2 + <div class="prompt-error"> 3 + <p class="went-wrong">Something went wrong :(</p> 4 + <p class="reason">{{ reason }}</p> 5 + <p id="maybe-not-in-iframe" class="hidden"> 6 + Possibly related: this prompt is meant to be shown in an iframe, but it seems like it's not. 7 + </p> 8 + </div> 9 9 10 - <script> 11 - if ({{{json check_frame}}} && window.self === window.top) { 12 - document.getElementById('maybe-not-in-iframe').classList.remove('hidden'); 13 - } 14 - </script> 10 + <script> 11 + if ({{{json check_frame}}} && window.self === window.top) { 12 + document.getElementById('maybe-not-in-iframe').classList.remove('hidden'); 13 + } 14 + </script> 15 15 {{/inline}} 16 16 17 17 {{#> base-framed}}{{/base-framed}}
+128
who-am-i/templates/prompt.hbs
··· 1 + {{#*inline "main"}} 2 + <p> 3 + Connect in the ATmosphere 4 + </p> 5 + 6 + <p id="error-message" class="hidden"></p> 7 + 8 + <p id="prompt" class="detail"> 9 + <span class="parent-host">{{ parent_host }}</span> would like to confirm your handle 10 + </p> 11 + 12 + <div id="loader" {{#unless did}}class="hidden"{{/unless}}> 13 + <span class="spinner"></span> 14 + </div> 15 + 16 + <div id="user-info"> 17 + <form id="form-action" action="/auth" method="GET" target="_blank" class="action {{#if did}}hidden{{/if}}"> 18 + <label> 19 + @<input id="handle" name="handle" placeholder="example.bsky.social" /> 20 + </label> 21 + <button id="connect" type="submit">connect</button> 22 + </form> 23 + 24 + <div id="handle-action" class="action"> 25 + <span id="handle"></span> 26 + <button id="allow">Allow</button> 27 + </div> 28 + </div> 29 + 30 + 31 + 32 + <script> 33 + const errorEl = document.getElementById('error-message'); 34 + const promptEl = document.getElementById('prompt'); 35 + const loaderEl = document.getElementById('loader'); 36 + const infoEl = document.getElementById('user-info'); 37 + const handleEl = document.getElementById('handle'); 38 + const formEl = document.getElementById('form-action'); // for anon 39 + const allowEl = document.getElementById('allow'); // for known-did 40 + const connectEl = document.getElementById('connect'); // for anon 41 + 42 + function err(e, msg) { 43 + loaderEl.classList.add('hidden'); 44 + errorEl.classList.remove('hidden'); 45 + errorEl.textContent = msg || e; 46 + throw new Error(e); 47 + } 48 + 49 + formEl && (formEl.onsubmit = e => { 50 + e.preventDefault(); 51 + loaderEl.classList.remove('hidden'); 52 + // TODO: include expected referer! (..this system is probably bad) 53 + // maybe a random localstorage key that we specifically listen for? 54 + const url = new URL('/auth', window.location); 55 + url.searchParams.set('handle', handleEl.value); 56 + window.open(url, '_blank'); 57 + }); 58 + 59 + window.addEventListener('storage', async e => { 60 + // here's a fun minor vuln: we can't tell which flow triggers the storage event. 61 + // so if you have two flows going, it grants for both (or the first responder?) if you grant for either. 62 + // (letting this slide while parent pages are allowlisted to microcosm only) 63 + 64 + const fail = (e, msg) => { 65 + loaderEl.classList.add('hidden'); 66 + formEl.classList.remove('hidden'); 67 + handleEl.focus(); 68 + handleEl.select(); 69 + err(e, msg); 70 + } 71 + 72 + const details = localStorage.getItem("who-am-i"); 73 + if (!details) { 74 + console.error("hmm, heard from localstorage but did not get DID"); 75 + return; 76 + } 77 + localStorage.removeItem("who-am-i"); 78 + 79 + let parsed; 80 + try { 81 + parsed = JSON.parse(details); 82 + } catch (e) { 83 + err(e, "something went wrong getting the details back"); 84 + } 85 + 86 + if (parsed.result === "fail") { 87 + fail(`uh oh: ${parsed.reason}`); 88 + } 89 + 90 + infoEl.classList.add('hidden'); 91 + 92 + const handle = await lookUp(parsed.fetch_key); 93 + 94 + shareAllow(handle); 95 + }); 96 + 97 + const lookUp = async fetch_key => { 98 + const user_info = new URL('/user-info', window.location); 99 + user_info.searchParams.set('fetch-key', fetch_key); 100 + let info; 101 + try { 102 + const resp = await fetch(user_info); 103 + if (!resp.ok) throw resp; 104 + info = await resp.json(); 105 + } catch (e) { 106 + err(e, 'failed to resolve handle from DID') 107 + } 108 + return info.handle; 109 + } 110 + 111 + const shareAllow = handle => { 112 + top.postMessage( 113 + { action: "allow", handle }, 114 + {{{json parent_host}}}, 115 + ); 116 + } 117 + 118 + const shareDeny = reason => { 119 + top.postMessage( 120 + { action: "deny", reason }, 121 + {{{json parent_origin}}}, 122 + ); 123 + } 124 + </script> 125 + 126 + {{/inline}} 127 + 128 + {{#> base-framed}}{{/base-framed}}
-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>