mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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
"<p>{body}</p>" 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) <noreply@anthropic.com>
This commit is contained in:
parent
858f40d134
commit
cb0c39ee91
4 changed files with 215 additions and 206 deletions
196
lib/crates/fabro-server/src/auth/browser_shell.rs
Normal file
196
lib/crates/fabro-server/src/auth/browser_shell.rs
Normal file
|
|
@ -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#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title} · Fabro</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: dark;
|
||||
--page: #0F1729;
|
||||
--overlay: rgba(255, 255, 255, 0.04);
|
||||
--overlay-2: rgba(255, 255, 255, 0.08);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--fg: #ffffff;
|
||||
--fg-2: #E8EDF3;
|
||||
--fg-3: #A8B5C5;
|
||||
--teal-500: #67B2D7;
|
||||
--teal-700: #357F9E;
|
||||
--mint: #5AC8A8;
|
||||
--coral: #E86B6B;
|
||||
--on-primary: #0F1729;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
color: var(--fg-2);
|
||||
background-color: var(--page);
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 60% at 15% 5%, rgba(53, 127, 158, 0.14) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 80% 50% at 85% 90%, rgba(90, 200, 168, 0.08) 0%, transparent 45%);
|
||||
background-attachment: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
}}
|
||||
main {{ width: 100%; max-width: 24rem; }}
|
||||
.brand {{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.75rem;
|
||||
}}
|
||||
.brand img {{ width: 3rem; height: 3rem; }}
|
||||
.panel {{
|
||||
background: rgba(37, 44, 61, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}}
|
||||
.stack > * + * {{ margin-top: 1.5rem; }}
|
||||
.eyebrow {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
color: var(--mint);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.eyebrow::before {{
|
||||
content: "";
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mint);
|
||||
box-shadow: 0 0 0 3px rgba(90, 200, 168, 0.22);
|
||||
}}
|
||||
.eyebrow.error {{ color: var(--coral); }}
|
||||
.eyebrow.error::before {{
|
||||
background: var(--coral);
|
||||
box-shadow: 0 0 0 3px rgba(232, 107, 107, 0.22);
|
||||
}}
|
||||
h1 {{
|
||||
margin: 0.625rem 0 0;
|
||||
color: var(--fg);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
text-wrap: balance;
|
||||
}}
|
||||
p {{
|
||||
margin: 0;
|
||||
color: var(--fg-3);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
text-wrap: pretty;
|
||||
}}
|
||||
code {{
|
||||
font-family: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--overlay-2);
|
||||
color: var(--fg-2);
|
||||
white-space: nowrap;
|
||||
}}
|
||||
.identity {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--overlay);
|
||||
border: 1px solid var(--line);
|
||||
}}
|
||||
.identity strong {{
|
||||
color: var(--fg);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.identity-meta {{
|
||||
color: var(--fg-3);
|
||||
font-size: 0.8125rem;
|
||||
font-feature-settings: "tnum";
|
||||
word-break: break-word;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--teal-500);
|
||||
color: var(--on-primary);
|
||||
padding: 0.625rem 1rem;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}}
|
||||
.button:hover {{ background: var(--teal-700); color: var(--fg); }}
|
||||
.button:focus-visible {{
|
||||
outline: 2px solid var(--teal-500);
|
||||
outline-offset: 2px;
|
||||
}}
|
||||
form {{ margin: 0; }}
|
||||
@media (prefers-reduced-motion: reduce) {{
|
||||
.button {{ transition: none; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand">
|
||||
<img src="/logo.svg" alt="Fabro" width="48" height="48">
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="stack">
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
);
|
||||
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
|
||||
}
|
||||
|
|
@ -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#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title} · Fabro</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: dark;
|
||||
--page: #0F1729;
|
||||
--overlay: rgba(255, 255, 255, 0.04);
|
||||
--overlay-2: rgba(255, 255, 255, 0.08);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--fg: #ffffff;
|
||||
--fg-2: #E8EDF3;
|
||||
--fg-3: #A8B5C5;
|
||||
--teal-500: #67B2D7;
|
||||
--teal-700: #357F9E;
|
||||
--mint: #5AC8A8;
|
||||
--coral: #E86B6B;
|
||||
--on-primary: #0F1729;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
color: var(--fg-2);
|
||||
background-color: var(--page);
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 60% at 15% 5%, rgba(53, 127, 158, 0.14) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 80% 50% at 85% 90%, rgba(90, 200, 168, 0.08) 0%, transparent 45%);
|
||||
background-attachment: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
}}
|
||||
main {{ width: 100%; max-width: 24rem; }}
|
||||
.brand {{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.75rem;
|
||||
}}
|
||||
.brand img {{ width: 3rem; height: 3rem; }}
|
||||
.panel {{
|
||||
background: rgba(37, 44, 61, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}}
|
||||
.stack > * + * {{ margin-top: 1.5rem; }}
|
||||
.eyebrow {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
color: var(--mint);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.eyebrow::before {{
|
||||
content: "";
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mint);
|
||||
box-shadow: 0 0 0 3px rgba(90, 200, 168, 0.22);
|
||||
}}
|
||||
.eyebrow.error {{ color: var(--coral); }}
|
||||
.eyebrow.error::before {{
|
||||
background: var(--coral);
|
||||
box-shadow: 0 0 0 3px rgba(232, 107, 107, 0.22);
|
||||
}}
|
||||
h1 {{
|
||||
margin: 0.625rem 0 0;
|
||||
color: var(--fg);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
text-wrap: balance;
|
||||
}}
|
||||
p {{
|
||||
margin: 0;
|
||||
color: var(--fg-3);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
text-wrap: pretty;
|
||||
}}
|
||||
code {{
|
||||
font-family: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--overlay-2);
|
||||
color: var(--fg-2);
|
||||
white-space: nowrap;
|
||||
}}
|
||||
.identity {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--overlay);
|
||||
border: 1px solid var(--line);
|
||||
}}
|
||||
.identity strong {{
|
||||
color: var(--fg);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.identity-meta {{
|
||||
color: var(--fg-3);
|
||||
font-size: 0.8125rem;
|
||||
font-feature-settings: "tnum";
|
||||
word-break: break-word;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--teal-500);
|
||||
color: var(--on-primary);
|
||||
padding: 0.625rem 1rem;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}}
|
||||
.button:hover {{ background: var(--teal-700); color: var(--fg); }}
|
||||
.button:focus-visible {{
|
||||
outline: 2px solid var(--teal-500);
|
||||
outline-offset: 2px;
|
||||
}}
|
||||
form {{ margin: 0; }}
|
||||
@media (prefers-reduced-motion: reduce) {{
|
||||
.button {{ transition: none; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand">
|
||||
<img src="/logo.svg" alt="Fabro" width="48" height="48">
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="stack">
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
);
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
"<!doctype html><html><body><p>{body}</p></body></html>"
|
||||
)),
|
||||
"Sign-in failed",
|
||||
&format!(
|
||||
r#"
|
||||
<div>
|
||||
<p class="eyebrow error">Sign-in failed</p>
|
||||
<h1>We couldn't complete sign-in</h1>
|
||||
</div>
|
||||
<p>{message}</p>
|
||||
<a class="button" href="/login">Back to sign in</a>
|
||||
"#
|
||||
),
|
||||
)
|
||||
.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<String>) -> Option<String> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue