forked from
microcosm.blue/microcosm-rs
Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
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> wants 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-input" class="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-view" class="handle"></span>
26 <button id="allow">Allow</button>
27 </div>
28</div>
29
30<div id="need-storage" class="hidden">
31 <p class="problem">Sorry, your browser is blocking access.</p>
32 <p>
33 Try <a href="/" target="_blank">connecting directly</a> first (but no promises).
34 Clicking <button id="desperation">this button</button> might also help.
35 </p>
36</div>
37
38
39
40<script>
41const errorEl = document.getElementById('error-message');
42const promptEl = document.getElementById('prompt');
43const loaderEl = document.getElementById('loader');
44const infoEl = document.getElementById('user-info');
45const handleInputEl = document.getElementById('handle-input');
46const handleViewEl = document.getElementById('handle-view');
47const formEl = document.getElementById('form-action'); // for anon
48const allowEl = document.getElementById('handle-action'); // for known-did
49const connectEl = document.getElementById('connect'); // for anon
50const needStorageEl = document.getElementById('need-storage'); // for safari/frame isolation
51const desperationEl = document.getElementById('desperation');
52
53function err(e, msg) {
54 loaderEl.classList.add('hidden');
55 errorEl.classList.remove('hidden');
56 errorEl.textContent = msg || e;
57 throw new Error(e);
58}
59
60// already-known user
61({{{json did}}}) && (async () => {
62 const handle = await lookUp({{{json fetch_key}}});
63 loaderEl.classList.add('hidden');
64 handleViewEl.textContent = `@${handle}`;
65 allowEl.addEventListener('click', () => shareAllow(handle, {{{json token}}}));
66})();
67
68// anon user
69formEl.onsubmit = e => {
70 e.preventDefault();
71 loaderEl.classList.remove('hidden');
72 // TODO: include expected referer! (..this system is probably bad)
73 // maybe a random localstorage key that we specifically listen for?
74 const url = new URL('/auth', window.location);
75 url.searchParams.set('handle', handleInputEl.value);
76 window.open(url, '_blank');
77};
78
79// check if we may be partitioned, preventing access after auth completion
80// this should only happen if on a browser that implements storage access api
81if ('hasStorageAccess' in document) {
82 document.hasStorageAccess().then((hasAccess) => {
83 if (!hasAccess) {
84 promptEl.classList.add('hidden');
85 infoEl.classList.add('hidden');
86 needStorageEl.classList.remove('hidden');
87 desperation.addEventListener('click', () => {
88 document.requestStorageAccess({
89 cookies: true,
90 localStorage: true,
91 }).then(
92 () => {
93 desperation.textContent = "(maybe helped?)";
94 setTimeout(() => location.reload(), 350);
95 },
96 () => desperation.textContent = "(doubtful)",
97 );
98 })
99 }
100 });
101}
102
103window.addEventListener('storage', async e => {
104 // here's a fun minor vuln: we can't tell which flow triggers the storage event.
105 // so if you have two flows going, it grants for both (or the first responder?) if you grant for either.
106 // (letting this slide while parent pages are allowlisted to microcosm only)
107
108 if (e.key !== 'who-am-i') return;
109 if (e.newValue === null) return;
110
111 const details = e.newValue;
112 if (!details) {
113 console.error("hmm, heard from localstorage but did not get DID", details, e);
114 err('sorry, something went wrong getting your details');
115 }
116
117 let parsed;
118 try {
119 parsed = JSON.parse(details);
120 } catch (e) {
121 err(e, "something went wrong getting the details back");
122 }
123
124 const fail = (e, msg) => {
125 loaderEl.classList.add('hidden');
126 formEl.classList.remove('hidden');
127 handleInputEl.focus();
128 handleInputEl.select();
129 err(e, msg);
130 }
131
132 if (parsed.result === "fail") {
133 fail(`uh oh: ${parsed.reason}`);
134 }
135
136 if (parsed.result === "deny") {
137 fail(parsed.reason);
138 }
139
140 infoEl.classList.add('hidden');
141
142 const handle = await lookUp(parsed.fetch_key);
143
144 shareAllow(handle, parsed.token);
145});
146
147async function lookUp(fetch_key) {
148 let info;
149 try {
150 const resp = await fetch('/user-info', {
151 method: 'POST',
152 headers: { 'Content-Type': 'application/json' },
153 body: JSON.stringify({ fetch_key }),
154 });
155 if (!resp.ok) throw resp;
156 info = await resp.json();
157 } catch (e) {
158 err(e, `failed to resolve handle from DID with ${fetch_key}`);
159 }
160 return info.handle;
161}
162
163const parentTarget = {{{json parent_target}}} ?? {{{json parent_origin}}};
164
165const shareAllow = (handle, token) => {
166 try {
167 top.postMessage(
168 { action: "allow", handle, token },
169 parentTarget,
170 );
171 } catch (e) {
172 err(e, 'Identity verified but failed to connect with app');
173 };
174 promptEl.textContent = '✔️ shared';
175}
176
177const shareDeny = reason => {
178 top.postMessage(
179 { action: "deny", reason },
180 parentTarget,
181 );
182}
183</script>
184
185{{/inline}}
186
187{{#> base-framed}}{{/base-framed}}