Skip to main content

fraiseql_auth/
error.rs

1//! Internal OIDC/JWT/session errors for the auth subsystem.
2//!
3//! This `AuthError` carries diagnostic detail for the middleware/handler layer
4//! (JWT parse reasons, OIDC metadata failures, PKCE state errors). It is never
5//! exposed directly to clients — the `IntoResponse` impl in `middleware.rs`
6//! always returns a generic user-facing message and logs the internal reason.
7//! It composes into the canonical
8//! [`fraiseql_error::FraiseQLError::Auth`] via the `From` impl at the
9//! bottom of this file (sqlx pattern).
10use thiserror::Error;
11
12/// All errors that can arise in the authentication and authorization layer.
13///
14/// Each variant maps to an appropriate HTTP status code via the [`axum::response::IntoResponse`]
15/// implementation in `middleware.rs`. Internal details are never forwarded to API clients —
16/// the `IntoResponse` impl always returns a generic user-facing message and logs the
17/// internal reason via `tracing::warn!`.
18#[derive(Debug, Error, Clone)]
19#[non_exhaustive]
20pub enum AuthError {
21    /// A supplied token could not be parsed or validated.
22    /// The `reason` field contains internal diagnostic detail and must not be
23    /// sent to API clients.
24    #[error("Invalid token: {reason}")]
25    InvalidToken {
26        /// Internal description of why the token is invalid (not forwarded to callers).
27        reason: String,
28    },
29
30    /// The token's `exp` claim is in the past.
31    #[error("Token expired. Obtain a new token by re-authenticating.")]
32    TokenExpired,
33
34    /// The token's cryptographic signature did not verify against the expected key.
35    #[error("Token signature is invalid. Ensure the token was issued by the expected provider.")]
36    InvalidSignature,
37
38    /// A required JWT claim (`sub`, `iss`, `aud`, etc.) was absent from the token.
39    #[error("Missing required claim: {claim}")]
40    MissingClaim {
41        /// Name of the missing claim (e.g., `"sub"`, `"aud"`).
42        claim: String,
43    },
44
45    /// A claim was present but its value did not satisfy the validator's constraints.
46    #[error("Invalid claim: {claim} - {reason}")]
47    InvalidClaimValue {
48        /// Name of the claim that failed validation.
49        claim:  String,
50        /// Internal description of the validation failure (not forwarded to callers).
51        reason: String,
52    },
53
54    /// An error was returned by the upstream OAuth provider (e.g., during code exchange).
55    /// The `message` field must not be forwarded to API clients — it may contain
56    /// provider-internal URLs, error codes, or rate-limit state.
57    #[error("OAuth error: {message}")]
58    OAuthError {
59        /// Provider-internal error message (not forwarded to callers).
60        message: String,
61    },
62
63    /// A session-store operation failed (creation, lookup, or revocation).
64    #[error("Session error: {message}")]
65    SessionError {
66        /// Internal session error details (not forwarded to callers).
67        message: String,
68    },
69
70    /// A database operation within the auth layer failed.
71    /// Must never be forwarded to API clients — the message may reveal
72    /// connection strings, query structure, or infrastructure topology.
73    #[error("Database error: {message}")]
74    DatabaseError {
75        /// Internal database error message (not forwarded to callers).
76        message: String,
77    },
78
79    /// The auth subsystem was misconfigured or a required configuration value was missing.
80    /// Must never be forwarded to API clients — the message may reveal file paths,
81    /// environment variable names, or key material.
82    #[error("Configuration error: {message}")]
83    ConfigError {
84        /// Internal configuration error details (not forwarded to callers).
85        message: String,
86    },
87
88    /// Fetching or parsing the OIDC discovery document failed.
89    #[error("OIDC metadata error: {message}")]
90    OidcMetadataError {
91        /// Internal metadata fetch error details (not forwarded to callers).
92        message: String,
93    },
94
95    /// A PKCE (Proof Key for Code Exchange, RFC 7636) operation failed.
96    #[error("PKCE error: {message}")]
97    PkceError {
98        /// Internal PKCE error details (not forwarded to callers).
99        message: String,
100    },
101
102    /// The OAuth `state` parameter did not match any stored CSRF token.
103    /// This may indicate a replay attack or an expired authorization flow.
104    #[error("State validation failed")]
105    InvalidState,
106
107    /// No `Authorization: Bearer <token>` header was present in the request.
108    #[error(
109        "No authentication token provided. Include a Bearer token in the Authorization header."
110    )]
111    TokenNotFound,
112
113    /// The session associated with a refresh token has been explicitly revoked.
114    #[error("Session revoked")]
115    SessionRevoked,
116
117    /// The authenticated user lacks the required permission for the requested operation.
118    /// The `message` field contains the specific permission check detail and must not
119    /// be forwarded to API clients in full (it reveals internal role/permission names).
120    #[error("Forbidden: {message}")]
121    Forbidden {
122        /// Internal permission check details (not forwarded to callers).
123        message: String,
124    },
125
126    /// An unexpected internal error occurred. Must never be forwarded to API clients.
127    #[error("Internal error: {message}")]
128    Internal {
129        /// Internal error details (not forwarded to callers).
130        message: String,
131    },
132
133    /// The system clock returned an unexpected value during a time-sensitive operation.
134    /// This typically indicates a misconfigured system clock or clock rollback.
135    #[error("System time error: {message}")]
136    SystemTimeError {
137        /// Internal system time error details (not forwarded to callers).
138        message: String,
139    },
140
141    /// The client exceeded the configured rate limit for this endpoint.
142    /// Unlike most other variants, the retry window is safe to forward to clients.
143    #[error("Rate limited: retry after {retry_after_secs} seconds")]
144    RateLimited {
145        /// How many seconds the client must wait before retrying.
146        retry_after_secs: u64,
147    },
148
149    /// The OIDC ID token is missing the required `nonce` claim.
150    ///
151    /// Returned when an expected nonce was provided for comparison but the token
152    /// does not carry a `nonce` claim. May indicate a misconfigured provider or
153    /// a token replay attempt using a stripped token.
154    /// See RFC 6749 §10.12 / OpenID Connect Core §3.1.3.7.
155    #[error("ID token is missing the required nonce claim")]
156    MissingNonce,
157
158    /// The `nonce` claim in the ID token does not match the expected value.
159    ///
160    /// Indicates a possible token replay or session fixation attack.
161    /// See RFC 6749 §10.12 / OpenID Connect Core §3.1.3.7.
162    #[error("ID token nonce mismatch — possible replay attack")]
163    NonceMismatch,
164
165    /// The OIDC ID token is missing the `auth_time` claim when `max_age` was requested.
166    ///
167    /// When `max_age` is sent in the authorization request, the provider MUST include
168    /// `auth_time` in the ID token. Its absence indicates a non-conformant provider.
169    /// See OpenID Connect Core §3.1.3.7.
170    #[error("ID token is missing auth_time claim (required when max_age is used)")]
171    MissingAuthTime,
172
173    /// The session authentication time exceeds the allowed `max_age`.
174    ///
175    /// The provider authenticated the user too long ago for this request's `max_age`
176    /// constraint. The user must re-authenticate to obtain a fresh session.
177    /// See OpenID Connect Core §3.1.3.7.
178    #[error("Session is too old: authenticated {age}s ago, max_age is {max_age_secs}s")]
179    SessionTooOld {
180        /// How many seconds ago the session was authenticated.
181        age:          i64,
182        /// Maximum allowed authentication age in seconds (from the authorization request).
183        max_age_secs: u64,
184    },
185
186    /// The token's `iat` (issued-at) claim is more than [`crate::jwt::MAX_CLOCK_SKEW_SECS`]
187    /// seconds in the future.
188    ///
189    /// A token with a future `iat` was either issued by a clock-skewed provider or forged.
190    /// RFC 7519 §4.1.6 defines `iat` as the time the JWT was issued; values substantially
191    /// ahead of the current time are not credible.
192    #[error(
193        "Token issued-at (iat) is in the future — possible forgery or severe clock skew. \
194         Re-authenticate to obtain a new token."
195    )]
196    TokenIssuedInFuture,
197
198    /// The token's `iat` (issued-at) claim is more than [`crate::jwt::MAX_TOKEN_AGE_SECS`]
199    /// seconds in the past.
200    ///
201    /// Excessively old tokens may be replayed credentials.  Short-lived access tokens expire
202    /// via `exp` long before this limit is reached; this guard targets long-lived or replayed
203    /// tokens.  Re-authenticate to obtain a fresh token.
204    #[error(
205        "Token is too old (iat exceeds maximum token age). \
206         Re-authenticate to obtain a new token."
207    )]
208    TokenTooOld,
209
210    /// The current time is before the token's `nbf` (not-before) claim.
211    ///
212    /// RFC 7519 §4.1.5 prohibits accepting tokens before the `nbf` time (plus the
213    /// allowed clock-skew tolerance).  This typically indicates a race condition, a
214    /// misconfigured issuer, or a replayed token from a future session.
215    #[error(
216        "Token is not yet valid (nbf claim is in the future). \
217         Wait until the token's not-before time has passed."
218    )]
219    TokenNotYetValid,
220
221    /// The OIDC ID token uses a signing algorithm that is explicitly forbidden.
222    ///
223    /// Symmetric algorithms (HS256, HS384, HS512) are forbidden on the OIDC path
224    /// because OIDC providers MUST use asymmetric keys (RS256, RS384, RS512, ES256,
225    /// ES384).  A token claiming a symmetric algorithm was either issued by a
226    /// misconfigured provider or crafted by an attacker attempting an algorithm
227    /// substitution attack (RFC 8725 §2.1).
228    ///
229    /// The `alg` field carries the forbidden algorithm name for operator logging;
230    /// it MUST NOT be forwarded to API clients.
231    #[error("OIDC ID token uses a forbidden algorithm: {alg}")]
232    ForbiddenAlgorithm {
233        /// The algorithm name from the JWT header (e.g., `"HS256"`).
234        alg: String,
235    },
236}
237
238/// Convenience alias for `Result<T, AuthError>`.
239pub type Result<T> = std::result::Result<T, AuthError>;
240
241/// Lossless composition into the canonical [`fraiseql_error::FraiseQLError`].
242///
243/// The auth subsystem owns this conversion (sqlx pattern) so that
244/// `fraiseql-error` can stay a leaf crate in the workspace dependency graph.
245/// The boxed payload preserves the full [`AuthError`] vocabulary via the
246/// `Display`/`source` chain.
247impl From<AuthError> for fraiseql_error::FraiseQLError {
248    fn from(e: AuthError) -> Self {
249        Self::Auth(Box::new(e))
250    }
251}