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