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

wip (more modest goals)

+46
who-am-i/readme.md
···
+
# who am i
+
+
a little auth service for microcosm demos
+
+
**you probably SHOULD NOT USE THIS in any serious environment**
+
+
for now the deployment is restricted to microcosm -- expanding it for wider use likely requires solving a number of challenges that oauth exists for.
+
+
+
## a little auth service
+
+
- you drop an iframe and a short few lines of JS on your web page, and get a nice-ish atproto login prompt.
+
- if the user has ever authorized this service before (and within some expiration), they will be presented with an in-frame one-click option to proceed.
+
- otherwise they get bounced over to the normal atproto oauth flow (in a popup or new tab)
+
- you get a callback containing
+
- a verified DID and handle
+
- a JWT containing the same that can be verified by public key
+
- **no write permissions** or any atproto permissions at all, just a verified identity
+
+
**you probably SHOULD NOT USE THIS in any serious environment**
+
+
+
### problems
+
+
- clickjacking: if this were allowed on arbitrary domains, malicious sites could trick users into proving their atproto identity.
+
- all the other problems oauth exists to solve: it's a little tricky to hook around the oauth flow so there are probably some annoying attacks.
+
- auth in front of auth: it's just a bit awkward to run an auth service that acts as an intermediary for a more-real auth behind it, but that's worse, less secure, and doesn't conform to any standards.
+
+
so, **you probably SHOULD NOT USE THIS in any serious environment**
+
+
+
## why
+
+
sometimes you want to make a thing that people can use with an atproto identity, and you might not want to let them put in any else's identity. apps that operate on public data like skircle, cred.blue, and the microcosm spacedust notifications demo don't require any special permission to operate for any user, and that's sometimes fine, but sometimes creepy/stalker-y/etc.
+
+
to avoid building a small torment nexus for a microcosm demo (while also not wanting to get deep into oauth or operate a demo-specific auth backend), i made this little service to just get a verified identity.
+
+
note: **you probably SHOULD NOT USE THIS in any serious environment**
+
+
---
+
+
since the requirements (read-only, just verifying identity) seem modest, i was hoping that a fairly simple implementation could be Good Enough, but in the time that i was willing to spend on it, the simple version without major obvious weaknesses i was hoping for didn't emerge.
+
+
it's still nice to have an explicit opt-in on a per-demo basis for microcosm so it will be used for that. it's allow-listed for the microcosm domain however (so not deployed on any adversarial hosting pages), so it's simultaenously overkill and restrictive.
+
+
i will get back to oauth eventually and hopefully roll out a microcosm service to make it easy for clients, but there are a few more things in the pipeline to get to first.
+1
who-am-i/src/expiring_task_map.rs
···
.run_until_cancelled(sleep(expiration))
.await
.is_some()
+
// is Some if the (sleep) task completed first
{
map.remove(&k);
cancel.cancel();
+6 -6
who-am-i/src/main.rs
···
/// Hosts who are allowed to one-click auth
///
/// Pass this argument multiple times to allow multiple hosts
-
#[arg(long, short = 'o', action = ArgAction::Append)]
-
one_click: Vec<String>,
+
#[arg(long = "allow_host", short = 'a', action = ArgAction::Append)]
+
allowed_hosts: Vec<String>,
}
#[tokio::main]
···
let args = Args::parse();
-
if args.one_click.is_empty() {
+
if args.allowed_hosts.is_empty() {
panic!("at least one --one-click host must be set");
}
-
println!("starting with allowed hosts:");
-
for host in &args.one_click {
+
println!("starting with allowed_hosts hosts:");
+
for host in &args.allowed_hosts {
println!(" - {host}");
}
-
serve(shutdown, args.app_secret, args.one_click, args.dev).await;
+
serve(shutdown, args.app_secret, args.allowed_hosts, args.dev).await;
}
+57 -24
who-am-i/src/server.rs
···
use handlebars::{Handlebars, handlebars_helper};
use serde::Deserialize;
-
use serde_json::json;
+
use serde_json::{Value, json};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
···
#[derive(Clone)]
struct AppState {
pub key: Key,
-
pub one_clicks: Arc<HashSet<String>>,
+
pub allowed_hosts: Arc<HashSet<String>>,
pub engine: AppEngine,
pub oauth: Arc<OAuth>,
-
pub resolving: ExpiringTaskMap<Result<String, ResolveHandleError>>,
+
pub resolve_handles: ExpiringTaskMap<Result<String, ResolveHandleError>>,
pub shutdown: CancellationToken,
}
···
pub async fn serve(
shutdown: CancellationToken,
app_secret: String,
-
one_click: Vec<String>,
+
allowed_hosts: Vec<String>,
dev: bool,
) {
let mut hbs = Handlebars::new();
···
hbs.register_templates_directory("templates", Default::default())
.unwrap();
-
handlebars_helper!(json: |v: String| serde_json::to_string(&v).unwrap());
+
handlebars_helper!(json: |v: Value| serde_json::to_string(&v).unwrap());
hbs.register_helper("json", Box::new(json));
// clients have to pick up their identity-resolving tasks within this period
···
let state = AppState {
engine: Engine::new(hbs),
key: Key::from(app_secret.as_bytes()), // TODO: via config
-
one_clicks: Arc::new(HashSet::from_iter(one_click)),
+
allowed_hosts: Arc::new(HashSet::from_iter(allowed_hosts)),
oauth: Arc::new(oauth),
-
resolving: ExpiringTaskMap::new(task_pickup_expiration),
+
resolve_handles: ExpiringTaskMap::new(task_pickup_expiration),
shutdown: shutdown.clone(),
};
···
async fn prompt(
State(AppState {
-
one_clicks,
+
allowed_hosts,
engine,
oauth,
-
resolving,
+
resolve_handles,
shutdown,
..
}): State<AppState>,
···
let Some(parent_host) = url.host_str() else {
return "could nto get host from url".into_response();
};
-
if !one_clicks.contains(parent_host) {
-
return format!("host {parent_host:?} not in one_clicks, disallowing for now")
+
if !allowed_hosts.contains(parent_host) {
+
return format!("host {parent_host:?} not in allowed_hosts, disallowing for now")
.into_response();
}
if let Some(did) = jar.get(DID_COOKIE_KEY) {
···
return "did from cookie failed to parse".into_response();
};
-
let fetch_key = resolving.dispatch(
+
let fetch_key = resolve_handles.dispatch(
{
let oauth = oauth.clone();
let did = did.clone();
···
fetch_key: String,
}
async fn user_info(
-
State(AppState { resolving, .. }): State<AppState>,
+
State(AppState {
+
resolve_handles, ..
+
}): State<AppState>,
Query(params): Query<UserInfoParams>,
) -> impl IntoResponse {
-
let Some(task_handle) = resolving.take(&params.fetch_key) else {
+
let Some(task_handle) = resolve_handles.take(&params.fetch_key) else {
return "oops, task does not exist or is gone".into_response();
};
if let Ok(handle) = task_handle.await.unwrap() {
···
#[derive(Debug, Deserialize)]
struct BeginOauthParams {
handle: String,
+
flow: String,
}
async fn start_oauth(
State(AppState { oauth, .. }): State<AppState>,
Query(params): Query<BeginOauthParams>,
jar: SignedCookieJar,
+
headers: HeaderMap,
) -> (SignedCookieJar, Redirect) {
// if any existing session was active, clear it first
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");
+
};
+
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..
+
}
+
eprintln!("auth_url {auth_url}");
+
(jar, Redirect::to(&auth_url))
}
impl OAuthCompleteError {
fn to_error_response(&self, engine: AppEngine) -> Response {
-
let (_level, _desc) = match self {
-
OAuthCompleteError::Denied { .. } => {
-
let status = StatusCode::FORBIDDEN;
-
return (status, RenderHtml("auth-fail", engine, json!({}))).into_response();
+
let (level, desc) = match self {
+
OAuthCompleteError::Denied { description, .. } => {
+
("warn", format!("asdf: {description:?}"))
}
OAuthCompleteError::Failed { .. } => (
"error",
-
"Something went wrong while requesting permission, sorry!",
+
"Something went wrong while requesting permission, sorry!".to_string(),
),
OAuthCompleteError::CallbackFailed(_) => (
"error",
-
"Something went wrong after permission was granted, sorry!",
+
"Something went wrong after permission was granted, sorry!".to_string(),
),
OAuthCompleteError::NoDid => (
"error",
-
"Something went wrong when trying to confirm your identity, sorry!",
+
"Something went wrong when trying to confirm your identity, sorry!".to_string(),
),
};
-
todo!();
+
(
+
if level == "warn" {
+
StatusCode::FORBIDDEN
+
} else {
+
StatusCode::INTERNAL_SERVER_ERROR
+
},
+
RenderHtml(
+
"auth-fail",
+
engine,
+
json!({
+
"reason": desc,
+
}),
+
),
+
)
+
.into_response()
}
}
async fn complete_oauth(
State(AppState {
engine,
-
resolving,
+
resolve_handles,
oauth,
shutdown,
..
···
let jar = jar.add(cookie);
-
let fetch_key = resolving.dispatch(
+
let fetch_key = resolve_handles.dispatch(
{
let oauth = oauth.clone();
let did = did.clone();
+11 -5
who-am-i/templates/auth-fail.hbs
···
{{#*inline "main"}}
<p>
-
Share your identity with
-
<span class="parent-host">{{ parent_host }}</span>?
+
Auth failed: {{ reason }}
</p>
<div id="user-info">
-
<div id="action">
auth failed.
-
</form>
</div>
+
<script>
+
// TODO: tie this back to its source...........
+
+
localStorage.setItem("who-am-i", JSON.stringify({
+
result: "fail",
+
reason: "alskfjlaskdjf",
+
}));
+
window.close();
+
</script>
{{/inline}}
-
{{#> prompt-base}}{{/prompt-base}}
+
{{#> return-base}}{{/return-base}}
+1
who-am-i/templates/authorized.hbs
···
// TODO: tie this back to its source...........
localStorage.setItem("who-am-i", JSON.stringify({
+
result: "success",
did: {{{json did}}},
fetch_key: {{{json fetch_key}}},
}));
+21 -4
who-am-i/templates/prompt-anon.hbs
···
{{#*inline "main"}}
<p>
-
Share your identity with
-
<span class="parent-host">{{ parent_host }}</span>?
+
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">
···
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;
···
if (!details) {
console.error("hmm, heard from localstorage but did not get DID");
}
-
var parsed = JSON.parse(details);
+
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');
-
loaderEl.classList.remove('hidden');
lookUpAndShare(parsed.fetch_key);
});
}
+3 -1
who-am-i/templates/prompt-base.hbs
···
margin: 1rem 0 0;
text-align: center;
}
+
p.detail {
+
font-size: 0.8rem;
+
}
.parent-host {
font-weight: bold;
color: #48c;
···
{{> main}}
</main>
</div>
-
+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>