+44
-4
who-am-i/src/server.rs
+44
-4
who-am-i/src/server.rs
···
10
10
response::{IntoResponse, Json, Redirect, Response},
11
11
routing::{get, post},
12
12
};
13
-
use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar};
13
+
use axum_extra::extract::cookie::{Cookie, Expiration, Key, SameSite, SignedCookieJar};
14
14
use axum_template::{RenderHtml, engine::Engine};
15
15
use handlebars::{Handlebars, handlebars_helper};
16
16
use jose_jwk::JwkSet;
···
20
20
use serde_json::{Value, json};
21
21
use std::collections::HashSet;
22
22
use std::sync::Arc;
23
-
use std::time::Duration;
23
+
use std::time::{Duration, SystemTime};
24
24
use tokio::net::TcpListener;
25
25
use tokio_util::sync::CancellationToken;
26
26
use url::Url;
···
32
32
const FAVICON: &[u8] = include_bytes!("../static/favicon.ico");
33
33
const STYLE_CSS: &str = include_str!("../static/style.css");
34
34
35
+
const HELLO_COOKIE_KEY: &str = "hello-who-am-i";
35
36
const DID_COOKIE_KEY: &str = "did";
36
37
37
38
const COOKIE_EXPIRATION: Duration = Duration::from_secs(30 * 86_400);
···
113
114
.unwrap();
114
115
}
115
116
117
+
#[derive(Debug, Deserialize)]
118
+
struct HelloQuery {
119
+
auth_reload: Option<String>,
120
+
auth_failed: Option<String>,
121
+
}
116
122
async fn hello(
117
123
State(AppState {
118
124
engine,
···
121
127
oauth,
122
128
..
123
129
}): State<AppState>,
130
+
Query(params): Query<HelloQuery>,
124
131
mut jar: SignedCookieJar,
125
132
) -> Response {
133
+
let is_auth_reload = params.auth_reload.is_some();
134
+
let auth_failed = params.auth_failed.is_some();
135
+
let no_cookie = jar.get(HELLO_COOKIE_KEY).is_none();
136
+
jar = jar.add(hello_cookie());
137
+
126
138
let info = if let Some(did) = jar.get(DID_COOKIE_KEY) {
127
139
if let Ok(did) = Did::new(did.value_trimmed().to_string()) {
128
140
// push cookie expiry
···
138
150
json!({
139
151
"did": did,
140
152
"fetch_key": fetch_key,
153
+
"is_auth_reload": is_auth_reload,
154
+
"auth_failed": auth_failed,
155
+
"no_cookie": no_cookie,
141
156
})
142
157
} else {
143
158
jar = jar.remove(DID_COOKIE_KEY);
144
-
json!({})
159
+
json!({
160
+
"is_auth_reload": is_auth_reload,
161
+
"auth_failed": auth_failed,
162
+
"no_cookie": no_cookie,
163
+
})
145
164
}
146
165
} else {
147
-
json!({})
166
+
json!({
167
+
"is_auth_reload": is_auth_reload,
168
+
"auth_failed": auth_failed,
169
+
"no_cookie": no_cookie,
170
+
})
148
171
};
149
172
let frame_headers = [(CONTENT_SECURITY_POLICY, "frame-ancestors 'none'")];
150
173
(frame_headers, jar, RenderHtml("hello", engine, info)).into_response()
···
162
185
([(CONTENT_TYPE, "image/x-icon")], FAVICON)
163
186
}
164
187
188
+
fn hello_cookie() -> Cookie<'static> {
189
+
Cookie::build((HELLO_COOKIE_KEY, "hiiii"))
190
+
.http_only(true)
191
+
.secure(true)
192
+
.same_site(SameSite::None)
193
+
.expires(Expiration::DateTime(
194
+
(SystemTime::now() + COOKIE_EXPIRATION).into(),
195
+
)) // wtf safari needs this to not be a session cookie??
196
+
.max_age(COOKIE_EXPIRATION.try_into().unwrap())
197
+
.path("/")
198
+
.into()
199
+
}
200
+
165
201
fn cookie(did: &Did) -> Cookie<'static> {
166
202
Cookie::build((DID_COOKIE_KEY, did.to_string()))
167
203
.http_only(true)
168
204
.secure(true)
169
205
.same_site(SameSite::None)
206
+
.expires(Expiration::DateTime(
207
+
(SystemTime::now() + COOKIE_EXPIRATION).into(),
208
+
)) // wtf safari needs this to not be a session cookie??
170
209
.max_age(COOKIE_EXPIRATION.try_into().unwrap())
210
+
.path("/")
171
211
.into()
172
212
}
173
213
+11
who-am-i/static/style.css
+11
who-am-i/static/style.css
···
165
165
color: #285;
166
166
}
167
167
168
+
#need-storage {
169
+
font-size: 0.8rem;
170
+
}
171
+
.problem {
172
+
color: #a31;
173
+
}
174
+
168
175
#or {
169
176
font-size: 0.8rem;
170
177
text-align: center;
···
182
189
.hidden {
183
190
display: none !important;
184
191
}
192
+
193
+
.hello-connect-plz {
194
+
margin: 1.667rem 0 0.667rem;
195
+
}
+45
-4
who-am-i/templates/hello.hbs
+45
-4
who-am-i/templates/hello.hbs
···
4
4
<div class="mini-content">
5
5
<div class="explain">
6
6
<p>This is a little identity-verifying service for microcosm demos.</p>
7
+
<p>Only <strong>read access to your public data</strong> is required to connect: connecting does not grant any ability to modify your account or data.</p>
7
8
</div>
8
9
9
10
{{#if did}}
···
50
51
} catch (e) {
51
52
err(e, 'failed to clear session, sorry');
52
53
}
53
-
window.location.reload();
54
+
window.location.replace(location.pathname);
55
+
window.location.reload(); // backup, in case there is no query?
54
56
});
55
57
})();
56
58
···
71
73
}
72
74
</script>
73
75
{{else}}
74
-
<p id="prompt" class="detail no">
75
-
No identity connected.
76
-
</p>
76
+
77
+
<p class="hello-connect-plz">Connect your handle</p>
78
+
79
+
{{#if is_auth_reload}}
80
+
{{#if no_cookie}}
81
+
<p id="prompt" class="detail no">
82
+
No identity connected. Your browser may be blocking access for connecting.
83
+
</p>
84
+
{{else}}
85
+
{{#if auth_failed}}
86
+
<p id="prompt" class="detail no">
87
+
No identity connected. Connecting failed or was denied.
88
+
</p>
89
+
{{else}}
90
+
<p id="prompt" class="detail no">
91
+
No identity connected.
92
+
</p>
93
+
{{/if}}
94
+
{{/if}}
95
+
{{/if}}
96
+
97
+
<div id="user-info">
98
+
<form id="form-action" action="/auth" target="_blank" method="GET" class="action {{#if did}}hidden{{/if}}">
99
+
<label>
100
+
@<input id="handle-input" class="handle" name="handle" placeholder="example.bsky.social" />
101
+
</label>
102
+
<button id="connect" type="submit">connect</button>
103
+
</form>
104
+
</div>
77
105
{{/if}}
106
+
78
107
</div>
108
+
<script>
109
+
window.addEventListener('storage', e => {
110
+
console.log('eyyy got storage', e);
111
+
if (e.key !== 'who-am-i') return;
112
+
if (!e.newValue) return;
113
+
if (e.newValue.result === 'success') {
114
+
window.location = '/?auth_reload=1';
115
+
} else {
116
+
window.location = '/?auth_reload=1&auth_failed=1';
117
+
}
118
+
});
119
+
</script>
79
120
{{/inline}}
80
121
81
122
{{#> base-full}}{{/base-full}}
+18
-1
who-am-i/templates/prompt.hbs
+18
-1
who-am-i/templates/prompt.hbs
···
27
27
</div>
28
28
</div>
29
29
30
+
<div id="need-storage" class="hidden">
31
+
<p class="problem">Sorry, your browser is blocking access.</p>
32
+
<p>Try <a href="/" target="_blank">connecting directly</a> first (but no promises).</p>
33
+
</div>
34
+
30
35
31
36
32
37
<script>
···
39
44
const formEl = document.getElementById('form-action'); // for anon
40
45
const allowEl = document.getElementById('handle-action'); // for known-did
41
46
const connectEl = document.getElementById('connect'); // for anon
47
+
const needStorageEl = document.getElementById('need-storage'); // for safari/frame isolation
42
48
43
49
function err(e, msg) {
44
50
loaderEl.classList.add('hidden');
···
66
72
window.open(url, '_blank');
67
73
};
68
74
75
+
// check if we may be partitioned, preventing access after auth completion
76
+
// this should only happen if on a browser that implements storage access api
77
+
if ('hasStorageAccess' in document) {
78
+
document.hasStorageAccess().then((hasAccess) => {
79
+
if (!hasAccess) {
80
+
promptEl.classList.add('hidden');
81
+
infoEl.classList.add('hidden');
82
+
needStorageEl.classList.remove('hidden');
83
+
}
84
+
});
85
+
}
86
+
69
87
window.addEventListener('storage', async e => {
70
88
// here's a fun minor vuln: we can't tell which flow triggers the storage event.
71
89
// so if you have two flows going, it grants for both (or the first responder?) if you grant for either.
···
79
97
console.error("hmm, heard from localstorage but did not get DID", details, e);
80
98
err('sorry, something went wrong getting your details');
81
99
}
82
-
localStorage.removeItem(e.key);
83
100
84
101
let parsed;
85
102
try {