haste_server/auth_n/oidc/
error.rs1use axum::{
5 http::{HeaderValue, StatusCode, header},
6 response::{IntoResponse, Redirect},
7};
8use url::Url;
9
10#[derive(serde::Serialize, Debug, PartialEq, Eq)]
11pub enum OIDCErrorCode {
12 #[serde(rename = "invalid_request")]
17 InvalidRequest,
18 #[serde(rename = "unauthorized_client")]
23 UnauthorizedClient,
24 #[serde(rename = "unsupported_response_type")]
29 UnsupportedResponseType,
30 #[serde(rename = "invalid_scope")]
34 InvalidScope,
35 #[serde(rename = "server_error")]
43 ServerError,
44 #[serde(rename = "temporarily_unavailable")]
52 TemporarilyUnavailable,
53 #[serde(rename = "invalid_client")]
66 InvalidClient,
67 #[serde(rename = "invalid_grant")]
75 InvalidGrant,
76
77 #[serde(rename = "access_denied")]
78 AccessDenied,
79}
80
81impl From<&OIDCErrorCode> for &str {
82 fn from(code: &OIDCErrorCode) -> Self {
83 match code {
84 OIDCErrorCode::InvalidRequest => "invalid_request",
85 OIDCErrorCode::UnauthorizedClient => "unauthorized_client",
86 OIDCErrorCode::UnsupportedResponseType => "unsupported_response_type",
87 OIDCErrorCode::InvalidScope => "invalid_scope",
88 OIDCErrorCode::ServerError => "server_error",
89 OIDCErrorCode::TemporarilyUnavailable => "temporarily_unavailable",
90 OIDCErrorCode::InvalidClient => "invalid_client",
91 OIDCErrorCode::InvalidGrant => "invalid_grant",
92 OIDCErrorCode::AccessDenied => "access_denied",
93 }
94 }
95}
96
97#[allow(dead_code)]
101#[derive(serde::Serialize, Debug)]
102pub struct OIDCError {
103 pub error: OIDCErrorCode,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub error_description: Option<String>,
106 #[serde(skip_serializing)]
115 pub redirect_uri: Option<String>,
116 #[serde(skip_serializing)]
118 pub state: Option<String>,
119}
120
121impl OIDCError {
122 pub fn new(
123 error: OIDCErrorCode,
124 error_description: Option<String>,
125 redirect_uri: Option<String>,
126 ) -> Self {
127 OIDCError {
128 error,
129 error_description,
130 redirect_uri,
131 state: None,
132 }
133 }
134
135 #[must_use]
136 pub fn with_state(mut self, state: &str) -> Self {
137 self.state = Some(state.to_string());
138 self
139 }
140
141 fn error_redirect_url(&self) -> Option<Url> {
150 let mut url = Url::parse(self.redirect_uri.as_deref()?).ok()?;
151 let error_code: &str = (&self.error).into();
152
153 {
154 let mut query = url.query_pairs_mut();
155 query.append_pair("error", error_code);
156
157 if let Some(description) = self.error_description.as_deref() {
158 query.append_pair("error_description", description);
159 }
160
161 if let Some(state) = self.state.as_deref() {
162 query.append_pair("state", state);
163 }
164 }
165
166 Some(url)
167 }
168}
169
170impl IntoResponse for OIDCError {
171 fn into_response(self) -> axum::response::Response {
172 if let Some(redirect_url) = self.error_redirect_url() {
173 Redirect::to(redirect_url.as_str()).into_response()
174 } else {
175 let json_body = serde_json::to_string(&self).unwrap_or_default();
177
178 let status = match self.error {
179 OIDCErrorCode::InvalidRequest
180 | OIDCErrorCode::InvalidGrant
181 | OIDCErrorCode::InvalidScope => StatusCode::BAD_REQUEST,
182 OIDCErrorCode::UnauthorizedClient | OIDCErrorCode::InvalidClient => {
183 StatusCode::UNAUTHORIZED
184 }
185 OIDCErrorCode::UnsupportedResponseType => StatusCode::UNPROCESSABLE_ENTITY,
186 OIDCErrorCode::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
187 OIDCErrorCode::TemporarilyUnavailable => StatusCode::SERVICE_UNAVAILABLE,
188 OIDCErrorCode::AccessDenied => StatusCode::FORBIDDEN,
189 };
190
191 let mut response = (
192 status,
193 [(
194 header::CONTENT_TYPE,
195 HeaderValue::from_static("application/json"),
196 )],
197 json_body,
198 )
199 .into_response();
200
201 if self.error == OIDCErrorCode::InvalidClient {
203 response.headers_mut().insert(
204 header::WWW_AUTHENTICATE,
205 HeaderValue::from_static("Basic realm=\"token\""),
206 );
207 }
208
209 response
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn invalid_client_is_401_with_www_authenticate() {
220 let response = OIDCError::new(OIDCErrorCode::InvalidClient, None, None).into_response();
221
222 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
223 assert!(response.headers().contains_key(header::WWW_AUTHENTICATE));
224 }
225
226 #[test]
227 fn invalid_grant_is_400() {
228 let response = OIDCError::new(OIDCErrorCode::InvalidGrant, None, None).into_response();
229
230 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
231 }
232
233 #[test]
234 fn invalid_scope_redirects_with_error_and_state() {
235 let response = OIDCError::new(
236 OIDCErrorCode::InvalidScope,
237 Some("bad".to_string()),
238 Some("https://app.example/cb".to_string()),
239 )
240 .with_state("abc")
241 .into_response();
242
243 let location = response.headers()[header::LOCATION].to_str().unwrap();
244 assert!(location.starts_with("https://app.example/cb?error=invalid_scope"));
245 assert!(location.ends_with("&state=abc"));
246 }
247}