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
use axum::{
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Missing authentication token")]
MissingToken,
#[error("Invalid token: {0}")]
InvalidToken(String),
#[error("Invalid claims: {0}")]
InvalidClaims(String),
/// Claims enrichment against the IdP userinfo endpoint failed — the
/// presented token cannot be resolved to a principal with authorization
/// attributes. This is an AUTHENTICATION failure and must answer 401
/// with an explicit retry signal, never 403-with-defaults.
///
/// b82a1925 guard-1: the previous behavior logged the enrichment error
/// as "non-fatal" and let a role-less principal fall through to a
/// `role="viewer"` default — a silent downgrade that default-denied as a
/// lying 403 and hid recurring token age-out windows for days.
///
/// Wire contract (spec-fixed, not left to taste):
/// - status 401
/// - `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750)
/// - JSON body: `{"error":"token_expired","retryable":true,"detail":…}`
/// so a client can mechanically distinguish retryable authn failure
/// from a real authz denial (403 is NEVER retryable).
/// - occurrence journaled at WARN with the underlying reason.
#[error("Token enrichment failed: {0}")]
EnrichmentFailed(String),
#[error("PEP error: {0}")]
PepError(#[from] pep::error::PepError),
#[error("Internal error: {0}")]
Internal(String),
}
impl AuthError {
/// The `WWW-Authenticate` challenge value carried by every 401 this
/// service emits. `error="invalid_token"` is constant per the wire
/// contract; `error_description` carries the specific reason.
fn www_authenticate(reason: &str) -> String {
format!(
"Bearer error=\"invalid_token\", error_description=\"{}\"",
reason.replace('"', "'")
)
}
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self {
AuthError::EnrichmentFailed(reason) => {
let body = serde_json::json!({
// Uniform machine-readable contract: any 401 from this
// service is retryable-exactly-once; `detail` preserves
// the underlying cause (invalid_token, IdP outage, …)
// for operators without changing client handling.
"error": "token_expired",
"retryable": true,
"detail": reason,
});
let mut resp = (StatusCode::UNAUTHORIZED, Json(body)).into_response();
resp.headers_mut().insert(
header::WWW_AUTHENTICATE,
Self::www_authenticate("token enrichment failed")
.parse()
.expect("static header value"),
);
resp
}
AuthError::MissingToken | AuthError::InvalidToken(_) => {
let mut resp = (StatusCode::UNAUTHORIZED, self.to_string()).into_response();
resp.headers_mut().insert(
header::WWW_AUTHENTICATE,
Self::www_authenticate("missing or invalid bearer token")
.parse()
.expect("static header value"),
);
resp
}
AuthError::InvalidClaims(_) => {
(StatusCode::FORBIDDEN, self.to_string()).into_response()
}
AuthError::PepError(ref e) => (e.status_code(), self.to_string()).into_response(),
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::HttpBody;
/// b82a1925 guard-1 wire contract: enrichment failure answers
/// 401 + WWW-Authenticate + machine-readable retryable body —
/// mechanically distinguishable from a real 403 authz denial.
#[test]
fn enrichment_failure_is_401_with_retry_signal() {
let resp = AuthError::EnrichmentFailed(
"Userinfo endpoint returned error: {\"error\":\"invalid_token\"} status=400"
.to_string(),
)
.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let www = resp
.headers()
.get(header::WWW_AUTHENTICATE)
.expect("WWW-Authenticate header required")
.to_str()
.unwrap();
assert!(www.starts_with("Bearer error=\"invalid_token\""), "{www}");
let body = resp.into_body();
// Body must be the machine-readable JSON (not the Display string).
let bytes = futures::executor::block_on(axum::body::to_bytes(
axum::body::Body::new(body),
4096,
))
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("JSON body");
assert_eq!(v["error"], "token_expired");
assert_eq!(v["retryable"], true);
assert!(v["detail"]
.as_str()
.unwrap()
.contains("invalid_token"));
}
/// 403 must NEVER appear on the enrichment-failure path — that is the
/// lying status that masked this bug class (b82a1925).
#[test]
fn authz_denial_stays_403_and_enrichment_never_is() {
let claims_resp = AuthError::InvalidClaims("no permit".to_string()).into_response();
assert_eq!(claims_resp.status(), StatusCode::FORBIDDEN);
let enrich_resp = AuthError::EnrichmentFailed("x".to_string()).into_response();
assert_ne!(enrich_resp.status(), StatusCode::FORBIDDEN);
}
#[test]
fn missing_token_carries_challenge_header() {
let resp = AuthError::MissingToken.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert!(resp.headers().get(header::WWW_AUTHENTICATE).is_some());
}
}