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. A 5xx description is logged but
9//! never sent: the wire carries a fixed message, so internal error text cannot
10//! reach a client or a third-party redirect URI.
11//!
12//! `From` impls (in the `conversions` submodule) bridge the underlying domain
13//! errors (`OauthError`, `AuthProviderError`, `SecretsBootstrapError`) so
14//! handlers use `?` and the variant-to-RFC-code mapping lives in one place.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19use axum::Json;
20use axum::http::{HeaderValue, StatusCode, header};
21use axum::response::{IntoResponse, Redirect, Response};
22use serde::Serialize;
23use systemprompt_traits::BoxedSource;
24
25mod code;
26mod conversions;
27
28pub use code::OAuthErrorCode;
29
30#[derive(Debug, Clone)]
31pub struct RedirectContext {
32    pub uri: String,
33    pub state: Option<String>,
34}
35
36#[derive(Debug)]
37pub struct OAuthHttpError {
38    code: OAuthErrorCode,
39    status: StatusCode,
40    description: String,
41    redirect: Option<RedirectContext>,
42    source: Option<BoxedSource>,
43}
44
45impl OAuthHttpError {
46    #[must_use]
47    pub fn new(code: OAuthErrorCode, description: impl Into<String>) -> Self {
48        Self {
49            status: code.default_status(),
50            code,
51            description: description.into(),
52            redirect: None,
53            source: None,
54        }
55    }
56
57    #[must_use]
58    pub fn invalid_request(description: impl Into<String>) -> Self {
59        Self::new(OAuthErrorCode::InvalidRequest, description)
60    }
61
62    #[must_use]
63    pub fn invalid_client(description: impl Into<String>) -> Self {
64        Self::new(OAuthErrorCode::InvalidClient, description)
65    }
66
67    #[must_use]
68    pub fn invalid_grant(description: impl Into<String>) -> Self {
69        Self::new(OAuthErrorCode::InvalidGrant, description)
70    }
71
72    #[must_use]
73    pub fn unauthorized_client(description: impl Into<String>) -> Self {
74        Self::new(OAuthErrorCode::UnauthorizedClient, description)
75    }
76
77    #[must_use]
78    pub fn unsupported_grant_type(description: impl Into<String>) -> Self {
79        Self::new(OAuthErrorCode::UnsupportedGrantType, description)
80    }
81
82    #[must_use]
83    pub fn invalid_scope(description: impl Into<String>) -> Self {
84        Self::new(OAuthErrorCode::InvalidScope, description)
85    }
86
87    #[must_use]
88    pub fn invalid_token(description: impl Into<String>) -> Self {
89        Self::new(OAuthErrorCode::InvalidToken, description)
90    }
91
92    #[must_use]
93    pub fn access_denied(description: impl Into<String>) -> Self {
94        Self::new(OAuthErrorCode::AccessDenied, description)
95    }
96
97    #[must_use]
98    pub fn server_error(description: impl Into<String>) -> Self {
99        Self::new(OAuthErrorCode::ServerError, description)
100    }
101
102    #[must_use]
103    pub fn invalid_client_metadata(description: impl Into<String>) -> Self {
104        Self::new(OAuthErrorCode::InvalidClientMetadata, description)
105    }
106
107    #[must_use]
108    pub fn authentication_failed(description: impl Into<String>) -> Self {
109        Self::new(OAuthErrorCode::AuthenticationFailed, description)
110    }
111
112    #[must_use]
113    pub fn registration_failed(description: impl Into<String>) -> Self {
114        Self::new(OAuthErrorCode::RegistrationFailed, description)
115    }
116
117    #[must_use]
118    pub fn username_unavailable(description: impl Into<String>) -> Self {
119        Self::new(OAuthErrorCode::UsernameUnavailable, description)
120    }
121
122    #[must_use]
123    pub fn email_exists(description: impl Into<String>) -> Self {
124        Self::new(OAuthErrorCode::EmailExists, description)
125    }
126
127    #[must_use]
128    pub fn expired_challenge(description: impl Into<String>) -> Self {
129        Self::new(OAuthErrorCode::ExpiredChallenge, description)
130    }
131
132    #[must_use]
133    pub fn invalid_credential(description: impl Into<String>) -> Self {
134        Self::new(OAuthErrorCode::InvalidCredential, description)
135    }
136
137    #[must_use]
138    pub fn link_failed(description: impl Into<String>) -> Self {
139        Self::new(OAuthErrorCode::LinkFailed, description)
140    }
141
142    #[must_use]
143    pub fn invalid_target(description: impl Into<String>) -> Self {
144        Self::new(OAuthErrorCode::InvalidTarget, description)
145    }
146
147    #[must_use]
148    pub fn not_found(description: impl Into<String>) -> Self {
149        Self::new(OAuthErrorCode::NotFound, description)
150    }
151
152    #[must_use]
153    pub const fn with_status(mut self, status: StatusCode) -> Self {
154        self.status = status;
155        self
156    }
157
158    #[must_use]
159    pub fn with_redirect(mut self, uri: impl Into<String>, state: Option<String>) -> Self {
160        self.redirect = Some(RedirectContext {
161            uri: uri.into(),
162            state,
163        });
164        self
165    }
166
167    #[must_use]
168    pub fn with_source(mut self, source: impl Into<BoxedSource>) -> Self {
169        self.source = Some(source.into());
170        self
171    }
172
173    #[must_use]
174    pub const fn code(&self) -> OAuthErrorCode {
175        self.code
176    }
177
178    #[must_use]
179    pub fn description(&self) -> &str {
180        &self.description
181    }
182
183    fn log(&self) {
184        let cause = self.source.as_deref().map(cause_chain);
185        if self.status.is_server_error() {
186            tracing::error!(
187                error = self.code.as_str(),
188                description = %self.description,
189                status = self.status.as_u16(),
190                cause = cause.as_deref(),
191                "OAuth server error response"
192            );
193        } else if self.status.is_client_error() {
194            tracing::warn!(
195                error = self.code.as_str(),
196                description = %self.description,
197                status = self.status.as_u16(),
198                cause = cause.as_deref(),
199                "OAuth client error response"
200            );
201        }
202    }
203}
204
205fn cause_chain(error: &(dyn std::error::Error + Send + Sync + 'static)) -> String {
206    let mut chain = error.to_string();
207    let mut next = error.source();
208    while let Some(cause) = next {
209        chain.push_str(": ");
210        chain.push_str(&cause.to_string());
211        next = cause.source();
212    }
213    chain
214}
215
216const SERVER_ERROR_DESCRIPTION: &str = "The authorization server encountered an internal error";
217
218#[derive(Debug, Serialize)]
219struct OAuthErrorBody<'a> {
220    error: &'a str,
221    error_description: &'a str,
222}
223
224impl IntoResponse for OAuthHttpError {
225    fn into_response(self) -> Response {
226        self.log();
227
228        let description = if self.status.is_server_error() {
229            SERVER_ERROR_DESCRIPTION
230        } else {
231            self.description.as_str()
232        };
233
234        if let Some(redirect) = &self.redirect {
235            let separator = if redirect.uri.contains('?') { '&' } else { '?' };
236            let mut target = format!(
237                "{}{separator}error={}&error_description={}",
238                redirect.uri,
239                urlencoding::encode(self.code.as_str()),
240                urlencoding::encode(description),
241            );
242            if let Some(state) = &redirect.state {
243                target.push_str("&state=");
244                target.push_str(&urlencoding::encode(state));
245            }
246            return Redirect::to(&target).into_response();
247        }
248
249        let body = OAuthErrorBody {
250            error: self.code.as_str(),
251            error_description: description,
252        };
253        let mut response = (self.status, Json(body)).into_response();
254
255        if self.status == StatusCode::UNAUTHORIZED
256            && let Ok(value) = HeaderValue::from_str(
257                "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"",
258            )
259        {
260            response
261                .headers_mut()
262                .insert(header::WWW_AUTHENTICATE, value);
263        }
264
265        response
266    }
267}