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

show current session at hello and allow revoking

do we need csrf here or...

Changed files
+107 -11
who-am-i
src
static
templates
+34 -7
who-am-i/src/server.rs
··· 7 7 header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE, HeaderMap, REFERER, X_FRAME_OPTIONS}, 8 8 }, 9 9 response::{IntoResponse, Json, Redirect, Response}, 10 - routing::get, 10 + routing::{get, post}, 11 11 }; 12 12 use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar}; 13 13 use axum_template::{RenderHtml, engine::Engine}; ··· 85 85 .route("/user-info", get(user_info)) 86 86 .route("/auth", get(start_oauth)) 87 87 .route("/authorized", get(complete_oauth)) 88 + .route("/disconnect", post(disconnect)) 88 89 .with_state(state); 89 90 90 91 let listener = TcpListener::bind("0.0.0.0:9997") ··· 98 99 } 99 100 100 101 async fn hello( 101 - State(AppState { engine, .. }): State<AppState>, 102 + State(AppState { 103 + engine, 104 + resolve_handles, 105 + shutdown, 106 + oauth, 107 + .. 108 + }): State<AppState>, 102 109 mut jar: SignedCookieJar, 103 110 ) -> Response { 104 - // push expiry (or clean up) the current cookie 105 - if let Some(did) = jar.get(DID_COOKIE_KEY) { 111 + let info = if let Some(did) = jar.get(DID_COOKIE_KEY) { 106 112 if let Ok(did) = Did::new(did.value_trimmed().to_string()) { 113 + // push cookie expiry 107 114 jar = jar.add(cookie(&did)); 115 + let fetch_key = resolve_handles.dispatch( 116 + { 117 + let oauth = oauth.clone(); 118 + let did = did.clone(); 119 + async move { oauth.resolve_handle(did.clone()).await } 120 + }, 121 + shutdown.child_token(), 122 + ); 123 + json!({ 124 + "did": did, 125 + "fetch_key": fetch_key, 126 + }) 108 127 } else { 109 128 jar = jar.remove(DID_COOKIE_KEY); 129 + json!({}) 110 130 } 111 - } 131 + } else { 132 + json!({}) 133 + }; 112 134 let frame_headers = [ 113 135 (X_FRAME_OPTIONS, "deny"), 114 136 (CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"), 115 137 ]; 116 - (frame_headers, jar, RenderHtml("hello", engine, json!({}))).into_response() 138 + (frame_headers, jar, RenderHtml("hello", engine, info)).into_response() 117 139 } 118 140 119 141 async fn css() -> impl IntoResponse { ··· 179 201 (X_FRAME_OPTIONS, format!("allow-from {parent_origin}")), 180 202 ( 181 203 CONTENT_SECURITY_POLICY, 182 - format!("frame-ancestors {parent_host}"), 204 + format!("frame-ancestors {parent_origin}"), 183 205 ), 184 206 ]; 185 207 ··· 362 384 }); 363 385 (jar, RenderHtml("authorized", engine, info)).into_response() 364 386 } 387 + 388 + async fn disconnect(jar: SignedCookieJar) -> impl IntoResponse { 389 + let jar = jar.remove(DID_COOKIE_KEY); 390 + (jar, Json(json!({ "ok": true }))) 391 + }
+5 -1
who-am-i/static/style.css
··· 136 136 } 137 137 138 138 #connect, 139 - #allow { 139 + #allow, 140 + #revoke { 140 141 background: transparent; 141 142 border: none; 142 143 border-left: 1px solid #bbb; ··· 144 145 color: #375; 145 146 font: inherit; 146 147 cursor: pointer; 148 + } 149 + #revoke { 150 + color: #a31; 147 151 } 148 152 #action:hover #allow { 149 153 color: #285;
+68 -3
who-am-i/templates/hello.hbs
··· 1 1 {{#*inline "description"}}A little identity-verifying auth service for microcosm demos{{/inline}} 2 2 3 3 {{#*inline "main"}} 4 - <div class="mini-content"> 5 - This is a little identity-verifying service for microcosm demos. 6 - </div> 4 + <div class="mini-content"> 5 + This is a little identity-verifying service for microcosm demos. 6 + 7 + {{#if did}} 8 + <p id="error-message" class="hidden"></p> 9 + 10 + <p id="prompt" class="detail"> 11 + Connected identity: 12 + </p> 13 + 14 + <div id="loader"> 15 + <span class="spinner"></span> 16 + </div> 17 + 18 + <div id="user-info"> 19 + <div id="handle-action" class="action"> 20 + <span id="handle-view" class="handle"></span> 21 + <button id="revoke">disconnect</button> 22 + </div> 23 + </div> 24 + <script> 25 + const errorEl = document.getElementById('error-message'); 26 + const loaderEl = document.getElementById('loader'); 27 + const handleViewEl = document.getElementById('handle-view'); 28 + const revokeEl = document.getElementById('revoke'); // for known-did 29 + 30 + function err(e, msg) { 31 + loaderEl.classList.add('hidden'); 32 + errorEl.classList.remove('hidden'); 33 + errorEl.textContent = msg || e; 34 + throw new Error(e); 35 + } 36 + 37 + // already-known user 38 + ({{{json did}}}) && (async () => { 39 + 40 + const handle = await lookUp({{{json fetch_key}}}); 41 + console.log('got handle', handle); 42 + 43 + loaderEl.classList.add('hidden'); 44 + handleViewEl.textContent = `@${handle}`; 45 + revokeEl.addEventListener('click', async () => { 46 + try { 47 + let res = await fetch('/disconnect', { method: 'POST', credentials: 'include' }); 48 + if (!res.ok) throw res; 49 + } catch (e) { 50 + err(e, 'failed to clear session, sorry'); 51 + } 52 + window.location.reload(); 53 + }); 54 + })(); 55 + 56 + async function lookUp(fetch_key) { 57 + const user_info = new URL('/user-info', window.location); 58 + user_info.searchParams.set('fetch-key', fetch_key); 59 + let info; 60 + try { 61 + const resp = await fetch(user_info); 62 + if (!resp.ok) throw resp; 63 + info = await resp.json(); 64 + } catch (e) { 65 + err(e, 'failed to resolve handle from DID') 66 + } 67 + return info.handle; 68 + } 69 + </script> 70 + {{/if}} 71 + </div> 7 72 {{/inline}} 8 73 9 74 {{#> base-full}}{{/base-full}}