···11+# who am i
22+33+a little auth service for microcosm demos
44+55+**you probably SHOULD NOT USE THIS in any serious environment**
66+77+for now the deployment is restricted to microcosm -- expanding it for wider use likely requires solving a number of challenges that oauth exists for.
88+99+1010+## a little auth service
1111+1212+- you drop an iframe and a short few lines of JS on your web page, and get a nice-ish atproto login prompt.
1313+- 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.
1414+- otherwise they get bounced over to the normal atproto oauth flow (in a popup or new tab)
1515+- you get a callback containing
1616+ - a verified DID and handle
1717+ - a JWT containing the same that can be verified by public key
1818+- **no write permissions** or any atproto permissions at all, just a verified identity
1919+2020+**you probably SHOULD NOT USE THIS in any serious environment**
2121+2222+2323+### problems
2424+2525+- clickjacking: if this were allowed on arbitrary domains, malicious sites could trick users into proving their atproto identity.
2626+- 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.
2727+- 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.
2828+2929+so, **you probably SHOULD NOT USE THIS in any serious environment**
3030+3131+3232+## why
3333+3434+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.
3535+3636+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.
3737+3838+note: **you probably SHOULD NOT USE THIS in any serious environment**
3939+4040+---
4141+4242+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.
4343+4444+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.
4545+4646+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
···4949 .run_until_cancelled(sleep(expiration))
5050 .await
5151 .is_some()
5252+ // is Some if the (sleep) task completed first
5253 {
5354 map.remove(&k);
5455 cancel.cancel();
+6-6
who-am-i/src/main.rs
···2121 /// Hosts who are allowed to one-click auth
2222 ///
2323 /// Pass this argument multiple times to allow multiple hosts
2424- #[arg(long, short = 'o', action = ArgAction::Append)]
2525- one_click: Vec<String>,
2424+ #[arg(long = "allow_host", short = 'a', action = ArgAction::Append)]
2525+ allowed_hosts: Vec<String>,
2626}
27272828#[tokio::main]
···34343535 let args = Args::parse();
36363737- if args.one_click.is_empty() {
3737+ if args.allowed_hosts.is_empty() {
3838 panic!("at least one --one-click host must be set");
3939 }
40404141- println!("starting with allowed hosts:");
4242- for host in &args.one_click {
4141+ println!("starting with allowed_hosts hosts:");
4242+ for host in &args.allowed_hosts {
4343 println!(" - {host}");
4444 }
45454646- serve(shutdown, args.app_secret, args.one_click, args.dev).await;
4646+ serve(shutdown, args.app_secret, args.allowed_hosts, args.dev).await;
4747}
+57-24
who-am-i/src/server.rs
···1414use handlebars::{Handlebars, handlebars_helper};
15151616use serde::Deserialize;
1717-use serde_json::json;
1717+use serde_json::{Value, json};
1818use std::collections::HashSet;
1919use std::sync::Arc;
2020use std::time::Duration;
···3434#[derive(Clone)]
3535struct AppState {
3636 pub key: Key,
3737- pub one_clicks: Arc<HashSet<String>>,
3737+ pub allowed_hosts: Arc<HashSet<String>>,
3838 pub engine: AppEngine,
3939 pub oauth: Arc<OAuth>,
4040- pub resolving: ExpiringTaskMap<Result<String, ResolveHandleError>>,
4040+ pub resolve_handles: ExpiringTaskMap<Result<String, ResolveHandleError>>,
4141 pub shutdown: CancellationToken,
4242}
4343···5050pub async fn serve(
5151 shutdown: CancellationToken,
5252 app_secret: String,
5353- one_click: Vec<String>,
5353+ allowed_hosts: Vec<String>,
5454 dev: bool,
5555) {
5656 let mut hbs = Handlebars::new();
···5858 hbs.register_templates_directory("templates", Default::default())
5959 .unwrap();
60606161- handlebars_helper!(json: |v: String| serde_json::to_string(&v).unwrap());
6161+ handlebars_helper!(json: |v: Value| serde_json::to_string(&v).unwrap());
6262 hbs.register_helper("json", Box::new(json));
63636464 // clients have to pick up their identity-resolving tasks within this period
···6969 let state = AppState {
7070 engine: Engine::new(hbs),
7171 key: Key::from(app_secret.as_bytes()), // TODO: via config
7272- one_clicks: Arc::new(HashSet::from_iter(one_click)),
7272+ allowed_hosts: Arc::new(HashSet::from_iter(allowed_hosts)),
7373 oauth: Arc::new(oauth),
7474- resolving: ExpiringTaskMap::new(task_pickup_expiration),
7474+ resolve_handles: ExpiringTaskMap::new(task_pickup_expiration),
7575 shutdown: shutdown.clone(),
7676 };
7777···96969797async fn prompt(
9898 State(AppState {
9999- one_clicks,
9999+ allowed_hosts,
100100 engine,
101101 oauth,
102102- resolving,
102102+ resolve_handles,
103103 shutdown,
104104 ..
105105 }): State<AppState>,
···118118 let Some(parent_host) = url.host_str() else {
119119 return "could nto get host from url".into_response();
120120 };
121121- if !one_clicks.contains(parent_host) {
122122- return format!("host {parent_host:?} not in one_clicks, disallowing for now")
121121+ if !allowed_hosts.contains(parent_host) {
122122+ return format!("host {parent_host:?} not in allowed_hosts, disallowing for now")
123123 .into_response();
124124 }
125125 if let Some(did) = jar.get(DID_COOKIE_KEY) {
···127127 return "did from cookie failed to parse".into_response();
128128 };
129129130130- let fetch_key = resolving.dispatch(
130130+ let fetch_key = resolve_handles.dispatch(
131131 {
132132 let oauth = oauth.clone();
133133 let did = did.clone();
···164164 fetch_key: String,
165165}
166166async fn user_info(
167167- State(AppState { resolving, .. }): State<AppState>,
167167+ State(AppState {
168168+ resolve_handles, ..
169169+ }): State<AppState>,
168170 Query(params): Query<UserInfoParams>,
169171) -> impl IntoResponse {
170170- let Some(task_handle) = resolving.take(¶ms.fetch_key) else {
172172+ let Some(task_handle) = resolve_handles.take(¶ms.fetch_key) else {
171173 return "oops, task does not exist or is gone".into_response();
172174 };
173175 if let Ok(handle) = task_handle.await.unwrap() {
···180182#[derive(Debug, Deserialize)]
181183struct BeginOauthParams {
182184 handle: String,
185185+ flow: String,
183186}
184187async fn start_oauth(
185188 State(AppState { oauth, .. }): State<AppState>,
186189 Query(params): Query<BeginOauthParams>,
187190 jar: SignedCookieJar,
191191+ headers: HeaderMap,
188192) -> (SignedCookieJar, Redirect) {
189193 // if any existing session was active, clear it first
190194 let jar = jar.remove(DID_COOKIE_KEY);
191195196196+ if let Some(referrer) = headers.get(REFERER) {
197197+ if let Ok(referrer) = referrer.to_str() {
198198+ println!("referrer: {referrer}");
199199+ } else {
200200+ eprintln!("referer contained opaque bytes");
201201+ };
202202+ } else {
203203+ eprintln!("no referrer");
204204+ };
205205+192206 let auth_url = oauth.begin(¶ms.handle).await.unwrap();
207207+ let flow = params.flow;
208208+ if !flow.chars().all(|c| char::is_ascii_alphanumeric(&c)) {
209209+ panic!("invalid flow (injection attempt?)"); // should probably just url-encode it instead..
210210+ }
211211+ eprintln!("auth_url {auth_url}");
212212+193213 (jar, Redirect::to(&auth_url))
194214}
195215196216impl OAuthCompleteError {
197217 fn to_error_response(&self, engine: AppEngine) -> Response {
198198- let (_level, _desc) = match self {
199199- OAuthCompleteError::Denied { .. } => {
200200- let status = StatusCode::FORBIDDEN;
201201- return (status, RenderHtml("auth-fail", engine, json!({}))).into_response();
218218+ let (level, desc) = match self {
219219+ OAuthCompleteError::Denied { description, .. } => {
220220+ ("warn", format!("asdf: {description:?}"))
202221 }
203222 OAuthCompleteError::Failed { .. } => (
204223 "error",
205205- "Something went wrong while requesting permission, sorry!",
224224+ "Something went wrong while requesting permission, sorry!".to_string(),
206225 ),
207226 OAuthCompleteError::CallbackFailed(_) => (
208227 "error",
209209- "Something went wrong after permission was granted, sorry!",
228228+ "Something went wrong after permission was granted, sorry!".to_string(),
210229 ),
211230 OAuthCompleteError::NoDid => (
212231 "error",
213213- "Something went wrong when trying to confirm your identity, sorry!",
232232+ "Something went wrong when trying to confirm your identity, sorry!".to_string(),
214233 ),
215234 };
216216- todo!();
235235+ (
236236+ if level == "warn" {
237237+ StatusCode::FORBIDDEN
238238+ } else {
239239+ StatusCode::INTERNAL_SERVER_ERROR
240240+ },
241241+ RenderHtml(
242242+ "auth-fail",
243243+ engine,
244244+ json!({
245245+ "reason": desc,
246246+ }),
247247+ ),
248248+ )
249249+ .into_response()
217250 }
218251}
219252220253async fn complete_oauth(
221254 State(AppState {
222255 engine,
223223- resolving,
256256+ resolve_handles,
224257 oauth,
225258 shutdown,
226259 ..
···241274242275 let jar = jar.add(cookie);
243276244244- let fetch_key = resolving.dispatch(
277277+ let fetch_key = resolve_handles.dispatch(
245278 {
246279 let oauth = oauth.clone();
247280 let did = did.clone();
+11-5
who-am-i/templates/auth-fail.hbs
···11{{#*inline "main"}}
22<p>
33- Share your identity with
44- <span class="parent-host">{{ parent_host }}</span>?
33+ Auth failed: {{ reason }}
54</p>
6576<div id="user-info">
88- <div id="action">
97 auth failed.
1010- </form>
118</div>
1291010+<script>
1111+// TODO: tie this back to its source...........
1212+1313+localStorage.setItem("who-am-i", JSON.stringify({
1414+ result: "fail",
1515+ reason: "alskfjlaskdjf",
1616+}));
1717+window.close();
1818+</script>
1319{{/inline}}
14201515-{{#> prompt-base}}{{/prompt-base}}
2121+{{#> return-base}}{{/return-base}}
+1
who-am-i/templates/authorized.hbs
···66// TODO: tie this back to its source...........
7788localStorage.setItem("who-am-i", JSON.stringify({
99+ result: "success",
910 did: {{{json did}}},
1011 fetch_key: {{{json fetch_key}}},
1112}));
+21-4
who-am-i/templates/prompt-anon.hbs
···11{{#*inline "main"}}
22<p>
33- Share your identity with
44- <span class="parent-host">{{ parent_host }}</span>?
33+ Connect your ATmosphere
44+</p>
55+66+<p class="detail">
77+ <span class="parent-host">{{ parent_host }}</span> would like to confirm your handle
58</p>
69710<div id="loader" class="hidden">
···2326const formEl = document.getElementById('action');
2427const handleEl = document.getElementById('handle');
25282929+function err(msg) {
3030+3131+}
3232+2633formEl.onsubmit = e => {
2734 e.preventDefault();
2835 // TODO: include expected referer! (..this system is probably bad)
2936 // maybe a random localstorage key that we specifically listen for?
3037 var url = new URL('/auth', window.location);
3138 url.searchParams.set('handle', handleEl.value);
3939+ url.searchParams.set('flow', {{{json flow}}});
3240 var flow = window.open(url, '_blank');
3341 window.f = flow;
3442···3745 if (!details) {
3846 console.error("hmm, heard from localstorage but did not get DID");
3947 }
4040- var parsed = JSON.parse(details);
4848+ loaderEl.classList.remove('hidden');
4949+5050+ try {
5151+ var parsed = JSON.parse(details);
5252+ } catch (e) {
5353+ return err("something went wrong getting the details back");
5454+ }
5555+5656+ if (parsed.result === "fail") {
5757+ return err(`something went wrong getting permission to share: ${parsed.reason}`);
5858+ }
41594260 infoEl.classList.add('hidden');
4343- loaderEl.classList.remove('hidden');
4461 lookUpAndShare(parsed.fetch_key);
4562 });
4663}