+44
-4
who-am-i/src/server.rs
+44
-4
who-am-i/src/server.rs
···
10
response::{IntoResponse, Json, Redirect, Response},
11
routing::{get, post},
12
};
13
-
use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar};
14
use axum_template::{RenderHtml, engine::Engine};
15
use handlebars::{Handlebars, handlebars_helper};
16
use jose_jwk::JwkSet;
···
20
use serde_json::{Value, json};
21
use std::collections::HashSet;
22
use std::sync::Arc;
23
-
use std::time::Duration;
24
use tokio::net::TcpListener;
25
use tokio_util::sync::CancellationToken;
26
use url::Url;
···
32
const FAVICON: &[u8] = include_bytes!("../static/favicon.ico");
33
const STYLE_CSS: &str = include_str!("../static/style.css");
34
35
const DID_COOKIE_KEY: &str = "did";
36
37
const COOKIE_EXPIRATION: Duration = Duration::from_secs(30 * 86_400);
···
113
.unwrap();
114
}
115
116
async fn hello(
117
State(AppState {
118
engine,
···
121
oauth,
122
..
123
}): State<AppState>,
124
mut jar: SignedCookieJar,
125
) -> Response {
126
let info = if let Some(did) = jar.get(DID_COOKIE_KEY) {
127
if let Ok(did) = Did::new(did.value_trimmed().to_string()) {
128
// push cookie expiry
···
138
json!({
139
"did": did,
140
"fetch_key": fetch_key,
141
})
142
} else {
143
jar = jar.remove(DID_COOKIE_KEY);
144
-
json!({})
145
}
146
} else {
147
-
json!({})
148
};
149
let frame_headers = [(CONTENT_SECURITY_POLICY, "frame-ancestors 'none'")];
150
(frame_headers, jar, RenderHtml("hello", engine, info)).into_response()
···
162
([(CONTENT_TYPE, "image/x-icon")], FAVICON)
163
}
164
165
fn cookie(did: &Did) -> Cookie<'static> {
166
Cookie::build((DID_COOKIE_KEY, did.to_string()))
167
.http_only(true)
168
.secure(true)
169
.same_site(SameSite::None)
170
.max_age(COOKIE_EXPIRATION.try_into().unwrap())
171
.into()
172
}
173
···
10
response::{IntoResponse, Json, Redirect, Response},
11
routing::{get, post},
12
};
13
+
use axum_extra::extract::cookie::{Cookie, Expiration, Key, SameSite, SignedCookieJar};
14
use axum_template::{RenderHtml, engine::Engine};
15
use handlebars::{Handlebars, handlebars_helper};
16
use jose_jwk::JwkSet;
···
20
use serde_json::{Value, json};
21
use std::collections::HashSet;
22
use std::sync::Arc;
23
+
use std::time::{Duration, SystemTime};
24
use tokio::net::TcpListener;
25
use tokio_util::sync::CancellationToken;
26
use url::Url;
···
32
const FAVICON: &[u8] = include_bytes!("../static/favicon.ico");
33
const STYLE_CSS: &str = include_str!("../static/style.css");
34
35
+
const HELLO_COOKIE_KEY: &str = "hello-who-am-i";
36
const DID_COOKIE_KEY: &str = "did";
37
38
const COOKIE_EXPIRATION: Duration = Duration::from_secs(30 * 86_400);
···
114
.unwrap();
115
}
116
117
+
#[derive(Debug, Deserialize)]
118
+
struct HelloQuery {
119
+
auth_reload: Option<String>,
120
+
auth_failed: Option<String>,
121
+
}
122
async fn hello(
123
State(AppState {
124
engine,
···
127
oauth,
128
..
129
}): State<AppState>,
130
+
Query(params): Query<HelloQuery>,
131
mut jar: SignedCookieJar,
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
+
138
let info = if let Some(did) = jar.get(DID_COOKIE_KEY) {
139
if let Ok(did) = Did::new(did.value_trimmed().to_string()) {
140
// push cookie expiry
···
150
json!({
151
"did": did,
152
"fetch_key": fetch_key,
153
+
"is_auth_reload": is_auth_reload,
154
+
"auth_failed": auth_failed,
155
+
"no_cookie": no_cookie,
156
})
157
} else {
158
jar = jar.remove(DID_COOKIE_KEY);
159
+
json!({
160
+
"is_auth_reload": is_auth_reload,
161
+
"auth_failed": auth_failed,
162
+
"no_cookie": no_cookie,
163
+
})
164
}
165
} else {
166
+
json!({
167
+
"is_auth_reload": is_auth_reload,
168
+
"auth_failed": auth_failed,
169
+
"no_cookie": no_cookie,
170
+
})
171
};
172
let frame_headers = [(CONTENT_SECURITY_POLICY, "frame-ancestors 'none'")];
173
(frame_headers, jar, RenderHtml("hello", engine, info)).into_response()
···
185
([(CONTENT_TYPE, "image/x-icon")], FAVICON)
186
}
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
+
201
fn cookie(did: &Did) -> Cookie<'static> {
202
Cookie::build((DID_COOKIE_KEY, did.to_string()))
203
.http_only(true)
204
.secure(true)
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??
209
.max_age(COOKIE_EXPIRATION.try_into().unwrap())
210
+
.path("/")
211
.into()
212
}
213
+11
who-am-i/static/style.css
+11
who-am-i/static/style.css
···
165
color: #285;
166
}
167
168
+
#need-storage {
169
+
font-size: 0.8rem;
170
+
}
171
+
.problem {
172
+
color: #a31;
173
+
}
174
+
175
#or {
176
font-size: 0.8rem;
177
text-align: center;
···
189
.hidden {
190
display: none !important;
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
<div class="mini-content">
5
<div class="explain">
6
<p>This is a little identity-verifying service for microcosm demos.</p>
7
</div>
8
9
{{#if did}}
···
50
} catch (e) {
51
err(e, 'failed to clear session, sorry');
52
}
53
-
window.location.reload();
54
});
55
})();
56
···
71
}
72
</script>
73
{{else}}
74
-
<p id="prompt" class="detail no">
75
-
No identity connected.
76
-
</p>
77
{{/if}}
78
</div>
79
{{/inline}}
80
81
{{#> base-full}}{{/base-full}}
···
4
<div class="mini-content">
5
<div class="explain">
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>
8
</div>
9
10
{{#if did}}
···
51
} catch (e) {
52
err(e, 'failed to clear session, sorry');
53
}
54
+
window.location.replace(location.pathname);
55
+
window.location.reload(); // backup, in case there is no query?
56
});
57
})();
58
···
73
}
74
</script>
75
{{else}}
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>
105
{{/if}}
106
+
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>
120
{{/inline}}
121
122
{{#> base-full}}{{/base-full}}
+18
-1
who-am-i/templates/prompt.hbs
+18
-1
who-am-i/templates/prompt.hbs
···
27
</div>
28
</div>
29
30
31
32
<script>
···
39
const formEl = document.getElementById('form-action'); // for anon
40
const allowEl = document.getElementById('handle-action'); // for known-did
41
const connectEl = document.getElementById('connect'); // for anon
42
43
function err(e, msg) {
44
loaderEl.classList.add('hidden');
···
66
window.open(url, '_blank');
67
};
68
69
window.addEventListener('storage', async e => {
70
// here's a fun minor vuln: we can't tell which flow triggers the storage event.
71
// so if you have two flows going, it grants for both (or the first responder?) if you grant for either.
···
79
console.error("hmm, heard from localstorage but did not get DID", details, e);
80
err('sorry, something went wrong getting your details');
81
}
82
-
localStorage.removeItem(e.key);
83
84
let parsed;
85
try {
···
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>Try <a href="/" target="_blank">connecting directly</a> first (but no promises).</p>
33
+
</div>
34
+
35
36
37
<script>
···
44
const formEl = document.getElementById('form-action'); // for anon
45
const allowEl = document.getElementById('handle-action'); // for known-did
46
const connectEl = document.getElementById('connect'); // for anon
47
+
const needStorageEl = document.getElementById('need-storage'); // for safari/frame isolation
48
49
function err(e, msg) {
50
loaderEl.classList.add('hidden');
···
72
window.open(url, '_blank');
73
};
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
+
87
window.addEventListener('storage', async e => {
88
// here's a fun minor vuln: we can't tell which flow triggers the storage event.
89
// so if you have two flows going, it grants for both (or the first responder?) if you grant for either.
···
97
console.error("hmm, heard from localstorage but did not get DID", details, e);
98
err('sorry, something went wrong getting your details');
99
}
100
101
let parsed;
102
try {