1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
//! Internal OIDC/JWT/session errors — NOT the same as `fraiseql_error::AuthError`.
//!
//! This `AuthError` carries diagnostic detail for the middleware/handler layer
//! (JWT parse reasons, OIDC metadata failures, PKCE state errors). It is
//! never exposed directly to clients — it gets mapped to the domain-level
//! `fraiseql_error::AuthError` before reaching the HTTP response.
use thiserror::Error;
/// All errors that can arise in the authentication and authorization layer.
///
/// Each variant maps to an appropriate HTTP status code via the [`axum::response::IntoResponse`]
/// implementation in `middleware.rs`. Internal details are never forwarded to API clients —
/// the `IntoResponse` impl always returns a generic user-facing message and logs the
/// internal reason via `tracing::warn!`.
#[derive(Debug, Error, Clone)]
#[non_exhaustive]
pub enum AuthError {
/// A supplied token could not be parsed or validated.
/// The `reason` field contains internal diagnostic detail and must not be
/// sent to API clients.
#[error("Invalid token: {reason}")]
InvalidToken {
/// Internal description of why the token is invalid (not forwarded to callers).
reason: String,
},
/// The token's `exp` claim is in the past.
#[error("Token expired. Obtain a new token by re-authenticating.")]
TokenExpired,
/// The token's cryptographic signature did not verify against the expected key.
#[error("Token signature is invalid. Ensure the token was issued by the expected provider.")]
InvalidSignature,
/// A required JWT claim (`sub`, `iss`, `aud`, etc.) was absent from the token.
#[error("Missing required claim: {claim}")]
MissingClaim {
/// Name of the missing claim (e.g., `"sub"`, `"aud"`).
claim: String,
},
/// A claim was present but its value did not satisfy the validator's constraints.
#[error("Invalid claim: {claim} - {reason}")]
InvalidClaimValue {
/// Name of the claim that failed validation.
claim: String,
/// Internal description of the validation failure (not forwarded to callers).
reason: String,
},
/// An error was returned by the upstream OAuth provider (e.g., during code exchange).
/// The `message` field must not be forwarded to API clients — it may contain
/// provider-internal URLs, error codes, or rate-limit state.
#[error("OAuth error: {message}")]
OAuthError {
/// Provider-internal error message (not forwarded to callers).
message: String,
},
/// A session-store operation failed (creation, lookup, or revocation).
#[error("Session error: {message}")]
SessionError {
/// Internal session error details (not forwarded to callers).
message: String,
},
/// A database operation within the auth layer failed.
/// Must never be forwarded to API clients — the message may reveal
/// connection strings, query structure, or infrastructure topology.
#[error("Database error: {message}")]
DatabaseError {
/// Internal database error message (not forwarded to callers).
message: String,
},
/// The auth subsystem was misconfigured or a required configuration value was missing.
/// Must never be forwarded to API clients — the message may reveal file paths,
/// environment variable names, or key material.
#[error("Configuration error: {message}")]
ConfigError {
/// Internal configuration error details (not forwarded to callers).
message: String,
},
/// Fetching or parsing the OIDC discovery document failed.
#[error("OIDC metadata error: {message}")]
OidcMetadataError {
/// Internal metadata fetch error details (not forwarded to callers).
message: String,
},
/// A PKCE (Proof Key for Code Exchange, RFC 7636) operation failed.
#[error("PKCE error: {message}")]
PkceError {
/// Internal PKCE error details (not forwarded to callers).
message: String,
},
/// The OAuth `state` parameter did not match any stored CSRF token.
/// This may indicate a replay attack or an expired authorization flow.
#[error("State validation failed")]
InvalidState,
/// No `Authorization: Bearer <token>` header was present in the request.
#[error(
"No authentication token provided. Include a Bearer token in the Authorization header."
)]
TokenNotFound,
/// The session associated with a refresh token has been explicitly revoked.
#[error("Session revoked")]
SessionRevoked,
/// The authenticated user lacks the required permission for the requested operation.
/// The `message` field contains the specific permission check detail and must not
/// be forwarded to API clients in full (it reveals internal role/permission names).
#[error("Forbidden: {message}")]
Forbidden {
/// Internal permission check details (not forwarded to callers).
message: String,
},
/// An unexpected internal error occurred. Must never be forwarded to API clients.
#[error("Internal error: {message}")]
Internal {
/// Internal error details (not forwarded to callers).
message: String,
},
/// The system clock returned an unexpected value during a time-sensitive operation.
/// This typically indicates a misconfigured system clock or clock rollback.
#[error("System time error: {message}")]
SystemTimeError {
/// Internal system time error details (not forwarded to callers).
message: String,
},
/// The client exceeded the configured rate limit for this endpoint.
/// Unlike most other variants, the retry window is safe to forward to clients.
#[error("Rate limited: retry after {retry_after_secs} seconds")]
RateLimited {
/// How many seconds the client must wait before retrying.
retry_after_secs: u64,
},
/// The OIDC ID token is missing the required `nonce` claim.
///
/// Returned when an expected nonce was provided for comparison but the token
/// does not carry a `nonce` claim. May indicate a misconfigured provider or
/// a token replay attempt using a stripped token.
/// See RFC 6749 §10.12 / OpenID Connect Core §3.1.3.7.
#[error("ID token is missing the required nonce claim")]
MissingNonce,
/// The `nonce` claim in the ID token does not match the expected value.
///
/// Indicates a possible token replay or session fixation attack.
/// See RFC 6749 §10.12 / OpenID Connect Core §3.1.3.7.
#[error("ID token nonce mismatch — possible replay attack")]
NonceMismatch,
/// The OIDC ID token is missing the `auth_time` claim when `max_age` was requested.
///
/// When `max_age` is sent in the authorization request, the provider MUST include
/// `auth_time` in the ID token. Its absence indicates a non-conformant provider.
/// See OpenID Connect Core §3.1.3.7.
#[error("ID token is missing auth_time claim (required when max_age is used)")]
MissingAuthTime,
/// The session authentication time exceeds the allowed `max_age`.
///
/// The provider authenticated the user too long ago for this request's `max_age`
/// constraint. The user must re-authenticate to obtain a fresh session.
/// See OpenID Connect Core §3.1.3.7.
#[error("Session is too old: authenticated {age}s ago, max_age is {max_age_secs}s")]
SessionTooOld {
/// How many seconds ago the session was authenticated.
age: i64,
/// Maximum allowed authentication age in seconds (from the authorization request).
max_age_secs: u64,
},
}
/// Convenience alias for `Result<T, AuthError>`.
pub type Result<T> = std::result::Result<T, AuthError>;