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

more error handling

+1
who-am-i/demo/index.html
···
(whoami => {
const handleMessage = ev => {
if (ev.source !== whoami.contentWindow) {
+
// TODO: ALSO CHECK ev.origin!!!!
console.log('nah');
return;
}
+1 -5
who-am-i/src/oauth.rs
···
}
#[derive(Debug, Error)]
-
#[error(transparent)]
-
pub struct AuthStartError(#[from] atrium_oauth::Error);
-
-
#[derive(Debug, Error)]
pub enum OAuthCompleteError {
#[error("the user denied request: {description:?} (from {issuer:?})")]
Denied {
···
})
}
-
pub async fn begin(&self, handle: &str) -> Result<String, AuthStartError> {
+
pub async fn begin(&self, handle: &str) -> Result<String, atrium_oauth::Error> {
let auth_opts = AuthorizeOptions {
scopes: READONLY_SCOPE.to_vec(),
..Default::default()
+35 -21
who-am-i/src/server.rs
···
if !allowed_hosts.contains(parent_host) {
return err("Login is not allowed on this page", false);
}
+
let parent_origin = url.origin().ascii_serialization();
+
if parent_origin == "null" {
+
return err("Referer origin is opaque", true);
+
}
if let Some(did) = jar.get(DID_COOKIE_KEY) {
let Ok(did) = Did::new(did.value_trimmed().to_string()) else {
return err("Bad cookie", false);
···
"did": did,
"fetch_key": fetch_key,
"parent_host": parent_host,
+
"parent_origin": parent_origin,
}),
)
.into_response()
} else {
RenderHtml(
-
"prompt-anon",
+
"prompt",
engine,
json!({
"parent_host": parent_host,
+
"parent_origin": parent_origin,
}),
)
.into_response()
···
#[derive(Debug, Deserialize)]
struct BeginOauthParams {
handle: String,
-
flow: String,
}
async fn start_oauth(
-
State(AppState { oauth, .. }): State<AppState>,
+
State(AppState { oauth, engine, .. }): State<AppState>,
Query(params): Query<BeginOauthParams>,
jar: SignedCookieJar,
-
headers: HeaderMap,
-
) -> (SignedCookieJar, Redirect) {
+
) -> Response {
// if any existing session was active, clear it first
+
// ...this might help a confusion attack w multiple sign-in flows or smth
let jar = jar.remove(DID_COOKIE_KEY);
-
if let Some(referrer) = headers.get(REFERER) {
-
if let Ok(referrer) = referrer.to_str() {
-
println!("referrer: {referrer}");
-
} else {
-
eprintln!("referer contained opaque bytes");
-
};
-
} else {
-
eprintln!("no referrer");
-
};
+
use atrium_identity::Error as IdError;
+
use atrium_oauth::Error as OAuthError;
-
let auth_url = oauth.begin(&params.handle).await.unwrap();
-
let flow = params.flow;
-
if !flow.chars().all(|c| char::is_ascii_alphanumeric(&c)) {
-
panic!("invalid flow (injection attempt?)"); // should probably just url-encode it instead..
+
match oauth.begin(&params.handle).await {
+
Ok(auth_url) => (jar, Redirect::to(&auth_url)).into_response(),
+
Err(OAuthError::Identity(IdError::NotFound)) => {
+
let info = json!({ "reason": "handle not found" });
+
(StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response()
+
}
+
Err(OAuthError::Identity(IdError::AtIdentifier(r))) => {
+
let info = json!({ "reason": r });
+
(StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response()
+
}
+
Err(OAuthError::Identity(IdError::HttpStatus(StatusCode::NOT_FOUND))) => {
+
let info = json!({ "reason": "handle not found" });
+
(StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response()
+
}
+
Err(e) => {
+
eprintln!("begin auth failed: {e:?}");
+
let info = json!({ "reason": "unknown" });
+
(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
RenderHtml("auth-fail", engine, info),
+
)
+
.into_response()
+
}
}
-
eprintln!("auth_url {auth_url}");
-
-
(jar, Redirect::to(&auth_url))
}
impl OAuthCompleteError {
+24 -2
who-am-i/static/style.css
···
max-width: 21rem;
}
+
#error-message {
+
font-size: 0.8rem;
+
color: #a31;
+
}
+
+
#error-message:not(.hidden) + #prompt {
+
display: none !important;
+
}
+
+
#error-message,
p {
margin: 1rem 0 0;
text-align: center;
+
}
+
p.detail {
+
font-size: 0.8rem;
}
.parent-host {
font-weight: bold;
···
0% { transform: rotate(0deg) }
100% { transform: rotate(360deg) }
}
+
/* loader visibility is mutually exclusive with its immediate sibling */
+
#loader:not(.hidden) + * {
+
display: none !important;
+
}
#user-info {
flex-grow: 1;
···
flex-direction: column;
justify-content: center;
}
-
#action {
+
.action {
background: #eee;
display: flex;
justify-content: space-between;
···
border: 1px solid #bbb;
cursor: pointer;
}
-
#action:hover {
+
.action:hover {
background: #fff;
}
+
#form-action:not(.hidden) + .action {
+
display: none !important;
+
}
+
+
#connect,
#allow {
background: transparent;
border: none;
+3 -4
who-am-i/templates/auth-fail.hbs
···
</div>
<script>
-
// TODO: tie this back to its source...........
-
localStorage.setItem("who-am-i", JSON.stringify({
result: "fail",
-
reason: "alskfjlaskdjf",
+
reason: {{{json reason}}},
}));
+
window.close();
</script>
{{/inline}}
-
{{#> return-base}}{{/return-base}}
+
{{#> base-framed}}{{/base-framed}}
-94
who-am-i/templates/prompt-anon.hbs
···
-
{{#*inline "main"}}
-
<p>
-
Connect your ATmosphere
-
</p>
-
-
<p class="detail">
-
<span class="parent-host">{{ parent_host }}</span> would like to confirm your handle
-
</p>
-
-
<div id="loader" class="hidden">
-
<span class="spinner"></span>
-
</div>
-
-
<div id="user-info">
-
<form id="action" action="/auth" method="GET" target="_blank">
-
<label>
-
@<input id="handle" name="handle" placeholder="example.bsky.social" />
-
</label>
-
<button id="allow" type="submit">connect</button>
-
</form>
-
</div>
-
-
<script>
-
var loaderEl = document.getElementById('loader');
-
var infoEl = document.getElementById('user-info');
-
const formEl = document.getElementById('action');
-
const handleEl = document.getElementById('handle');
-
-
function err(msg) {
-
-
}
-
-
formEl.onsubmit = e => {
-
e.preventDefault();
-
// TODO: include expected referer! (..this system is probably bad)
-
// maybe a random localstorage key that we specifically listen for?
-
var url = new URL('/auth', window.location);
-
url.searchParams.set('handle', handleEl.value);
-
url.searchParams.set('flow', {{{json flow}}});
-
var flow = window.open(url, '_blank');
-
window.f = flow;
-
-
window.addEventListener('storage', e => {
-
var details = localStorage.getItem("who-am-i");
-
if (!details) {
-
console.error("hmm, heard from localstorage but did not get DID");
-
}
-
loaderEl.classList.remove('hidden');
-
-
try {
-
var parsed = JSON.parse(details);
-
} catch (e) {
-
return err("something went wrong getting the details back");
-
}
-
-
if (parsed.result === "fail") {
-
return err(`something went wrong getting permission to share: ${parsed.reason}`);
-
}
-
-
infoEl.classList.add('hidden');
-
lookUpAndShare(parsed.fetch_key);
-
});
-
}
-
-
function lookUpAndShare(fetch_key) {
-
let user_info = new URL('/user-info', window.location);
-
user_info.searchParams.set('fetch-key', fetch_key);
-
fetch(user_info)
-
.then(resp => {
-
if (!resp.ok) throw new Error('request failed');
-
return resp.json();
-
})
-
.then(
-
({ handle }) => {
-
loaderEl.remove();
-
handleEl.textContent = `@${handle}`;
-
infoEl.classList.remove('hidden');
-
share(handle);
-
},
-
err => {
-
infoEl.textContent = 'ohno';
-
console.error(err);
-
},
-
);
-
}
-
-
function share(handle) {
-
top.postMessage({ source: 'whoami', handle }, '*'); // TODO: pass the referrer back from server
-
}
-
-
</script>
-
{{/inline}}
-
-
{{#> prompt-base}}{{/prompt-base}}
+12 -12
who-am-i/templates/prompt-error.hbs
···
{{#*inline "main"}}
-
<div class="prompt-error">
-
<p class="went-wrong">Something went wrong :(</p>
-
<p class="reason">{{ reason }}</p>
-
<p id="maybe-not-in-iframe" class="hidden">
-
Possibly related: this prompt is meant to be shown in an iframe, but it seems like it's not.
-
</p>
-
</div>
+
<div class="prompt-error">
+
<p class="went-wrong">Something went wrong :(</p>
+
<p class="reason">{{ reason }}</p>
+
<p id="maybe-not-in-iframe" class="hidden">
+
Possibly related: this prompt is meant to be shown in an iframe, but it seems like it's not.
+
</p>
+
</div>
-
<script>
-
if ({{{json check_frame}}} && window.self === window.top) {
-
document.getElementById('maybe-not-in-iframe').classList.remove('hidden');
-
}
-
</script>
+
<script>
+
if ({{{json check_frame}}} && window.self === window.top) {
+
document.getElementById('maybe-not-in-iframe').classList.remove('hidden');
+
}
+
</script>
{{/inline}}
{{#> base-framed}}{{/base-framed}}
+128
who-am-i/templates/prompt.hbs
···
+
{{#*inline "main"}}
+
<p>
+
Connect in the ATmosphere
+
</p>
+
+
<p id="error-message" class="hidden"></p>
+
+
<p id="prompt" class="detail">
+
<span class="parent-host">{{ parent_host }}</span> would like to confirm your handle
+
</p>
+
+
<div id="loader" {{#unless did}}class="hidden"{{/unless}}>
+
<span class="spinner"></span>
+
</div>
+
+
<div id="user-info">
+
<form id="form-action" action="/auth" method="GET" target="_blank" class="action {{#if did}}hidden{{/if}}">
+
<label>
+
@<input id="handle" name="handle" placeholder="example.bsky.social" />
+
</label>
+
<button id="connect" type="submit">connect</button>
+
</form>
+
+
<div id="handle-action" class="action">
+
<span id="handle"></span>
+
<button id="allow">Allow</button>
+
</div>
+
</div>
+
+
+
+
<script>
+
const errorEl = document.getElementById('error-message');
+
const promptEl = document.getElementById('prompt');
+
const loaderEl = document.getElementById('loader');
+
const infoEl = document.getElementById('user-info');
+
const handleEl = document.getElementById('handle');
+
const formEl = document.getElementById('form-action'); // for anon
+
const allowEl = document.getElementById('allow'); // for known-did
+
const connectEl = document.getElementById('connect'); // for anon
+
+
function err(e, msg) {
+
loaderEl.classList.add('hidden');
+
errorEl.classList.remove('hidden');
+
errorEl.textContent = msg || e;
+
throw new Error(e);
+
}
+
+
formEl && (formEl.onsubmit = e => {
+
e.preventDefault();
+
loaderEl.classList.remove('hidden');
+
// TODO: include expected referer! (..this system is probably bad)
+
// maybe a random localstorage key that we specifically listen for?
+
const url = new URL('/auth', window.location);
+
url.searchParams.set('handle', handleEl.value);
+
window.open(url, '_blank');
+
});
+
+
window.addEventListener('storage', async e => {
+
// here's a fun minor vuln: we can't tell which flow triggers the storage event.
+
// so if you have two flows going, it grants for both (or the first responder?) if you grant for either.
+
// (letting this slide while parent pages are allowlisted to microcosm only)
+
+
const fail = (e, msg) => {
+
loaderEl.classList.add('hidden');
+
formEl.classList.remove('hidden');
+
handleEl.focus();
+
handleEl.select();
+
err(e, msg);
+
}
+
+
const details = localStorage.getItem("who-am-i");
+
if (!details) {
+
console.error("hmm, heard from localstorage but did not get DID");
+
return;
+
}
+
localStorage.removeItem("who-am-i");
+
+
let parsed;
+
try {
+
parsed = JSON.parse(details);
+
} catch (e) {
+
err(e, "something went wrong getting the details back");
+
}
+
+
if (parsed.result === "fail") {
+
fail(`uh oh: ${parsed.reason}`);
+
}
+
+
infoEl.classList.add('hidden');
+
+
const handle = await lookUp(parsed.fetch_key);
+
+
shareAllow(handle);
+
});
+
+
const lookUp = async fetch_key => {
+
const user_info = new URL('/user-info', window.location);
+
user_info.searchParams.set('fetch-key', fetch_key);
+
let info;
+
try {
+
const resp = await fetch(user_info);
+
if (!resp.ok) throw resp;
+
info = await resp.json();
+
} catch (e) {
+
err(e, 'failed to resolve handle from DID')
+
}
+
return info.handle;
+
}
+
+
const shareAllow = handle => {
+
top.postMessage(
+
{ action: "allow", handle },
+
{{{json parent_host}}},
+
);
+
}
+
+
const shareDeny = reason => {
+
top.postMessage(
+
{ action: "deny", reason },
+
{{{json parent_origin}}},
+
);
+
}
+
</script>
+
+
{{/inline}}
+
+
{{#> base-framed}}{{/base-framed}}
-168
who-am-i/templates/return-base.hbs
···
-
<!doctype html>
-
-
<style>
-
body {
-
color: #434;
-
font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif;
-
margin: 0;
-
min-height: 100vh;
-
padding: 0;
-
}
-
.wrap {
-
border: 2px solid #221828;
-
border-radius: 0.5rem;
-
box-sizing: border-box;
-
overflow: hidden;
-
display: flex;
-
flex-direction: column;
-
height: 100vh;
-
}
-
.wrap.unframed {
-
border-radius: 0;
-
border-width: 0.4rem;
-
}
-
header {
-
background: #221828;
-
display: flex;
-
justify-content: space-between;
-
padding: 0 0.25rem;
-
color: #c9b;
-
display: flex;
-
gap: 0.5rem;
-
align-items: baseline;
-
}
-
header > * {
-
flex-basis: 33%;
-
}
-
header > .empty {
-
font-size: 0.8rem;
-
opacity: 0.5;
-
}
-
header > .title {
-
text-align: center;
-
}
-
header > a.micro {
-
text-decoration: none;
-
font-size: 0.8rem;
-
text-align: right;
-
opacity: 0.5;
-
}
-
header > a.micro:hover {
-
opacity: 1;
-
}
-
main {
-
background: #ccc;
-
display: flex;
-
flex-direction: column;
-
flex-grow: 1;
-
padding: 0.25rem 0.5rem;
-
}
-
p {
-
margin: 1rem 0 0;
-
text-align: center;
-
}
-
.parent-host {
-
font-weight: bold;
-
color: #48c;
-
display: inline-block;
-
padding: 0 0.125rem;
-
border-radius: 0.25rem;
-
border: 1px solid #aaa;
-
font-size: 0.8rem;
-
}
-
-
#loader {
-
display: flex;
-
flex-grow: 1;
-
justify-content: center;
-
align-items: center;
-
}
-
.spinner {
-
animation: rotation 1.618s ease-in-out infinite;
-
border-radius: 50%;
-
border: 3px dashed #434;
-
box-sizing: border-box;
-
display: inline-block;
-
height: 1.5em;
-
width: 1.5em;
-
}
-
@keyframes rotation {
-
0% { transform: rotate(0deg) }
-
100% { transform: rotate(360deg) }
-
}
-
-
#user-info {
-
flex-grow: 1;
-
display: flex;
-
flex-direction: column;
-
justify-content: center;
-
}
-
#action {
-
background: #eee;
-
display: flex;
-
justify-content: space-between;
-
padding: 0.5rem 0.25rem 0.5rem 0.5rem;
-
font-size: 0.8rem;
-
align-items: baseline;
-
border-radius: 0.5rem;
-
border: 1px solid #bbb;
-
cursor: pointer;
-
}
-
#action:hover {
-
background: #fff;
-
}
-
#allow {
-
background: transparent;
-
border: none;
-
border-left: 1px solid #bbb;
-
padding: 0 0.5rem;
-
color: #375;
-
font: inherit;
-
cursor: pointer;
-
}
-
#action:hover #allow {
-
color: #285;
-
}
-
-
#or {
-
font-size: 0.8rem;
-
text-align: center;
-
}
-
#or p {
-
margin: 0 0 1rem;
-
}
-
-
input#handle {
-
border: none;
-
border-bottom: 1px dashed #aaa;
-
background: transparent;
-
}
-
-
.hidden {
-
display: none !important;
-
}
-
-
</style>
-
-
<div class="wrap unframed">
-
<header>
-
<div class="empty">🔒</div>
-
<code class="title" style="font-family: monospace;"
-
>who-am-i</code>
-
<a href="https://microcosm.blue" target="_blank" class="micro"
-
><span style="color: #f396a9">m</span
-
><span style="color: #f49c5c">i</span
-
><span style="color: #c7b04c">c</span
-
><span style="color: #92be4c">r</span
-
><span style="color: #4ec688">o</span
-
><span style="color: #51c2b6">c</span
-
><span style="color: #54bed7">o</span
-
><span style="color: #8fb1f1">s</span
-
><span style="color: #ce9df1">m</span
-
></a>
-
</header>
-
-
<main>
-
{{> main}}
-
</main>
-
</div>