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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
//! Builder for [`OAuthAuthenticator`]. The async [`build`] step
//! fetches JWKS at construction time so misconfigurations
//! (unreachable endpoint, malformed JWK set, missing required field)
//! fail fast — handlers never see a half-constructed authenticator.
//!
//! [`build`]: OAuthAuthenticatorBuilder::build
use crate::authenticator::OAuthAuthenticator;
use crate::cache::TokenCache;
use crate::error::OAuthError;
use crate::introspect::IntrospectionConfig;
use crate::jwks::Cache;
use jsonwebtoken::Algorithm;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::Arc;
/// Fluent builder for [`OAuthAuthenticator`].
pub struct OAuthAuthenticatorBuilder {
issuer_url: Option<String>,
audience: Option<String>,
allowed_algs: Vec<Algorithm>,
jwks_uri: Option<String>,
required_scopes: HashMap<String, Vec<String>>,
http: Option<reqwest::Client>,
introspection: Option<IntrospectionConfig>,
token_cache_capacity: usize,
}
impl Default for OAuthAuthenticatorBuilder {
fn default() -> Self {
Self {
issuer_url: None,
audience: None,
allowed_algs: vec![Algorithm::RS256, Algorithm::ES256],
jwks_uri: None,
required_scopes: HashMap::new(),
http: None,
introspection: None,
token_cache_capacity: 0,
}
}
}
impl OAuthAuthenticatorBuilder {
/// OAuth issuer URL — validated against the JWT `iss` claim.
/// Required.
pub fn issuer_url(mut self, url: impl Into<String>) -> Self {
self.issuer_url = Some(url.into());
self
}
/// Expected token audience — validated against the JWT `aud`
/// claim. Required; [`build`] returns
/// [`OAuthError::Misconfigured`] when not set.
///
/// [`build`]: OAuthAuthenticatorBuilder::build
pub fn audience(mut self, aud: impl Into<String>) -> Self {
self.audience = Some(aud.into());
self
}
/// Override the algorithm allowlist. Default: `[RS256, ES256]`.
/// The header `alg` of every JWT is checked against this list;
/// tokens using an unlisted algorithm are rejected immediately
/// with [`AuthError::Rejected`] before any key material is consulted.
///
/// Passing an empty `Vec` causes [`OAuthAuthenticatorBuilder::build`] to
/// return [`OAuthError::Misconfigured`].
///
/// [`AuthError::Rejected`]: klieo_auth_common::AuthError::Rejected
pub fn allowed_algorithms(mut self, algs: Vec<Algorithm>) -> Self {
self.allowed_algs = algs;
self
}
/// JWKS endpoint — fetched once at [`build`] and refreshed once
/// on signature-verification failure. Required.
///
/// [`build`]: OAuthAuthenticatorBuilder::build
pub fn jwks_uri(mut self, uri: impl Into<String>) -> Self {
self.jwks_uri = Some(uri.into());
self
}
/// Method-to-required-scopes allow-list consulted by
/// [`Authenticator::authorize_method`]. Methods absent from the
/// map are denied; methods mapped to an empty `Vec` allow any
/// authenticated principal; methods mapped to a non-empty `Vec`
/// require at least one matching scope on the token.
///
/// [`Authenticator::authorize_method`]: klieo_auth_common::Authenticator::authorize_method
pub fn required_scopes(mut self, map: HashMap<String, Vec<String>>) -> Self {
self.required_scopes = map;
self
}
/// Bring-your-own [`reqwest::Client`] (e.g. for connection
/// pooling or custom TLS roots). Optional; the default builds a
/// fresh client.
pub fn http_client(mut self, http: reqwest::Client) -> Self {
self.http = Some(http);
self
}
/// Configure RFC 7662 introspection fallback. When JWT
/// verification fails (e.g. opaque token), the authenticator
/// POSTs to the introspection endpoint with Basic auth and
/// extracts sub + scope from the response.
///
/// See ADR-021.
pub fn with_introspection(
mut self,
url: impl Into<String>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
self.introspection = Some(IntrospectionConfig {
url: url.into(),
client_id: client_id.into(),
client_secret: client_secret.into(),
});
self
}
/// Enable an LRU cache for verified bearer tokens. The cache is
/// keyed by SHA-256(token) and holds `(sub, scopes, expires_at)`;
/// a cache hit short-circuits both the JWT signature verify and
/// the introspection roundtrip.
///
/// `capacity` is the maximum number of entries retained. `0`
/// (the default) disables the cache entirely. Reasonable values
/// sit in the low thousands — each entry holds a `sub` string,
/// the scope set, and a `SystemTime`.
///
/// Entry TTL follows the token's `exp` claim, clamped to a
/// 5-minute ceiling so long-lived tokens still re-verify every
/// five minutes. Tokens without a usable `exp` fall back to a
/// 60-second TTL.
///
/// **Revocation caveat**: a token revoked between cache insert
/// and entry expiry continues to authenticate. Operators that
/// need sub-minute revocation latency should either configure
/// short `exp` claims at the IdP or leave the cache disabled.
#[must_use]
pub fn with_token_cache(mut self, capacity: usize) -> Self {
self.token_cache_capacity = capacity;
self
}
/// Fetch the IdP's OpenID Connect Discovery document at
/// `{issuer_url}/.well-known/openid-configuration` and
/// auto-populate `jwks_uri` + `issuer_url` from the response.
///
/// Async because it fetches over HTTP at config time. Errors
/// surface as `OAuthError::DiscoveryFailed`; operators can
/// fall back to explicit `.jwks_uri(...)` if discovery is
/// not available.
///
/// See ADR-021.
pub async fn with_discovery(
mut self,
issuer_url: impl Into<String>,
) -> Result<Self, OAuthError> {
let issuer = issuer_url.into();
let http = crate::http::default_http_client()?;
let doc = crate::discovery::fetch(&http, &issuer).await?;
// OIDC Discovery §4.3 / RFC 8414 §3.3: the document's `issuer` MUST be
// identical to the URL it was fetched from. A mismatch means a spoofed
// or misconfigured document; trusting its `issuer` would pin the JWT
// `iss` check to an attacker-chosen value. Tolerate only a trailing
// slash (same origin + path).
if doc.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') {
return Err(OAuthError::DiscoveryFailed(format!(
"issuer mismatch: document declared an issuer that differs from {issuer}"
)));
}
self.issuer_url = Some(doc.issuer);
self.jwks_uri = Some(doc.jwks_uri);
Ok(self)
}
/// Validate configuration, fetch JWKS once, and return a ready
/// [`OAuthAuthenticator`]. Fails fast on missing required fields,
/// unreachable JWKS endpoint, or malformed JWK set.
///
/// # Errors
///
/// Returns [`OAuthError::Misconfigured`] when any of the following are missing or invalid:
/// - `issuer_url` — set via [`.issuer_url()`](OAuthAuthenticatorBuilder::issuer_url)
/// - `audience` — set via [`.audience()`](OAuthAuthenticatorBuilder::audience)
/// - `allowed_algs` is empty — set via [`.allowed_algorithms()`](OAuthAuthenticatorBuilder::allowed_algorithms)
/// - `jwks_uri` — set via [`.jwks_uri()`](OAuthAuthenticatorBuilder::jwks_uri)
///
/// Returns [`OAuthError::JwksFetch`] when the JWKS endpoint is unreachable or returns an error.
pub async fn build(self) -> Result<OAuthAuthenticator, OAuthError> {
let issuer = self
.issuer_url
.ok_or_else(|| OAuthError::Misconfigured("issuer_url required".into()))?;
let audience = self.audience.ok_or_else(|| {
OAuthError::Misconfigured("audience required; call .audience() on the builder".into())
})?;
if self.allowed_algs.is_empty() {
return Err(OAuthError::Misconfigured(
"allowed_algs must not be empty".into(),
));
}
let jwks_uri = self
.jwks_uri
.ok_or_else(|| OAuthError::Misconfigured("jwks_uri required".into()))?;
let http = match self.http {
Some(http) => http,
None => crate::http::default_http_client()?,
};
let cache = Cache::fetch(http.clone(), jwks_uri).await?;
let token_cache =
NonZeroUsize::new(self.token_cache_capacity).map(|cap| Arc::new(TokenCache::new(cap)));
Ok(OAuthAuthenticator {
issuer,
audience,
allowed_algs: self.allowed_algs,
cache,
scope_map: Arc::new(self.required_scopes),
http,
introspection: self.introspection,
token_cache,
})
}
}
impl OAuthAuthenticator {
/// Construct a fluent builder for this authenticator.
pub fn builder() -> OAuthAuthenticatorBuilder {
OAuthAuthenticatorBuilder::default()
}
/// Capability-shaped default — fetch the OIDC discovery document
/// at `issuer_url`, validate tokens against `audience`, and
/// return a ready [`OAuthAuthenticator`].
///
/// Uses default algorithm allowlist (`RS256` + `ES256`) and an
/// empty `required_scopes` map. **Empty `required_scopes` denies
/// every method** — `authorize_method` requires an entry per
/// method (empty `Vec` allows any authenticated principal,
/// non-empty `Vec` requires at least one matching scope). Call
/// [`Self::builder`] and chain
/// [`OAuthAuthenticatorBuilder::required_scopes`] to allow-list
/// the methods your transport exposes. Same path for introspection,
/// custom `reqwest::Client`, or a non-default algorithm list.
///
/// # Errors
///
/// - [`OAuthError::DiscoveryFailed`] when the discovery document
/// cannot be fetched or parsed.
/// - [`OAuthError::JwksFetch`] when the JWKS endpoint discovered
/// from the document is unreachable or returns an error.
pub async fn from_oidc(
issuer_url: impl Into<String>,
audience: impl Into<String>,
) -> Result<Self, OAuthError> {
Self::builder()
.with_discovery(issuer_url)
.await?
.audience(audience)
.build()
.await
}
}