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 /// A local email + password login failed because the user is unknown **or** the
238 /// password was wrong.
239 ///
240 /// These two cases are deliberately merged into a single variant so the client
241 /// cannot distinguish them (no user-existence oracle); the precise reason is recorded
242 /// in the server audit log instead. The login path equalizes timing across both
243 /// cases by always performing an Argon2 verification.
244 #[error("Invalid email or password")]
245 InvalidCredentials,
246
247 /// A local email + password login presented the **correct** password for an account
248 /// whose local sign-in has been administratively disabled.
249 ///
250 /// Only reachable after a successful password verification, so it never reveals
251 /// account state to a party that does not already hold valid credentials.
252 #[error("This account is disabled")]
253 AccountDisabled,
254
255 /// A local signup was attempted for an email that already has a local credential.
256 #[error("An account already exists for this email")]
257 EmailAlreadyRegistered,
258
259 /// A local signup was rejected by input validation (malformed email or a password
260 /// that violates the length policy).
261 ///
262 /// The `reason` carries the specific validation detail for server-side logging; the
263 /// `IntoResponse` impl returns a generic message to the client.
264 #[error("Invalid registration details: {reason}")]
265 InvalidRegistration {
266 /// Internal validation detail (not forwarded to callers).
267 reason: String,
268 },
269}
270
271/// Convenience alias for `Result<T, AuthError>`.
272pub type Result<T> = std::result::Result<T, AuthError>;
273
274/// Lossless composition into the canonical [`fraiseql_error::FraiseQLError`].
275///
276/// The auth subsystem owns this conversion (sqlx pattern) so that
277/// `fraiseql-error` can stay a leaf crate in the workspace dependency graph.
278/// The boxed payload preserves the full [`AuthError`] vocabulary via the
279/// `Display`/`source` chain.
280impl From<AuthError> for fraiseql_error::FraiseQLError {
281 fn from(e: AuthError) -> Self {
282 Self::Auth(Box::new(e))
283 }
284}