Skip to main content

haste_server/auth_n/oidc/
error.rs

1// Custom OIDC error types
2// Based on https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 and https://openid.net/specs/openid-connect-core-1_0.html and 3.1.2.6
3
4use 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    /**
13     * The request is missing a required parameter, includes aninvalid parameter value,
14     * includes a parameter more than once, or is otherwise malformed.
15     */
16    #[serde(rename = "invalid_request")]
17    InvalidRequest,
18    /**
19     * The client is not authorized to request an authorization
20     * code using this method.
21     */
22    #[serde(rename = "unauthorized_client")]
23    UnauthorizedClient,
24    /**
25     * The authorization server does not support obtaining an
26     * authorization code using this method.
27     */
28    #[serde(rename = "unsupported_response_type")]
29    UnsupportedResponseType,
30    /**
31     * The requested scope is invalid, unknown, or malformed.
32     */
33    #[serde(rename = "invalid_scope")]
34    InvalidScope,
35    /**
36     * The authorization server encountered an unexpected
37     * condition that prevented it from fulfilling the request.
38     * (This error code is needed because a 500 Internal Server
39     * Error HTTP status code cannot be returned to the client
40     * via an HTTP redirect.)
41     */
42    #[serde(rename = "server_error")]
43    ServerError,
44    /**
45     * The authorization server is currently unable to handle
46     * the request due to a temporary overloading or maintenance
47     * of the server.  (This error code is needed because a 503
48     * Service Unavailable HTTP status code cannot be returned
49     * to the client via an HTTP redirect.)
50     */
51    #[serde(rename = "temporarily_unavailable")]
52    TemporarilyUnavailable,
53    /**
54     * Client authentication failed (e.g., unknown client, no
55     * client authentication included, or unsupported
56     * authentication method).  The authorization server MAY
57     * return an HTTP 401 (Unauthorized) status code to indicate
58     * which HTTP authentication schemes are supported.  If the
59     * client attempted to authenticate via the "Authorization"
60     * request header field, the authorization server MUST
61     * respond with an HTTP 401 (Unauthorized) status code and
62     * include the "WWW-Authenticate" response header field
63     * matching the authentication scheme used by the client.
64     */
65    #[serde(rename = "invalid_client")]
66    InvalidClient,
67    /**
68     *  The provided authorization grant (e.g., authorization
69     * code, resource owner credentials) or refresh token is
70     * invalid, expired, revoked, does not match the redirection
71     * URI used in the authorization request, or was issued to
72     * another client.
73     */
74    #[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/// An OAuth 2.0 error. Sent as a redirect carrying `error` and
98/// `error_description` (RFC 6749 §4.1.2.1), or as a JSON body with the same
99/// names (§5.2).
100#[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    /// Where the user agent is sent with the error, per RFC 6749 §4.1.2.1.
107    ///
108    /// Only a redirect URI that has already been validated against the client
109    /// application's registered redirect URIs may be set here. The response
110    /// redirects to it without further checks, so an unvalidated value turns
111    /// the authorization server into an open redirector. RFC 6749 §4.1.2.1
112    /// says a request with an invalid redirect URI must not redirect at all,
113    /// and the token endpoint (§5.2) never redirects: both leave this `None`.
114    #[serde(skip_serializing)]
115    pub redirect_uri: Option<String>,
116    /// The request's `state`, echoed in the redirect (RFC 6749 §4.1.2.1).
117    #[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    /// The URL the user agent is redirected to, with `error` and
142    /// `error_description` added as query parameters.
143    ///
144    /// `None` when the error carries no redirect URI, or when that URI does
145    /// not parse as an absolute URL: a malformed URI cannot be redirected to.
146    /// The parameters are appended to any query the URI already has, and are
147    /// percent-encoded, so neither the description nor an existing query can
148    /// break the URL.
149    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            // RFC 6749 §5.2: a JSON body with the error code and description.
176            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            // RFC 6749 §5.2: a 401 `invalid_client` names the scheme the client may use.
202            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}