Skip to main content

systemprompt_api/routes/oauth/
error.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 bridge the underlying domain errors (`OauthError`,
11//! `AuthProviderError`, `SecretsBootstrapError`) so handlers use `?` and the
12//! variant-to-RFC-code mapping lives in one place.
13//!
14//! `OAuthErrorCode` lists the RFC 6749 §5.2 codes plus the WebAuthn/RFC 7591
15//! extensions used by this server; `Display` yields the wire string. The
16//! default HTTP status follows §5.2: token-endpoint errors return 400 except
17//! `invalid_client`, which RFC 6749 permits to return 401 to advertise
18//! authentication schemes — and so we do. `access_denied`, `invalid_token`,
19//! and `authentication_failed` retain 401 because they signal that the
20//! *caller* (not the request) was rejected (RFC 6750 §3.1). The redirect
21//! variant carries the §4.1.2.1 context so the response renders as a 302 to
22//! the client's `redirect_uri`.
23
24use axum::Json;
25use axum::http::{HeaderValue, StatusCode, header};
26use axum::response::{IntoResponse, Redirect, Response};
27use serde::Serialize;
28use systemprompt_config::SecretsBootstrapError;
29use systemprompt_oauth::OauthError;
30use systemprompt_traits::auth::AuthProviderError;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum OAuthErrorCode {
34    InvalidRequest,
35    InvalidClient,
36    InvalidGrant,
37    UnauthorizedClient,
38    UnsupportedGrantType,
39    InvalidScope,
40    InvalidToken,
41    AccessDenied,
42    ServerError,
43    TemporarilyUnavailable,
44    InvalidClientMetadata,
45    AuthenticationFailed,
46    RegistrationFailed,
47    UsernameUnavailable,
48    EmailExists,
49    ExpiredChallenge,
50    InvalidCredential,
51    LinkFailed,
52    InvalidTarget,
53    NotFound,
54}
55
56impl OAuthErrorCode {
57    #[must_use]
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::InvalidRequest => "invalid_request",
61            Self::InvalidClient => "invalid_client",
62            Self::InvalidGrant => "invalid_grant",
63            Self::UnauthorizedClient => "unauthorized_client",
64            Self::UnsupportedGrantType => "unsupported_grant_type",
65            Self::InvalidScope => "invalid_scope",
66            Self::InvalidToken => "invalid_token",
67            Self::AccessDenied => "access_denied",
68            Self::ServerError => "server_error",
69            Self::TemporarilyUnavailable => "temporarily_unavailable",
70            Self::InvalidClientMetadata => "invalid_client_metadata",
71            Self::AuthenticationFailed => "authentication_failed",
72            Self::RegistrationFailed => "registration_failed",
73            Self::UsernameUnavailable => "username_unavailable",
74            Self::EmailExists => "email_exists",
75            Self::ExpiredChallenge => "expired_challenge",
76            Self::InvalidCredential => "invalid_credential",
77            Self::LinkFailed => "link_failed",
78            Self::InvalidTarget => "invalid_target",
79            Self::NotFound => "not_found",
80        }
81    }
82
83    #[must_use]
84    pub const fn default_status(self) -> StatusCode {
85        match self {
86            Self::InvalidRequest
87            | Self::InvalidGrant
88            | Self::UnauthorizedClient
89            | Self::UnsupportedGrantType
90            | Self::InvalidScope
91            | Self::InvalidClientMetadata
92            | Self::ExpiredChallenge
93            | Self::InvalidCredential
94            | Self::LinkFailed
95            | Self::InvalidTarget
96            | Self::RegistrationFailed => StatusCode::BAD_REQUEST,
97            Self::InvalidClient
98            | Self::AccessDenied
99            | Self::AuthenticationFailed
100            | Self::InvalidToken => StatusCode::UNAUTHORIZED,
101            Self::UsernameUnavailable | Self::EmailExists => StatusCode::CONFLICT,
102            Self::NotFound => StatusCode::NOT_FOUND,
103            Self::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
104            Self::TemporarilyUnavailable => StatusCode::SERVICE_UNAVAILABLE,
105        }
106    }
107}
108
109#[derive(Debug, Clone)]
110pub struct RedirectContext {
111    pub uri: String,
112    pub state: Option<String>,
113}
114
115#[derive(Debug)]
116pub struct OAuthHttpError {
117    code: OAuthErrorCode,
118    status: StatusCode,
119    description: String,
120    redirect: Option<RedirectContext>,
121}
122
123impl OAuthHttpError {
124    #[must_use]
125    pub fn new(code: OAuthErrorCode, description: impl Into<String>) -> Self {
126        Self {
127            status: code.default_status(),
128            code,
129            description: description.into(),
130            redirect: None,
131        }
132    }
133
134    #[must_use]
135    pub fn invalid_request(description: impl Into<String>) -> Self {
136        Self::new(OAuthErrorCode::InvalidRequest, description)
137    }
138
139    #[must_use]
140    pub fn invalid_client(description: impl Into<String>) -> Self {
141        Self::new(OAuthErrorCode::InvalidClient, description)
142    }
143
144    #[must_use]
145    pub fn invalid_grant(description: impl Into<String>) -> Self {
146        Self::new(OAuthErrorCode::InvalidGrant, description)
147    }
148
149    #[must_use]
150    pub fn unauthorized_client(description: impl Into<String>) -> Self {
151        Self::new(OAuthErrorCode::UnauthorizedClient, description)
152    }
153
154    #[must_use]
155    pub fn unsupported_grant_type(description: impl Into<String>) -> Self {
156        Self::new(OAuthErrorCode::UnsupportedGrantType, description)
157    }
158
159    #[must_use]
160    pub fn invalid_scope(description: impl Into<String>) -> Self {
161        Self::new(OAuthErrorCode::InvalidScope, description)
162    }
163
164    #[must_use]
165    pub fn invalid_token(description: impl Into<String>) -> Self {
166        Self::new(OAuthErrorCode::InvalidToken, description)
167    }
168
169    #[must_use]
170    pub fn access_denied(description: impl Into<String>) -> Self {
171        Self::new(OAuthErrorCode::AccessDenied, description)
172    }
173
174    #[must_use]
175    pub fn server_error(description: impl Into<String>) -> Self {
176        Self::new(OAuthErrorCode::ServerError, description)
177    }
178
179    #[must_use]
180    pub fn invalid_client_metadata(description: impl Into<String>) -> Self {
181        Self::new(OAuthErrorCode::InvalidClientMetadata, description)
182    }
183
184    #[must_use]
185    pub fn authentication_failed(description: impl Into<String>) -> Self {
186        Self::new(OAuthErrorCode::AuthenticationFailed, description)
187    }
188
189    #[must_use]
190    pub fn registration_failed(description: impl Into<String>) -> Self {
191        Self::new(OAuthErrorCode::RegistrationFailed, description)
192    }
193
194    #[must_use]
195    pub fn username_unavailable(description: impl Into<String>) -> Self {
196        Self::new(OAuthErrorCode::UsernameUnavailable, description)
197    }
198
199    #[must_use]
200    pub fn email_exists(description: impl Into<String>) -> Self {
201        Self::new(OAuthErrorCode::EmailExists, description)
202    }
203
204    #[must_use]
205    pub fn expired_challenge(description: impl Into<String>) -> Self {
206        Self::new(OAuthErrorCode::ExpiredChallenge, description)
207    }
208
209    #[must_use]
210    pub fn invalid_credential(description: impl Into<String>) -> Self {
211        Self::new(OAuthErrorCode::InvalidCredential, description)
212    }
213
214    #[must_use]
215    pub fn link_failed(description: impl Into<String>) -> Self {
216        Self::new(OAuthErrorCode::LinkFailed, description)
217    }
218
219    #[must_use]
220    pub fn invalid_target(description: impl Into<String>) -> Self {
221        Self::new(OAuthErrorCode::InvalidTarget, description)
222    }
223
224    #[must_use]
225    pub fn not_found(description: impl Into<String>) -> Self {
226        Self::new(OAuthErrorCode::NotFound, description)
227    }
228
229    // Use sparingly — the per-code default already encodes the spec mapping.
230    #[must_use]
231    pub const fn with_status(mut self, status: StatusCode) -> Self {
232        self.status = status;
233        self
234    }
235
236    #[must_use]
237    pub fn with_redirect(mut self, uri: impl Into<String>, state: Option<String>) -> Self {
238        self.redirect = Some(RedirectContext {
239            uri: uri.into(),
240            state,
241        });
242        self
243    }
244
245    #[must_use]
246    pub const fn code(&self) -> OAuthErrorCode {
247        self.code
248    }
249
250    #[must_use]
251    pub fn description(&self) -> &str {
252        &self.description
253    }
254
255    fn log(&self) {
256        if self.status.is_server_error() {
257            tracing::error!(
258                error = self.code.as_str(),
259                description = %self.description,
260                status = self.status.as_u16(),
261                "OAuth server error response"
262            );
263        } else if self.status.is_client_error() {
264            tracing::warn!(
265                error = self.code.as_str(),
266                description = %self.description,
267                status = self.status.as_u16(),
268                "OAuth client error response"
269            );
270        }
271    }
272}
273
274#[derive(Debug, Serialize)]
275struct OAuthErrorBody<'a> {
276    error: &'a str,
277    error_description: &'a str,
278}
279
280impl IntoResponse for OAuthHttpError {
281    fn into_response(self) -> Response {
282        self.log();
283
284        if let Some(redirect) = &self.redirect {
285            let mut target = format!(
286                "{}?error={}&error_description={}",
287                redirect.uri,
288                urlencoding::encode(self.code.as_str()),
289                urlencoding::encode(&self.description),
290            );
291            if let Some(state) = &redirect.state {
292                target.push_str("&state=");
293                target.push_str(&urlencoding::encode(state));
294            }
295            return Redirect::to(&target).into_response();
296        }
297
298        let body = OAuthErrorBody {
299            error: self.code.as_str(),
300            error_description: &self.description,
301        };
302        let mut response = (self.status, Json(body)).into_response();
303
304        if self.status == StatusCode::UNAUTHORIZED
305            && let Ok(value) = HeaderValue::from_str(
306                "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"",
307            )
308        {
309            response
310                .headers_mut()
311                .insert(header::WWW_AUTHENTICATE, value);
312        }
313
314        response
315    }
316}
317
318impl From<OauthError> for OAuthHttpError {
319    fn from(err: OauthError) -> Self {
320        match &err {
321            OauthError::InvalidClient(_) | OauthError::ClientNotFound(_) => {
322                Self::invalid_client(err.to_string())
323            },
324            OauthError::InvalidGrant(_)
325            | OauthError::CodeNotFound(_)
326            | OauthError::TokenNotFound(_)
327            | OauthError::PkceMismatch(_)
328            | OauthError::Expired(_) => Self::invalid_grant(err.to_string()),
329            OauthError::Validation(_) => Self::invalid_request(err.to_string()),
330            OauthError::Unauthorized(_) => Self::access_denied(err.to_string()),
331            OauthError::UsernameTaken(_) => Self::username_unavailable(
332                "Username is already taken. Please choose a different username.",
333            ),
334            OauthError::EmailRegistered(_) => {
335                Self::email_exists("An account with this email already exists.")
336            },
337            OauthError::UserNotFound(_) => Self::not_found(err.to_string()),
338            OauthError::RegistrationStateExpired => Self::expired_challenge(
339                "Registration challenge has expired. Please start the registration process again.",
340            ),
341            OauthError::WebAuthnVerificationFailed(_) => Self::invalid_credential(
342                "WebAuthn verification failed. Please ensure your authenticator and browser are \
343                 compatible.",
344            ),
345            OauthError::WebAuthn(_)
346            | OauthError::User(_)
347            | OauthError::Session(_)
348            | OauthError::TokenInvalid(_)
349            | OauthError::TokenAlgMismatch { .. }
350            | OauthError::TokenMissingKid
351            | OauthError::TokenUnknownKid { .. }
352            | OauthError::Provider(_)
353            | OauthError::Repository(_)
354            | OauthError::DatabaseRepository(_)
355            | OauthError::Config(_)
356            | OauthError::Crypto(_)
357            | OauthError::Internal(_) => Self::server_error(err.to_string()),
358        }
359    }
360}
361
362impl From<AuthProviderError> for OAuthHttpError {
363    fn from(err: AuthProviderError) -> Self {
364        match &err {
365            AuthProviderError::InvalidCredentials | AuthProviderError::InvalidToken => {
366                Self::invalid_client(err.to_string())
367            },
368            AuthProviderError::UserNotFound => Self::not_found(err.to_string()),
369            AuthProviderError::TokenExpired => Self::invalid_grant(err.to_string()),
370            AuthProviderError::InsufficientPermissions => Self::access_denied(err.to_string()),
371            _ => Self::server_error(err.to_string()),
372        }
373    }
374}
375
376impl From<SecretsBootstrapError> for OAuthHttpError {
377    fn from(err: SecretsBootstrapError) -> Self {
378        Self::server_error(err.to_string())
379    }
380}
381
382impl From<sqlx::Error> for OAuthHttpError {
383    fn from(err: sqlx::Error) -> Self {
384        if let sqlx::Error::Database(db_err) = &err
385            && db_err.is_unique_violation()
386        {
387            return Self::new(OAuthErrorCode::UsernameUnavailable, err.to_string());
388        }
389        Self::server_error(err.to_string())
390    }
391}
392
393impl From<anyhow::Error> for OAuthHttpError {
394    fn from(err: anyhow::Error) -> Self {
395        Self::server_error(err.to_string())
396    }
397}