Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions crates/outer/src/auth/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,9 @@ async fn authorize_post(
}
}

/// Allow and deny redirects carry `iss` (RFC 9207) plus `state` when sent.
/// Allow and deny hand the browser back to the client (see
/// `pages::return_page` for why it isn't a 302). The URL carries `iss`
/// (RFC 9207) plus `state` when sent.
fn redirect(base: &str, params: &[(&str, &str)], state: Option<&str>, issuer: &str) -> Response {
let mut url = match Url::parse(base) {
Ok(url) => url,
Expand All @@ -582,7 +584,15 @@ fn redirect(base: &str, params: &[(&str, &str)], state: Option<&str>, issuer: &s
}
pairs.append_pair("iss", issuer);
}
(StatusCode::FOUND, [("location", url.to_string())], "").into_response()
let host = match url.host_str() {
Some(host) => host.to_string(),
None => format!("{}:", url.scheme()),
};
html(
StatusCode::OK,
pages::return_page(url.as_str(), &host),
None,
)
}

/// `POST /oauth/register` (RFC 7591): public clients only, HTTPS or loopback
Expand Down
27 changes: 27 additions & 0 deletions crates/outer/src/auth/pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@ pub fn consent_page(client: &ClientDoc, scope: &str, request_id: &str, csrf: &st
)
}

/// After Allow/Deny: hand the browser back to the client with a page that
/// navigates by itself (`<meta refresh>`) instead of a 302 answer to the form
/// POST. Browsers apply CSP `form-action` to every hop of a form
/// submission's redirect chain, and some clients bounce through more than one
/// origin before landing (Gemini: oauth-redirect.googleusercontent.com, then
/// Google's own pages), so a 302 gets the user stuck on the consent page. A
/// refresh is a fresh navigation that `form-action` doesn't govern. The link
/// is the fallback when refresh is disabled.
pub fn return_page(url: &str, host: &str) -> Markup {
html! {
(maud::DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
meta http-equiv="refresh" content=(format!("0;url={url}"));
title { "Returning · Outer" }
}
body style="font-family:system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;line-height:1.5;" {
h1 style="font-size:1.25rem;" { "Outer" }
p { "Returning to " strong { (host) } "…" }
p { a href=(url) { "Continue to " (host) } }
}
}
}
}

pub fn error_page(title: &str, message: &str) -> Markup {
layout(
title,
Expand Down
29 changes: 13 additions & 16 deletions crates/outer/src/auth/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,16 @@ fn raw_set_session(headers: &HeaderMap) -> String {
.expect("session cookie set")
}

fn location_query(headers: &HeaderMap) -> (StatusCode, HashMap<String, String>) {
let location = headers
.get("location")
.unwrap()
.to_str()
.unwrap()
.to_string();
let params = url::Url::parse(&format!("http://redirect.invalid{location}"))
/// The client URL the Allow/Deny page navigates to (its `<meta refresh>`).
fn return_query(body: &str) -> HashMap<String, String> {
let start = body.find("0;url=").expect("refresh target") + "0;url=".len();
let end = start + body[start..].find('"').expect("closing quote");
let target = body[start..end].replace("&amp;", "&");
url::Url::parse(&target)
.unwrap()
.query_pairs()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect();
(StatusCode::FOUND, params)
.collect()
}

fn app_with(state: &AuthState) -> Router {
Expand Down Expand Up @@ -316,9 +313,9 @@ async fn allow(
]),
);
req.headers_mut().insert("cookie", session.parse().unwrap());
let (status, headers, _) = send(&flow.app, req).await;
assert_eq!(status, StatusCode::FOUND);
let (_, params) = location_query(&headers);
let (status, _, body) = send(&flow.app, req).await;
assert_eq!(status, StatusCode::OK);
let params = return_query(&body);
assert_eq!(params.get("state").map(String::as_str), Some("xyz-state"));
assert_eq!(params.get("iss").map(String::as_str), Some(ISSUER));
assert!(params.contains_key("code"));
Expand Down Expand Up @@ -450,9 +447,9 @@ async fn full_authorize_flow_mints_a_working_token() {
]),
);
req.headers_mut().insert("cookie", session.parse().unwrap());
let (status, headers, _) = send(&flow.app, req).await;
assert_eq!(status, StatusCode::FOUND);
let (_, params) = location_query(&headers);
let (status, _, body) = send(&flow.app, req).await;
assert_eq!(status, StatusCode::OK);
let params = return_query(&body);
assert_eq!(
params.get("error").map(String::as_str),
Some("access_denied")
Expand Down
Loading