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