Skip to main content

mz_frontegg_auth/
error.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::sync::Arc;
11
12use reqwest::StatusCode;
13use thiserror::Error;
14
15use crate::AppPasswordParseError;
16
17#[derive(Clone, Error, Debug)]
18pub enum Error {
19    #[error(transparent)]
20    InvalidPasswordFormat(#[from] AppPasswordParseError),
21    #[error("invalid token format: {0}")]
22    InvalidTokenFormat(#[from] jsonwebtoken::errors::Error),
23    #[error("authentication token exchange failed: {0}")]
24    ReqwestError(Arc<reqwest::Error>),
25    #[error("middleware programming error: {0}")]
26    MiddlewareError(Arc<anyhow::Error>),
27    #[error("authentication token missing claims")]
28    MissingClaims,
29    #[error("authentication token expired")]
30    TokenExpired,
31    #[error("unauthorized organization")]
32    UnauthorizedTenant,
33    #[error("the app password was not valid")]
34    InvalidAppPassword,
35    #[error("user in access token did not match the expected user")]
36    WrongUser,
37    #[error("user name too long")]
38    UserNameTooLong,
39    #[error("user declared by tenant access token cannot be an email address")]
40    InvalidTenantApiTokenUser,
41    #[error("tenant access token does not declare a user")]
42    MissingTenantApiTokenUser,
43    #[error("request timeout")]
44    Timeout(Arc<tokio::time::error::Elapsed>),
45    #[error("internal error")]
46    Internal(Arc<anyhow::Error>),
47}
48
49impl Error {
50    /// Whether authentication failed because the client's credentials were rejected: Frontegg
51    /// refused them, or they are malformed or belong to a different user or tenant.
52    ///
53    /// Every other failure is on the server side. That includes Frontegg throttling (`408`,
54    /// `429`) past the client's retries, and a token Frontegg issued for valid credentials that
55    /// does not validate against the configured key, the clock or the expected claims.
56    pub fn is_credential_rejection(&self) -> bool {
57        match self {
58            Error::ReqwestError(e) => e
59                .status()
60                .is_some_and(|s| s == StatusCode::UNAUTHORIZED || s == StatusCode::FORBIDDEN),
61            Error::InvalidPasswordFormat(_)
62            | Error::InvalidAppPassword
63            | Error::UnauthorizedTenant
64            | Error::WrongUser
65            | Error::UserNameTooLong
66            | Error::InvalidTenantApiTokenUser
67            | Error::MissingTenantApiTokenUser => true,
68            Error::InvalidTokenFormat(_)
69            | Error::MissingClaims
70            | Error::TokenExpired
71            | Error::Timeout(_)
72            | Error::MiddlewareError(_)
73            | Error::Internal(_) => false,
74        }
75    }
76}
77
78impl From<anyhow::Error> for Error {
79    fn from(value: anyhow::Error) -> Self {
80        Error::Internal(Arc::new(value))
81    }
82}
83
84impl From<tokio::time::error::Elapsed> for Error {
85    fn from(value: tokio::time::error::Elapsed) -> Self {
86        Error::Timeout(Arc::new(value))
87    }
88}
89
90impl From<reqwest::Error> for Error {
91    fn from(value: reqwest::Error) -> Self {
92        Error::ReqwestError(Arc::new(value))
93    }
94}
95
96impl From<reqwest_middleware::Error> for Error {
97    fn from(value: reqwest_middleware::Error) -> Self {
98        match value {
99            reqwest_middleware::Error::Middleware(e) => Error::MiddlewareError(Arc::new(e)),
100            reqwest_middleware::Error::Reqwest(e) => Error::ReqwestError(Arc::new(e)),
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
108
109    use axum::Router;
110    use axum::http::StatusCode as AxumStatusCode;
111    use axum::routing::post;
112    use tokio::net::TcpListener;
113
114    use super::*;
115
116    #[mz_ore::test(tokio::test)]
117    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `TLS_method` on OS `linux`
118    async fn only_rejected_credentials_are_the_clients_fault() {
119        // Answers every request with the status code in its path.
120        let app = Router::new().route(
121            "/{:status_code}",
122            post(
123                |axum::extract::Path(code): axum::extract::Path<u16>| async move {
124                    AxumStatusCode::from_u16(code).unwrap()
125                },
126            ),
127        );
128        let tcp = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))
129            .await
130            .unwrap();
131        let addr = tcp.local_addr().unwrap();
132        mz_ore::task::spawn(|| "test-server", async move {
133            axum::serve(tcp, app.into_make_service()).await.unwrap();
134        });
135
136        for (status, rejection) in [
137            (401, true),
138            (403, true),
139            (408, false),
140            (429, false),
141            (503, false),
142        ] {
143            let err = reqwest::Client::new()
144                .post(format!("http://{addr}/{status}"))
145                .send()
146                .await
147                .unwrap()
148                .error_for_status()
149                .expect_err("an error status");
150            assert_eq!(
151                Error::from(err).is_credential_rejection(),
152                rejection,
153                "status {status}"
154            );
155        }
156
157        // Checks on the token Frontegg issued fail on the server side.
158        assert!(!Error::TokenExpired.is_credential_rejection());
159        assert!(!Error::MissingClaims.is_credential_rejection());
160        // Checks on what the client sent do not.
161        assert!(Error::WrongUser.is_credential_rejection());
162        assert!(Error::UserNameTooLong.is_credential_rejection());
163        // The user a tenant API token declares is set when the client creates it.
164        assert!(Error::MissingTenantApiTokenUser.is_credential_rejection());
165    }
166}