tangled
alpha
login
or
join now
bad-example.com
/
microcosm-links
7
fork
atom
APIs for links and references in the ATmosphere
7
fork
atom
overview
issues
pulls
pipelines
more error handling
bad-example.com
8 months ago
3d9a9d32
867098a8
+204
-306
9 changed files
expand all
collapse all
unified
split
who-am-i
demo
index.html
src
oauth.rs
server.rs
static
style.css
templates
auth-fail.hbs
prompt-anon.hbs
prompt-error.hbs
prompt.hbs
return-base.hbs
+1
who-am-i/demo/index.html
···
19
19
(whoami => {
20
20
const handleMessage = ev => {
21
21
if (ev.source !== whoami.contentWindow) {
22
22
+
// TODO: ALSO CHECK ev.origin!!!!
22
23
console.log('nah');
23
24
return;
24
25
}
+1
-5
who-am-i/src/oauth.rs
···
54
54
}
55
55
56
56
#[derive(Debug, Error)]
57
57
-
#[error(transparent)]
58
58
-
pub struct AuthStartError(#[from] atrium_oauth::Error);
59
59
-
60
60
-
#[derive(Debug, Error)]
61
57
pub enum OAuthCompleteError {
62
58
#[error("the user denied request: {description:?} (from {issuer:?})")]
63
59
Denied {
···
124
120
})
125
121
}
126
122
127
127
-
pub async fn begin(&self, handle: &str) -> Result<String, AuthStartError> {
123
123
+
pub async fn begin(&self, handle: &str) -> Result<String, atrium_oauth::Error> {
128
124
let auth_opts = AuthorizeOptions {
129
125
scopes: READONLY_SCOPE.to_vec(),
130
126
..Default::default()
+35
-21
who-am-i/src/server.rs
···
145
145
if !allowed_hosts.contains(parent_host) {
146
146
return err("Login is not allowed on this page", false);
147
147
}
148
148
+
let parent_origin = url.origin().ascii_serialization();
149
149
+
if parent_origin == "null" {
150
150
+
return err("Referer origin is opaque", true);
151
151
+
}
148
152
if let Some(did) = jar.get(DID_COOKIE_KEY) {
149
153
let Ok(did) = Did::new(did.value_trimmed().to_string()) else {
150
154
return err("Bad cookie", false);
···
166
170
"did": did,
167
171
"fetch_key": fetch_key,
168
172
"parent_host": parent_host,
173
173
+
"parent_origin": parent_origin,
169
174
}),
170
175
)
171
176
.into_response()
172
177
} else {
173
178
RenderHtml(
174
174
-
"prompt-anon",
179
179
+
"prompt",
175
180
engine,
176
181
json!({
177
182
"parent_host": parent_host,
183
183
+
"parent_origin": parent_origin,
178
184
}),
179
185
)
180
186
.into_response()
···
228
234
#[derive(Debug, Deserialize)]
229
235
struct BeginOauthParams {
230
236
handle: String,
231
231
-
flow: String,
232
237
}
233
238
async fn start_oauth(
234
234
-
State(AppState { oauth, .. }): State<AppState>,
239
239
+
State(AppState { oauth, engine, .. }): State<AppState>,
235
240
Query(params): Query<BeginOauthParams>,
236
241
jar: SignedCookieJar,
237
237
-
headers: HeaderMap,
238
238
-
) -> (SignedCookieJar, Redirect) {
242
242
+
) -> Response {
239
243
// if any existing session was active, clear it first
244
244
+
// ...this might help a confusion attack w multiple sign-in flows or smth
240
245
let jar = jar.remove(DID_COOKIE_KEY);
241
246
242
242
-
if let Some(referrer) = headers.get(REFERER) {
243
243
-
if let Ok(referrer) = referrer.to_str() {
244
244
-
println!("referrer: {referrer}");
245
245
-
} else {
246
246
-
eprintln!("referer contained opaque bytes");
247
247
-
};
248
248
-
} else {
249
249
-
eprintln!("no referrer");
250
250
-
};
247
247
+
use atrium_identity::Error as IdError;
248
248
+
use atrium_oauth::Error as OAuthError;
251
249
252
252
-
let auth_url = oauth.begin(¶ms.handle).await.unwrap();
253
253
-
let flow = params.flow;
254
254
-
if !flow.chars().all(|c| char::is_ascii_alphanumeric(&c)) {
255
255
-
panic!("invalid flow (injection attempt?)"); // should probably just url-encode it instead..
250
250
+
match oauth.begin(¶ms.handle).await {
251
251
+
Ok(auth_url) => (jar, Redirect::to(&auth_url)).into_response(),
252
252
+
Err(OAuthError::Identity(IdError::NotFound)) => {
253
253
+
let info = json!({ "reason": "handle not found" });
254
254
+
(StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response()
255
255
+
}
256
256
+
Err(OAuthError::Identity(IdError::AtIdentifier(r))) => {
257
257
+
let info = json!({ "reason": r });
258
258
+
(StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response()
259
259
+
}
260
260
+
Err(OAuthError::Identity(IdError::HttpStatus(StatusCode::NOT_FOUND))) => {
261
261
+
let info = json!({ "reason": "handle not found" });
262
262
+
(StatusCode::NOT_FOUND, RenderHtml("auth-fail", engine, info)).into_response()
263
263
+
}
264
264
+
Err(e) => {
265
265
+
eprintln!("begin auth failed: {e:?}");
266
266
+
let info = json!({ "reason": "unknown" });
267
267
+
(
268
268
+
StatusCode::INTERNAL_SERVER_ERROR,
269
269
+
RenderHtml("auth-fail", engine, info),
270
270
+
)
271
271
+
.into_response()
272
272
+
}
256
273
}
257
257
-
eprintln!("auth_url {auth_url}");
258
258
-
259
259
-
(jar, Redirect::to(&auth_url))
260
274
}
261
275
262
276
impl OAuthCompleteError {
+24
-2
who-am-i/static/style.css
···
60
60
max-width: 21rem;
61
61
}
62
62
63
63
+
#error-message {
64
64
+
font-size: 0.8rem;
65
65
+
color: #a31;
66
66
+
}
67
67
+
68
68
+
#error-message:not(.hidden) + #prompt {
69
69
+
display: none !important;
70
70
+
}
71
71
+
72
72
+
#error-message,
63
73
p {
64
74
margin: 1rem 0 0;
65
75
text-align: center;
76
76
+
}
77
77
+
p.detail {
78
78
+
font-size: 0.8rem;
66
79
}
67
80
.parent-host {
68
81
font-weight: bold;
···
93
106
0% { transform: rotate(0deg) }
94
107
100% { transform: rotate(360deg) }
95
108
}
109
109
+
/* loader visibility is mutually exclusive with its immediate sibling */
110
110
+
#loader:not(.hidden) + * {
111
111
+
display: none !important;
112
112
+
}
96
113
97
114
#user-info {
98
115
flex-grow: 1;
···
100
117
flex-direction: column;
101
118
justify-content: center;
102
119
}
103
103
-
#action {
120
120
+
.action {
104
121
background: #eee;
105
122
display: flex;
106
123
justify-content: space-between;
···
111
128
border: 1px solid #bbb;
112
129
cursor: pointer;
113
130
}
114
114
-
#action:hover {
131
131
+
.action:hover {
115
132
background: #fff;
116
133
}
134
134
+
#form-action:not(.hidden) + .action {
135
135
+
display: none !important;
136
136
+
}
137
137
+
138
138
+
#connect,
117
139
#allow {
118
140
background: transparent;
119
141
border: none;
+3
-4
who-am-i/templates/auth-fail.hbs
···
8
8
</div>
9
9
10
10
<script>
11
11
-
// TODO: tie this back to its source...........
12
12
-
13
11
localStorage.setItem("who-am-i", JSON.stringify({
14
12
result: "fail",
15
15
-
reason: "alskfjlaskdjf",
13
13
+
reason: {{{json reason}}},
16
14
}));
15
15
+
17
16
window.close();
18
17
</script>
19
18
{{/inline}}
20
19
21
21
-
{{#> return-base}}{{/return-base}}
20
20
+
{{#> base-framed}}{{/base-framed}}
-94
who-am-i/templates/prompt-anon.hbs
···
1
1
-
{{#*inline "main"}}
2
2
-
<p>
3
3
-
Connect your ATmosphere
4
4
-
</p>
5
5
-
6
6
-
<p class="detail">
7
7
-
<span class="parent-host">{{ parent_host }}</span> would like to confirm your handle
8
8
-
</p>
9
9
-
10
10
-
<div id="loader" class="hidden">
11
11
-
<span class="spinner"></span>
12
12
-
</div>
13
13
-
14
14
-
<div id="user-info">
15
15
-
<form id="action" action="/auth" method="GET" target="_blank">
16
16
-
<label>
17
17
-
@<input id="handle" name="handle" placeholder="example.bsky.social" />
18
18
-
</label>
19
19
-
<button id="allow" type="submit">connect</button>
20
20
-
</form>
21
21
-
</div>
22
22
-
23
23
-
<script>
24
24
-
var loaderEl = document.getElementById('loader');
25
25
-
var infoEl = document.getElementById('user-info');
26
26
-
const formEl = document.getElementById('action');
27
27
-
const handleEl = document.getElementById('handle');
28
28
-
29
29
-
function err(msg) {
30
30
-
31
31
-
}
32
32
-
33
33
-
formEl.onsubmit = e => {
34
34
-
e.preventDefault();
35
35
-
// TODO: include expected referer! (..this system is probably bad)
36
36
-
// maybe a random localstorage key that we specifically listen for?
37
37
-
var url = new URL('/auth', window.location);
38
38
-
url.searchParams.set('handle', handleEl.value);
39
39
-
url.searchParams.set('flow', {{{json flow}}});
40
40
-
var flow = window.open(url, '_blank');
41
41
-
window.f = flow;
42
42
-
43
43
-
window.addEventListener('storage', e => {
44
44
-
var details = localStorage.getItem("who-am-i");
45
45
-
if (!details) {
46
46
-
console.error("hmm, heard from localstorage but did not get DID");
47
47
-
}
48
48
-
loaderEl.classList.remove('hidden');
49
49
-
50
50
-
try {
51
51
-
var parsed = JSON.parse(details);
52
52
-
} catch (e) {
53
53
-
return err("something went wrong getting the details back");
54
54
-
}
55
55
-
56
56
-
if (parsed.result === "fail") {
57
57
-
return err(`something went wrong getting permission to share: ${parsed.reason}`);
58
58
-
}
59
59
-
60
60
-
infoEl.classList.add('hidden');
61
61
-
lookUpAndShare(parsed.fetch_key);
62
62
-
});
63
63
-
}
64
64
-
65
65
-
function lookUpAndShare(fetch_key) {
66
66
-
let user_info = new URL('/user-info', window.location);
67
67
-
user_info.searchParams.set('fetch-key', fetch_key);
68
68
-
fetch(user_info)
69
69
-
.then(resp => {
70
70
-
if (!resp.ok) throw new Error('request failed');
71
71
-
return resp.json();
72
72
-
})
73
73
-
.then(
74
74
-
({ handle }) => {
75
75
-
loaderEl.remove();
76
76
-
handleEl.textContent = `@${handle}`;
77
77
-
infoEl.classList.remove('hidden');
78
78
-
share(handle);
79
79
-
},
80
80
-
err => {
81
81
-
infoEl.textContent = 'ohno';
82
82
-
console.error(err);
83
83
-
},
84
84
-
);
85
85
-
}
86
86
-
87
87
-
function share(handle) {
88
88
-
top.postMessage({ source: 'whoami', handle }, '*'); // TODO: pass the referrer back from server
89
89
-
}
90
90
-
91
91
-
</script>
92
92
-
{{/inline}}
93
93
-
94
94
-
{{#> prompt-base}}{{/prompt-base}}
+12
-12
who-am-i/templates/prompt-error.hbs
···
1
1
{{#*inline "main"}}
2
2
-
<div class="prompt-error">
3
3
-
<p class="went-wrong">Something went wrong :(</p>
4
4
-
<p class="reason">{{ reason }}</p>
5
5
-
<p id="maybe-not-in-iframe" class="hidden">
6
6
-
Possibly related: this prompt is meant to be shown in an iframe, but it seems like it's not.
7
7
-
</p>
8
8
-
</div>
2
2
+
<div class="prompt-error">
3
3
+
<p class="went-wrong">Something went wrong :(</p>
4
4
+
<p class="reason">{{ reason }}</p>
5
5
+
<p id="maybe-not-in-iframe" class="hidden">
6
6
+
Possibly related: this prompt is meant to be shown in an iframe, but it seems like it's not.
7
7
+
</p>
8
8
+
</div>
9
9
10
10
-
<script>
11
11
-
if ({{{json check_frame}}} && window.self === window.top) {
12
12
-
document.getElementById('maybe-not-in-iframe').classList.remove('hidden');
13
13
-
}
14
14
-
</script>
10
10
+
<script>
11
11
+
if ({{{json check_frame}}} && window.self === window.top) {
12
12
+
document.getElementById('maybe-not-in-iframe').classList.remove('hidden');
13
13
+
}
14
14
+
</script>
15
15
{{/inline}}
16
16
17
17
{{#> base-framed}}{{/base-framed}}
+128
who-am-i/templates/prompt.hbs
···
1
1
+
{{#*inline "main"}}
2
2
+
<p>
3
3
+
Connect in the ATmosphere
4
4
+
</p>
5
5
+
6
6
+
<p id="error-message" class="hidden"></p>
7
7
+
8
8
+
<p id="prompt" class="detail">
9
9
+
<span class="parent-host">{{ parent_host }}</span> would like to confirm your handle
10
10
+
</p>
11
11
+
12
12
+
<div id="loader" {{#unless did}}class="hidden"{{/unless}}>
13
13
+
<span class="spinner"></span>
14
14
+
</div>
15
15
+
16
16
+
<div id="user-info">
17
17
+
<form id="form-action" action="/auth" method="GET" target="_blank" class="action {{#if did}}hidden{{/if}}">
18
18
+
<label>
19
19
+
@<input id="handle" name="handle" placeholder="example.bsky.social" />
20
20
+
</label>
21
21
+
<button id="connect" type="submit">connect</button>
22
22
+
</form>
23
23
+
24
24
+
<div id="handle-action" class="action">
25
25
+
<span id="handle"></span>
26
26
+
<button id="allow">Allow</button>
27
27
+
</div>
28
28
+
</div>
29
29
+
30
30
+
31
31
+
32
32
+
<script>
33
33
+
const errorEl = document.getElementById('error-message');
34
34
+
const promptEl = document.getElementById('prompt');
35
35
+
const loaderEl = document.getElementById('loader');
36
36
+
const infoEl = document.getElementById('user-info');
37
37
+
const handleEl = document.getElementById('handle');
38
38
+
const formEl = document.getElementById('form-action'); // for anon
39
39
+
const allowEl = document.getElementById('allow'); // for known-did
40
40
+
const connectEl = document.getElementById('connect'); // for anon
41
41
+
42
42
+
function err(e, msg) {
43
43
+
loaderEl.classList.add('hidden');
44
44
+
errorEl.classList.remove('hidden');
45
45
+
errorEl.textContent = msg || e;
46
46
+
throw new Error(e);
47
47
+
}
48
48
+
49
49
+
formEl && (formEl.onsubmit = e => {
50
50
+
e.preventDefault();
51
51
+
loaderEl.classList.remove('hidden');
52
52
+
// TODO: include expected referer! (..this system is probably bad)
53
53
+
// maybe a random localstorage key that we specifically listen for?
54
54
+
const url = new URL('/auth', window.location);
55
55
+
url.searchParams.set('handle', handleEl.value);
56
56
+
window.open(url, '_blank');
57
57
+
});
58
58
+
59
59
+
window.addEventListener('storage', async e => {
60
60
+
// here's a fun minor vuln: we can't tell which flow triggers the storage event.
61
61
+
// so if you have two flows going, it grants for both (or the first responder?) if you grant for either.
62
62
+
// (letting this slide while parent pages are allowlisted to microcosm only)
63
63
+
64
64
+
const fail = (e, msg) => {
65
65
+
loaderEl.classList.add('hidden');
66
66
+
formEl.classList.remove('hidden');
67
67
+
handleEl.focus();
68
68
+
handleEl.select();
69
69
+
err(e, msg);
70
70
+
}
71
71
+
72
72
+
const details = localStorage.getItem("who-am-i");
73
73
+
if (!details) {
74
74
+
console.error("hmm, heard from localstorage but did not get DID");
75
75
+
return;
76
76
+
}
77
77
+
localStorage.removeItem("who-am-i");
78
78
+
79
79
+
let parsed;
80
80
+
try {
81
81
+
parsed = JSON.parse(details);
82
82
+
} catch (e) {
83
83
+
err(e, "something went wrong getting the details back");
84
84
+
}
85
85
+
86
86
+
if (parsed.result === "fail") {
87
87
+
fail(`uh oh: ${parsed.reason}`);
88
88
+
}
89
89
+
90
90
+
infoEl.classList.add('hidden');
91
91
+
92
92
+
const handle = await lookUp(parsed.fetch_key);
93
93
+
94
94
+
shareAllow(handle);
95
95
+
});
96
96
+
97
97
+
const lookUp = async fetch_key => {
98
98
+
const user_info = new URL('/user-info', window.location);
99
99
+
user_info.searchParams.set('fetch-key', fetch_key);
100
100
+
let info;
101
101
+
try {
102
102
+
const resp = await fetch(user_info);
103
103
+
if (!resp.ok) throw resp;
104
104
+
info = await resp.json();
105
105
+
} catch (e) {
106
106
+
err(e, 'failed to resolve handle from DID')
107
107
+
}
108
108
+
return info.handle;
109
109
+
}
110
110
+
111
111
+
const shareAllow = handle => {
112
112
+
top.postMessage(
113
113
+
{ action: "allow", handle },
114
114
+
{{{json parent_host}}},
115
115
+
);
116
116
+
}
117
117
+
118
118
+
const shareDeny = reason => {
119
119
+
top.postMessage(
120
120
+
{ action: "deny", reason },
121
121
+
{{{json parent_origin}}},
122
122
+
);
123
123
+
}
124
124
+
</script>
125
125
+
126
126
+
{{/inline}}
127
127
+
128
128
+
{{#> base-framed}}{{/base-framed}}
-168
who-am-i/templates/return-base.hbs
···
1
1
-
<!doctype html>
2
2
-
3
3
-
<style>
4
4
-
body {
5
5
-
color: #434;
6
6
-
font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif;
7
7
-
margin: 0;
8
8
-
min-height: 100vh;
9
9
-
padding: 0;
10
10
-
}
11
11
-
.wrap {
12
12
-
border: 2px solid #221828;
13
13
-
border-radius: 0.5rem;
14
14
-
box-sizing: border-box;
15
15
-
overflow: hidden;
16
16
-
display: flex;
17
17
-
flex-direction: column;
18
18
-
height: 100vh;
19
19
-
}
20
20
-
.wrap.unframed {
21
21
-
border-radius: 0;
22
22
-
border-width: 0.4rem;
23
23
-
}
24
24
-
header {
25
25
-
background: #221828;
26
26
-
display: flex;
27
27
-
justify-content: space-between;
28
28
-
padding: 0 0.25rem;
29
29
-
color: #c9b;
30
30
-
display: flex;
31
31
-
gap: 0.5rem;
32
32
-
align-items: baseline;
33
33
-
}
34
34
-
header > * {
35
35
-
flex-basis: 33%;
36
36
-
}
37
37
-
header > .empty {
38
38
-
font-size: 0.8rem;
39
39
-
opacity: 0.5;
40
40
-
}
41
41
-
header > .title {
42
42
-
text-align: center;
43
43
-
}
44
44
-
header > a.micro {
45
45
-
text-decoration: none;
46
46
-
font-size: 0.8rem;
47
47
-
text-align: right;
48
48
-
opacity: 0.5;
49
49
-
}
50
50
-
header > a.micro:hover {
51
51
-
opacity: 1;
52
52
-
}
53
53
-
main {
54
54
-
background: #ccc;
55
55
-
display: flex;
56
56
-
flex-direction: column;
57
57
-
flex-grow: 1;
58
58
-
padding: 0.25rem 0.5rem;
59
59
-
}
60
60
-
p {
61
61
-
margin: 1rem 0 0;
62
62
-
text-align: center;
63
63
-
}
64
64
-
.parent-host {
65
65
-
font-weight: bold;
66
66
-
color: #48c;
67
67
-
display: inline-block;
68
68
-
padding: 0 0.125rem;
69
69
-
border-radius: 0.25rem;
70
70
-
border: 1px solid #aaa;
71
71
-
font-size: 0.8rem;
72
72
-
}
73
73
-
74
74
-
#loader {
75
75
-
display: flex;
76
76
-
flex-grow: 1;
77
77
-
justify-content: center;
78
78
-
align-items: center;
79
79
-
}
80
80
-
.spinner {
81
81
-
animation: rotation 1.618s ease-in-out infinite;
82
82
-
border-radius: 50%;
83
83
-
border: 3px dashed #434;
84
84
-
box-sizing: border-box;
85
85
-
display: inline-block;
86
86
-
height: 1.5em;
87
87
-
width: 1.5em;
88
88
-
}
89
89
-
@keyframes rotation {
90
90
-
0% { transform: rotate(0deg) }
91
91
-
100% { transform: rotate(360deg) }
92
92
-
}
93
93
-
94
94
-
#user-info {
95
95
-
flex-grow: 1;
96
96
-
display: flex;
97
97
-
flex-direction: column;
98
98
-
justify-content: center;
99
99
-
}
100
100
-
#action {
101
101
-
background: #eee;
102
102
-
display: flex;
103
103
-
justify-content: space-between;
104
104
-
padding: 0.5rem 0.25rem 0.5rem 0.5rem;
105
105
-
font-size: 0.8rem;
106
106
-
align-items: baseline;
107
107
-
border-radius: 0.5rem;
108
108
-
border: 1px solid #bbb;
109
109
-
cursor: pointer;
110
110
-
}
111
111
-
#action:hover {
112
112
-
background: #fff;
113
113
-
}
114
114
-
#allow {
115
115
-
background: transparent;
116
116
-
border: none;
117
117
-
border-left: 1px solid #bbb;
118
118
-
padding: 0 0.5rem;
119
119
-
color: #375;
120
120
-
font: inherit;
121
121
-
cursor: pointer;
122
122
-
}
123
123
-
#action:hover #allow {
124
124
-
color: #285;
125
125
-
}
126
126
-
127
127
-
#or {
128
128
-
font-size: 0.8rem;
129
129
-
text-align: center;
130
130
-
}
131
131
-
#or p {
132
132
-
margin: 0 0 1rem;
133
133
-
}
134
134
-
135
135
-
input#handle {
136
136
-
border: none;
137
137
-
border-bottom: 1px dashed #aaa;
138
138
-
background: transparent;
139
139
-
}
140
140
-
141
141
-
.hidden {
142
142
-
display: none !important;
143
143
-
}
144
144
-
145
145
-
</style>
146
146
-
147
147
-
<div class="wrap unframed">
148
148
-
<header>
149
149
-
<div class="empty">🔒</div>
150
150
-
<code class="title" style="font-family: monospace;"
151
151
-
>who-am-i</code>
152
152
-
<a href="https://microcosm.blue" target="_blank" class="micro"
153
153
-
><span style="color: #f396a9">m</span
154
154
-
><span style="color: #f49c5c">i</span
155
155
-
><span style="color: #c7b04c">c</span
156
156
-
><span style="color: #92be4c">r</span
157
157
-
><span style="color: #4ec688">o</span
158
158
-
><span style="color: #51c2b6">c</span
159
159
-
><span style="color: #54bed7">o</span
160
160
-
><span style="color: #8fb1f1">s</span
161
161
-
><span style="color: #ce9df1">m</span
162
162
-
></a>
163
163
-
</header>
164
164
-
165
165
-
<main>
166
166
-
{{> main}}
167
167
-
</main>
168
168
-
</div>