Skip to main content

systemprompt_api/routes/oauth/error/
mod.rs

1//! Unified HTTP error type for the OAuth route module.
2//!
3//! Every OAuth handler returns `Result<_, OAuthHttpError>`. The `IntoResponse`
4//! impl logs exactly once (matching `ApiError`'s log-by-status-class pattern)
5//! and emits an RFC 6749 §5.2 wire shape `{"error": "...", "error_description":
6//! "..."}`. The authorize-flow variant (§4.1.2.1) carries a redirect target so
7//! the response renders as a 302 to the client's `redirect_uri` with the same
8//! error fields encoded as query parameters.
9//!
10//! `From` impls (in the `conversions` submodule) bridge the underlying domain
11//! errors (`OauthError`, `AuthProviderError`, `SecretsBootstrapError`) so
12//! handlers use `?` and the variant-to-RFC-code mapping lives in one place.
13
14use axum::Json;
15use axum::http::{HeaderValue, StatusCode, header};
16use axum::response::{IntoResponse, Redirect, Response};
17use serde::Serialize;
18
19mod code;
20mod conversions;
21
22pub use code::OAuthErrorCode;
23
24#[derive(Debug, Clone)]
25pub struct RedirectContext {
26    pub uri: String,
27    pub state: Option<String>,
28}
29
30#[derive(Debug)]
31pub struct OAuthHttpError {
32    code: OAuthErrorCode,
33    status: StatusCode,
34    description: String,
35    redirect: Option<RedirectContext>,
36}
37
38impl OAuthHttpError {
39    #[must_use]
40    pub fn new(code: OAuthErrorCode, description: impl Into<String>) -> Self {
41        Self {
42            status: code.default_status(),
43            code,
44            description: description.into(),
45            redirect: None,
46        }
47    }
48
49    #[must_use]
50    pub fn invalid_request(description: impl Into<String>) -> Self {
51        Self::new(OAuthErrorCode::InvalidRequest, description)
52    }
53
54    #[must_use]
55    pub fn invalid_client(description: impl Into<String>) -> Self {
56        Self::new(OAuthErrorCode::InvalidClient, description)
57    }
58
59    #[must_use]
60    pub fn invalid_grant(description: impl Into<String>) -> Self {
61        Self::new(OAuthErrorCode::InvalidGrant, description)
62    }
63
64    #[must_use]
65    pub fn unauthorized_client(description: impl Into<String>) -> Self {
66        Self::new(OAuthErrorCode::UnauthorizedClient, description)
67    }
68
69    #[must_use]
70    pub fn unsupported_grant_type(description: impl Into<String>) -> Self {
71        Self::new(OAuthErrorCode::UnsupportedGrantType, description)
72    }
73
74    #[must_use]
75    pub fn invalid_scope(description: impl Into<String>) -> Self {
76        Self::new(OAuthErrorCode::InvalidScope, description)
77    }
78
79    #[must_use]
80    pub fn invalid_token(description: impl Into<String>) -> Self {
81        Self::new(OAuthErrorCode::InvalidToken, description)
82    }
83
84    #[must_use]
85    pub fn access_denied(description: impl Into<String>) -> Self {
86        Self::new(OAuthErrorCode::AccessDenied, description)
87    }
88
89    #[must_use]
90    pub fn server_error(description: impl Into<String>) -> Self {
91        Self::new(OAuthErrorCode::ServerError, description)
92    }
93
94    #[must_use]
95    pub fn invalid_client_metadata(description: impl Into<String>) -> Self {
96        Self::new(OAuthErrorCode::InvalidClientMetadata, description)
97    }
98
99    #[must_use]
100    pub fn authentication_failed(description: impl Into<String>) -> Self {
101        Self::new(OAuthErrorCode::AuthenticationFailed, description)
102    }
103
104    #[must_use]
105    pub fn registration_failed(description: impl Into<String>) -> Self {
106        Self::new(OAuthErrorCode::RegistrationFailed, description)
107    }
108
109    #[must_use]
110    pub fn username_unavailable(description: impl Into<String>) -> Self {
111        Self::new(OAuthErrorCode::UsernameUnavailable, description)
112    }
113
114    #[must_use]
115    pub fn email_exists(description: impl Into<String>) -> Self {
116        Self::new(OAuthErrorCode::EmailExists, description)
117    }
118
119    #[must_use]
120    pub fn expired_challenge(description: impl Into<String>) -> Self {
121        Self::new(OAuthErrorCode::ExpiredChallenge, description)
122    }
123
124    #[must_use]
125    pub fn invalid_credential(description: impl Into<String>) -> Self {
126        Self::new(OAuthErrorCode::InvalidCredential, description)
127    }
128
129    #[must_use]
130    pub fn link_failed(description: impl Into<String>) -> Self {
131        Self::new(OAuthErrorCode::LinkFailed, description)
132    }
133
134    #[must_use]
135    pub fn invalid_target(description: impl Into<String>) -> Self {
136        Self::new(OAuthErrorCode::InvalidTarget, description)
137    }
138
139    #[must_use]
140    pub fn not_found(description: impl Into<String>) -> Self {
141        Self::new(OAuthErrorCode::NotFound, description)
142    }
143
144    // Use sparingly — the per-code default already encodes the spec mapping.
145    #[must_use]
146    pub const fn with_status(mut self, status: StatusCode) -> Self {
147        self.status = status;
148        self
149    }
150
151    #[must_use]
152    pub fn with_redirect(mut self, uri: impl Into<String>, state: Option<String>) -> Self {
153        self.redirect = Some(RedirectContext {
154            uri: uri.into(),
155            state,
156        });
157        self
158    }
159
160    #[must_use]
161    pub const fn code(&self) -> OAuthErrorCode {
162        self.code
163    }
164
165    #[must_use]
166    pub fn description(&self) -> &str {
167        &self.description
168    }
169
170    fn log(&self) {
171        if self.status.is_server_error() {
172            tracing::error!(
173                error = self.code.as_str(),
174                description = %self.description,
175                status = self.status.as_u16(),
176                "OAuth server error response"
177            );
178        } else if self.status.is_client_error() {
179            tracing::warn!(
180                error = self.code.as_str(),
181                description = %self.description,
182                status = self.status.as_u16(),
183                "OAuth client error response"
184            );
185        }
186    }
187}
188
189#[derive(Debug, Serialize)]
190struct OAuthErrorBody<'a> {
191    error: &'a str,
192    error_description: &'a str,
193}
194
195impl IntoResponse for OAuthHttpError {
196    fn into_response(self) -> Response {
197        self.log();
198
199        if let Some(redirect) = &self.redirect {
200            let mut target = format!(
201                "{}?error={}&error_description={}",
202                redirect.uri,
203                urlencoding::encode(self.code.as_str()),
204                urlencoding::encode(&self.description),
205            );
206            if let Some(state) = &redirect.state {
207                target.push_str("&state=");
208                target.push_str(&urlencoding::encode(state));
209            }
210            return Redirect::to(&target).into_response();
211        }
212
213        let body = OAuthErrorBody {
214            error: self.code.as_str(),
215            error_description: &self.description,
216        };
217        let mut response = (self.status, Json(body)).into_response();
218
219        if self.status == StatusCode::UNAUTHORIZED
220            && let Ok(value) = HeaderValue::from_str(
221                "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"",
222            )
223        {
224            response
225                .headers_mut()
226                .insert(header::WWW_AUTHENTICATE, value);
227        }
228
229        response
230    }
231}