+43
-15
who-am-i/src/server.rs
+43
-15
who-am-i/src/server.rs
···
5
5
extract::{FromRef, Json as ExtractJson, Query, State},
6
6
http::{
7
7
StatusCode,
8
-
header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE, HeaderMap, REFERER},
8
+
header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE, HeaderMap, ORIGIN, REFERER},
9
9
},
10
10
response::{IntoResponse, Json, Redirect, Response},
11
11
routing::{get, post},
···
211
211
.into()
212
212
}
213
213
214
+
#[derive(Debug, Deserialize)]
215
+
struct PromptQuery {
216
+
// this must *ONLY* be used for the postmessage target origin
217
+
app: Option<String>,
218
+
}
214
219
async fn prompt(
215
220
State(AppState {
216
221
allowed_hosts,
···
221
226
tokens,
222
227
..
223
228
}): State<AppState>,
229
+
Query(params): Query<PromptQuery>,
224
230
jar: SignedCookieJar,
225
231
headers: HeaderMap,
226
232
) -> impl IntoResponse {
227
-
let err = |reason, check_frame| {
233
+
let err = |reason, check_frame, detail| {
228
234
metrics::counter!("whoami_auth_prompt", "ok" => "false", "reason" => reason).increment(1);
229
-
let info = json!({ "reason": reason, "check_frame": check_frame });
235
+
let info = json!({
236
+
"reason": reason,
237
+
"check_frame": check_frame,
238
+
"detail": detail,
239
+
});
230
240
let html = RenderHtml("prompt-error", engine.clone(), info);
231
241
(StatusCode::BAD_REQUEST, html).into_response()
232
242
};
233
243
234
-
let Some(referrer) = headers.get(REFERER) else {
235
-
return err("Missing referer", true);
244
+
let Some(parent) = headers.get(ORIGIN).or_else(|| {
245
+
eprintln!("referrer fallback");
246
+
// TODO: referer should only be used for localhost??
247
+
headers.get(REFERER)
248
+
}) else {
249
+
return err("Missing origin and no referrer for fallback", true, None);
236
250
};
237
-
let Ok(referrer) = referrer.to_str() else {
238
-
return err("Unreadable referer", true);
251
+
let Ok(parent) = parent.to_str() else {
252
+
return err("Unreadable origin or referrer", true, None);
239
253
};
240
-
let Ok(url) = Url::parse(referrer) else {
241
-
return err("Bad referer", true);
254
+
eprintln!(
255
+
"rolling with parent: {parent:?} (from origin? {})",
256
+
headers.get(ORIGIN).is_some()
257
+
);
258
+
let Ok(url) = Url::parse(parent) else {
259
+
return err("Bad origin or referrer", true, None);
242
260
};
243
261
let Some(parent_host) = url.host_str() else {
244
-
return err("Referer missing host", true);
262
+
return err("Origin or referrer missing host", true, None);
245
263
};
246
264
if !allowed_hosts.contains(parent_host) {
247
-
return err("Login is not allowed on this page", false);
265
+
return err(
266
+
"Login is not allowed on this page",
267
+
false,
268
+
Some(parent_host),
269
+
);
248
270
}
249
271
let parent_origin = url.origin().ascii_serialization();
250
272
if parent_origin == "null" {
251
-
return err("Referer origin is opaque", true);
273
+
return err("Origin or referrer header value is opaque", true, None);
252
274
}
253
275
254
-
let csp = format!("frame-ancestors {parent_origin}");
276
+
let all_allowed = allowed_hosts
277
+
.iter()
278
+
.map(|h| format!("https://{h}"))
279
+
.collect::<Vec<_>>()
280
+
.join(" ");
281
+
let csp = format!("frame-ancestors 'self' {parent_origin} {all_allowed}");
255
282
let frame_headers = [(CONTENT_SECURITY_POLICY, &csp)];
256
283
257
284
if let Some(did) = jar.get(DID_COOKIE_KEY) {
258
285
let Ok(did) = Did::new(did.value_trimmed().to_string()) else {
259
-
return err("Bad cookie", false);
286
+
return err("Bad cookie", false, None);
260
287
};
261
288
262
289
// push cookie expiry
···
266
293
Ok(t) => t,
267
294
Err(e) => {
268
295
eprintln!("failed to create JWT: {e:?}");
269
-
return err("failed to create JWT", false);
296
+
return err("failed to create JWT", false, None);
270
297
}
271
298
};
272
299
···
286
313
"fetch_key": fetch_key,
287
314
"parent_host": parent_host,
288
315
"parent_origin": parent_origin,
316
+
"parent_target": params.app.map(|h| format!("https://{h}")),
289
317
});
290
318
(frame_headers, jar, RenderHtml("prompt", engine, info)).into_response()
291
319
} else {
+1
-1
who-am-i/static/style.css
+1
-1
who-am-i/static/style.css
+1
who-am-i/templates/prompt-error.hbs
+1
who-am-i/templates/prompt-error.hbs
···
2
2
<div class="prompt-error">
3
3
<p class="went-wrong">Something went wrong :(</p>
4
4
<p class="reason">{{ reason }}</p>
5
+
<p class="reason detail">{{ detail }}</p>
5
6
<p id="maybe-not-in-iframe" class="hidden">
6
7
Possibly related: this prompt is meant to be shown in an iframe, but it seems like it's not.
7
8
</p>
+15
-6
who-am-i/templates/prompt.hbs
+15
-6
who-am-i/templates/prompt.hbs
···
89
89
cookies: true,
90
90
localStorage: true,
91
91
}).then(
92
-
() => desperation.textContent = "(maybe helped?)",
92
+
() => {
93
+
desperation.textContent = "(maybe helped?)";
94
+
setTimeout(() => location.reload(), 350);
95
+
},
93
96
() => desperation.textContent = "(doubtful)",
94
97
);
95
98
})
···
157
160
return info.handle;
158
161
}
159
162
163
+
const parentTarget = {{{json parent_target}}} ?? {{{json parent_origin}}};
164
+
160
165
const shareAllow = (handle, token) => {
161
-
top.postMessage(
162
-
{ action: "allow", handle, token },
163
-
{{{json parent_origin}}},
164
-
);
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
+
};
165
174
promptEl.textContent = '✔️ shared';
166
175
}
167
176
168
177
const shareDeny = reason => {
169
178
top.postMessage(
170
179
{ action: "deny", reason },
171
-
{{{json parent_origin}}},
180
+
parentTarget,
172
181
);
173
182
}
174
183
</script>