Skip to main content

riley_auth_core/
error.rs

1use std::path::PathBuf;
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6
7#[derive(Debug, thiserror::Error)]
8pub enum Error {
9    // Config
10    #[error("config not found (searched: {searched:?})")]
11    ConfigNotFound { searched: Vec<PathBuf> },
12
13    #[error("config parse error in {path}: {source}")]
14    ConfigParse {
15        path: PathBuf,
16        source: toml::de::Error,
17    },
18
19    #[error("config error: {0}")]
20    Config(String),
21
22    // Database
23    #[error("database error: {0}")]
24    Database(#[from] sqlx::Error),
25
26    #[error("database migration error: {0}")]
27    Migration(#[from] sqlx::migrate::MigrateError),
28
29    // Auth
30    #[error("invalid or expired token")]
31    InvalidToken,
32
33    #[error("token expired")]
34    ExpiredToken,
35
36    #[error("missing authentication")]
37    Unauthenticated,
38
39    #[error("insufficient permissions")]
40    Forbidden,
41
42    #[error("OAuth error: {0}")]
43    OAuth(String),
44
45    #[error("invalid OAuth state")]
46    InvalidOAuthState,
47
48    // User
49    #[error("user not found")]
50    UserNotFound,
51
52    #[error("username taken")]
53    UsernameTaken,
54
55    #[error("username held until {held_until}")]
56    UsernameHeld { held_until: chrono::DateTime<chrono::Utc> },
57
58    #[error("username change on cooldown until {available_at}")]
59    UsernameChangeCooldown {
60        available_at: chrono::DateTime<chrono::Utc>,
61    },
62
63    #[error("invalid username: {reason}")]
64    InvalidUsername { reason: String },
65
66    #[error("reserved username")]
67    ReservedUsername,
68
69    #[error("cannot unlink last provider")]
70    LastProvider,
71
72    #[error("provider already linked")]
73    ProviderAlreadyLinked,
74
75    // OAuth client
76    #[error("invalid client")]
77    InvalidClient,
78
79    #[error("invalid redirect URI")]
80    InvalidRedirectUri,
81
82    #[error("invalid authorization code")]
83    InvalidAuthorizationCode,
84
85    #[error("invalid grant")]
86    InvalidGrant,
87
88    #[error("unsupported grant type")]
89    UnsupportedGrantType,
90
91    #[error("invalid scope")]
92    InvalidScope,
93
94    #[error("consent required")]
95    ConsentRequired,
96
97    // General
98    #[error("bad request: {0}")]
99    BadRequest(String),
100
101    #[error("not found")]
102    NotFound,
103
104    #[error("payload too large")]
105    PayloadTooLarge,
106
107    #[error("unsupported media type")]
108    UnsupportedMediaType,
109
110    #[error("rate limited")]
111    RateLimited,
112
113    #[error(transparent)]
114    Internal(#[from] anyhow::Error),
115}
116
117#[derive(Serialize, utoipa::ToSchema)]
118pub struct ErrorBody {
119    /// Short, stable error code (e.g., "invalid_token", "forbidden").
120    error: String,
121    /// Human-readable error description (omitted for server errors).
122    #[serde(skip_serializing_if = "Option::is_none")]
123    error_description: Option<String>,
124}
125
126impl Error {
127    fn status_code(&self) -> StatusCode {
128        match self {
129            Self::ConfigNotFound { .. }
130            | Self::ConfigParse { .. }
131            | Self::Config(_)
132            | Self::Migration(_) => StatusCode::INTERNAL_SERVER_ERROR,
133
134            Self::Database(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
135
136            Self::InvalidToken | Self::ExpiredToken | Self::Unauthenticated => StatusCode::UNAUTHORIZED,
137            Self::Forbidden => StatusCode::FORBIDDEN,
138
139            Self::OAuth(_) | Self::InvalidOAuthState => StatusCode::BAD_REQUEST,
140
141            Self::UserNotFound | Self::NotFound => StatusCode::NOT_FOUND,
142
143            Self::InvalidClient => StatusCode::UNAUTHORIZED,
144
145            Self::UsernameTaken | Self::ProviderAlreadyLinked => StatusCode::CONFLICT,
146
147            Self::UsernameHeld { .. }
148            | Self::UsernameChangeCooldown { .. }
149            | Self::InvalidUsername { .. }
150            | Self::ReservedUsername
151            | Self::LastProvider
152            | Self::InvalidRedirectUri
153            | Self::InvalidAuthorizationCode
154            | Self::InvalidGrant
155            | Self::UnsupportedGrantType
156            | Self::InvalidScope
157            | Self::ConsentRequired
158            | Self::BadRequest(_) => StatusCode::BAD_REQUEST,
159
160            Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
161            Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
162            Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
163        }
164    }
165
166    /// User-facing error code (short, stable string).
167    fn error_code(&self) -> &'static str {
168        match self {
169            Self::ConfigNotFound { .. } => "config_not_found",
170            Self::ConfigParse { .. } => "config_parse_error",
171            Self::Config(_) => "config_error",
172            Self::Database(_) => "internal_error",
173            Self::Migration(_) => "migration_error",
174            Self::InvalidToken => "invalid_token",
175            Self::ExpiredToken => "invalid_token",
176            Self::Unauthenticated => "unauthenticated",
177            Self::Forbidden => "forbidden",
178            Self::OAuth(_) => "oauth_error",
179            Self::InvalidOAuthState => "invalid_oauth_state",
180            Self::UserNotFound => "user_not_found",
181            Self::UsernameTaken => "username_taken",
182            Self::UsernameHeld { .. } => "username_held",
183            Self::UsernameChangeCooldown { .. } => "username_change_cooldown",
184            Self::InvalidUsername { .. } => "invalid_username",
185            Self::ReservedUsername => "reserved_username",
186            Self::LastProvider => "last_provider",
187            Self::ProviderAlreadyLinked => "provider_already_linked",
188            Self::InvalidClient => "invalid_client",
189            Self::InvalidRedirectUri => "invalid_redirect_uri",
190            Self::InvalidAuthorizationCode => "invalid_grant",
191            Self::InvalidGrant => "invalid_grant",
192            Self::UnsupportedGrantType => "unsupported_grant_type",
193            Self::InvalidScope => "invalid_scope",
194            Self::ConsentRequired => "consent_required",
195            Self::BadRequest(_) => "bad_request",
196            Self::NotFound => "not_found",
197            Self::PayloadTooLarge => "payload_too_large",
198            Self::UnsupportedMediaType => "unsupported_media_type",
199            Self::RateLimited => "rate_limited",
200            Self::Internal(_) => "internal_error",
201        }
202    }
203}
204
205impl IntoResponse for Error {
206    fn into_response(self) -> Response {
207        let status = self.status_code();
208
209        // Log internal errors, don't expose details to client
210        let error_description = if status.is_server_error() {
211            tracing::error!(error = %self, "internal error");
212            None
213        } else {
214            Some(self.to_string())
215        };
216
217        let body = ErrorBody {
218            error: self.error_code().to_string(),
219            error_description,
220        };
221
222        (status, axum::Json(body)).into_response()
223    }
224}
225
226/// Check if a sqlx error is a unique constraint violation (Postgres code 23505).
227pub fn is_unique_violation(err: &Error) -> bool {
228    if let Error::Database(sqlx::Error::Database(db_err)) = err {
229        return db_err.code().as_deref() == Some("23505");
230    }
231    false
232}
233
234/// Return the constraint name from a unique violation error, if available.
235pub fn unique_violation_constraint(err: &Error) -> Option<String> {
236    if let Error::Database(sqlx::Error::Database(db_err)) = err {
237        if db_err.code().as_deref() == Some("23505") {
238            return db_err.constraint().map(|s| s.to_string());
239        }
240    }
241    None
242}
243
244/// Build a `WWW-Authenticate: Bearer` header value per RFC 6750 §3.1.
245///
246/// Returns `Some(value)` for errors that should include the header on
247/// Bearer-token-protected endpoints; `None` for errors where it doesn't apply.
248pub fn www_authenticate_value(issuer: &str, error: &Error) -> Option<String> {
249    // Escape `\` and `"` in the issuer for use in a quoted-string (RFC 7230 §3.2.6).
250    let realm = issuer.replace('\\', "\\\\").replace('"', "\\\"");
251    match error {
252        Error::Unauthenticated => {
253            Some(format!("Bearer realm=\"{realm}\""))
254        }
255        Error::ExpiredToken => {
256            Some(format!(
257                "Bearer realm=\"{realm}\", error=\"invalid_token\", error_description=\"token expired\""
258            ))
259        }
260        Error::InvalidToken => {
261            Some(format!(
262                "Bearer realm=\"{realm}\", error=\"invalid_token\""
263            ))
264        }
265        Error::Forbidden => {
266            Some(format!(
267                "Bearer realm=\"{realm}\", error=\"insufficient_scope\""
268            ))
269        }
270        _ => None,
271    }
272}
273
274pub type Result<T> = std::result::Result<T, Error>;