Skip to main content

alibi_core/error/
page.rs

1//! Built-in error page rendering and redirects.
2
3fn safe_error_code(input: &str) -> &str {
4    if !input.is_empty()
5        && input
6            .chars()
7            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '\'')
8    {
9        input
10    } else {
11        "UNKNOWN"
12    }
13}
14
15fn is_preserved_entity(input: &str) -> bool {
16    input.starts_with("amp;")
17        || input.starts_with("lt;")
18        || input.starts_with("gt;")
19        || input.starts_with("quot;")
20        || input.starts_with("#39;")
21        || input
22            .strip_prefix("#x")
23            .and_then(|rest| rest.split_once(';'))
24            .is_some_and(|(hex, _)| !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit()))
25        || input
26            .strip_prefix('#')
27            .and_then(|rest| rest.split_once(';'))
28            .is_some_and(|(digits, _)| {
29                !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
30            })
31}
32
33fn sanitize_html(input: &str) -> String {
34    let mut out = String::with_capacity(input.len());
35
36    for (idx, ch) in input.char_indices() {
37        match ch {
38            '<' => out.push_str("&lt;"),
39            '>' => out.push_str("&gt;"),
40            '"' => out.push_str("&quot;"),
41            '\'' => out.push_str("&#39;"),
42            '&' => {
43                let rest = input.get(idx + ch.len_utf8()..).unwrap_or_default();
44                if is_preserved_entity(rest) {
45                    out.push('&');
46                } else {
47                    out.push_str("&amp;");
48                }
49            }
50            _ => out.push(ch),
51        }
52    }
53
54    out
55}
56
57fn default_error_description(code: &str) -> String {
58    format!(
59        "We encountered an unexpected error. Please try again or return to the home page. If you're a developer, you can find <a href='https://better-auth.com/docs/reference/errors/{code}' target='_blank' rel=\"noopener noreferrer\" style='color: var(--foreground); text-decoration: underline;'>more information about the error</a>."
60    )
61}
62
63/// Build the HTML error page returned by `GET /error`.
64///
65/// Matches the current TS better-auth error page renderer.
66#[must_use]
67pub fn error_page_html(error_code: &str) -> String {
68    error_page_html_with_description(error_code, None)
69}
70
71/// Build the HTML error page returned by `GET /error`, optionally
72/// overriding the default description text.
73pub fn error_page_html_with_description(
74    error_code: &str,
75    error_description: Option<&str>,
76) -> String {
77    let safe_code = safe_error_code(error_code);
78    let description =
79        error_description.map_or_else(|| default_error_description(safe_code), sanitize_html);
80    let ask_ai_query = format!("What%20does%20the%20error%20code%20{safe_code}%20mean%3F");
81
82    format!(
83        include_str!("page.html"),
84        safe_code = safe_code,
85        description = description,
86        ask_ai_query = ask_ai_query,
87    )
88}
89
90/// Build the production error redirect with sanitized code and encoded description.
91#[must_use]
92pub fn error_page_redirect_location(error_code: &str, error_description: Option<&str>) -> String {
93    let mut query = url::form_urlencoded::Serializer::new(String::new());
94    _ = query.append_pair("error", safe_error_code(error_code));
95    if let Some(description) = error_description.filter(|value| !value.is_empty()) {
96        _ = query.append_pair("error_description", description);
97    }
98    format!("/?{}", query.finish())
99}
100
101/// Produce the configured error response from independently selected error parameters.
102///
103/// # Errors
104/// Returns an ordinary failure for an invalid configured redirect destination.
105pub fn error_page_response(
106    config: &crate::AuthConfig,
107    error_code: &str,
108    error_description: Option<&str>,
109) -> crate::AuthResult<crate::AuthResponse> {
110    use crate::{AuthError, AuthResponse};
111    if let Some(target) = config
112        .api_error_url
113        .as_deref()
114        .filter(|target| !target.is_empty())
115    {
116        let relative = target.starts_with('/');
117        let origin = url::Url::parse("https://better-auth.invalid")
118            .map_err(|error| AuthError::internal(error.to_string()))?;
119        let mut location = if relative {
120            origin.join(target)
121        } else {
122            url::Url::parse(target)
123        }
124        .map_err(|error| {
125            AuthError::CallbackFailure(Box::new(AuthError::internal(error.to_string())))
126        })?;
127        if target.starts_with("//")
128            || target.starts_with("/\\")
129            || (relative && location.origin() != origin.origin())
130        {
131            return Err(AuthError::CallbackFailure(Box::new(AuthError::internal(
132                "Invalid error URL",
133            ))));
134        }
135        let generated = error_page_redirect_location(error_code, error_description);
136        let query = generated.strip_prefix("/?").unwrap_or_default();
137        let existing = location.query().unwrap_or_default();
138        let separator = if existing.is_empty() || existing.ends_with('&') {
139            ""
140        } else {
141            "&"
142        };
143        let combined = format!("{existing}{separator}{query}");
144        location.set_query(Some(&combined));
145        let location = if relative {
146            location[url::Position::BeforePath..].to_owned()
147        } else {
148            location.to_string()
149        };
150        return Ok(AuthResponse::new(302).with_header("location", location));
151    }
152    if !config.render_error_page {
153        return Ok(AuthResponse::new(302).with_header(
154            "location",
155            error_page_redirect_location(error_code, error_description),
156        ));
157    }
158    let html = error_page_html_with_description(error_code, error_description);
159    Ok(AuthResponse::html(200, html).with_header("content-type", "text/html"))
160}