From cb0c39ee915896c5a3e8873180092a8bc95bbf36 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 12:23:59 -0400 Subject: [PATCH] fix(server): style web OAuth state-failure error page Render the OAuth callback state-validation error through the same dark-themed browser shell used by the CLI auth flow instead of the bare "

{body}

" fallback. Extract the shell into a shared auth/browser_shell module so both flows reuse one definition. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fabro-server/src/auth/browser_shell.rs | 196 ++++++++++++++++++ lib/crates/fabro-server/src/auth/cli_flow.rs | 188 +---------------- lib/crates/fabro-server/src/auth/mod.rs | 2 + lib/crates/fabro-server/src/web_auth.rs | 35 ++-- 4 files changed, 215 insertions(+), 206 deletions(-) create mode 100644 lib/crates/fabro-server/src/auth/browser_shell.rs diff --git a/lib/crates/fabro-server/src/auth/browser_shell.rs b/lib/crates/fabro-server/src/auth/browser_shell.rs new file mode 100644 index 000000000..a83841b54 --- /dev/null +++ b/lib/crates/fabro-server/src/auth/browser_shell.rs @@ -0,0 +1,196 @@ +//! Shared HTML shell for browser-facing auth pages. +//! +//! Both the web sign-in flow and the CLI auth flow render styled status pages +//! (errors, confirmations) directly from the server. This module provides the +//! single dark-themed shell those pages share so they stay visually consistent. + +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{Html, IntoResponse, Response}; + +/// Wrap `body` in the standard Fabro auth page chrome (logo, panel, dark +/// atmosphere) and return it as an HTML response with `status`. +pub(crate) fn browser_shell(status: StatusCode, title: &str, body: &str) -> Response { + let html = format!( + r#" + + + + + {title} · Fabro + + + + +
+
+ Fabro +
+
+
+ {body} +
+
+
+ +"#, + ); + let mut response = (status, Html(html)).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response.headers_mut().insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index e86edf92f..c2e79dd3f 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use axum::extract::rejection::JsonRejection; use axum::extract::{Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; -use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; use base64::Engine; @@ -26,6 +26,7 @@ use sha2::{Digest, Sha256}; use tracing::{info, warn}; use url::{Host, Url}; +use crate::auth::browser_shell::browser_shell; use crate::auth::{self, AuthCode, ConsumeOutcome, JwtSubject, RefreshToken}; use crate::jwt_auth::{AuthMode, ConfiguredAuth}; use crate::server::AppState; @@ -775,191 +776,6 @@ fn html_escape(value: &str) -> String { .replace('\'', "'") } -fn browser_shell(status: StatusCode, title: &str, body: &str) -> Response { - let html = format!( - r#" - - - - - {title} · Fabro - - - - -
-
- Fabro -
-
-
- {body} -
-
-
- -"#, - ); - let mut response = (status, Html(html)).into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - ); - response.headers_mut().insert( - "x-content-type-options", - HeaderValue::from_static("nosniff"), - ); - response - .headers_mut() - .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - response -} - fn cli_login_confirmation_page(session: &SessionCookie) -> Response { let login = html_escape(&session.login); let display_name = if session.name.trim().is_empty() { diff --git a/lib/crates/fabro-server/src/auth/mod.rs b/lib/crates/fabro-server/src/auth/mod.rs index 709efe7b8..31d3ef083 100644 --- a/lib/crates/fabro-server/src/auth/mod.rs +++ b/lib/crates/fabro-server/src/auth/mod.rs @@ -1,9 +1,11 @@ +mod browser_shell; mod cli_flow; mod github_endpoints; mod jwt; mod keys; mod translate; +pub(crate) use browser_shell::browser_shell; pub(crate) use cli_flow::web_routes; pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken}; pub use github_endpoints::GithubEndpoints; diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 29439dd8d..4b1f991bd 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; -use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; use cookie::time::Duration; @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{debug, error, info, warn}; -use crate::auth::GithubEndpoints; +use crate::auth::{GithubEndpoints, browser_shell}; use crate::jwt_auth::{ AuthMode, AuthenticatedService, AuthenticatedSubject, auth_method_name, dev_token_matches, }; @@ -204,26 +204,21 @@ fn json_response(status: StatusCode, body: serde_json::Value) -> Response { (status, Json(body)).into_response() } -fn static_error_page(body: &'static str) -> Response { - let mut response = ( +fn static_error_page(message: &'static str) -> Response { + browser_shell( StatusCode::BAD_REQUEST, - Html(format!( - "

{body}

" - )), + "Sign-in failed", + &format!( + r#" +
+

Sign-in failed

+

We couldn't complete sign-in

+
+

{message}

+Back to sign in +"# + ), ) - .into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - ); - response.headers_mut().insert( - "x-content-type-options", - HeaderValue::from_static("nosniff"), - ); - response - .headers_mut() - .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - response } fn sanitize_return_to(return_to: Option) -> Option {