oauth_resource_server/policy.rs
1//! Startup policy: which static token, if any, the auth layer holds alongside
2//! OAuth — and whether the configuration leaves the protected routes open.
3//!
4//! Pure decision logic, no logging: every outcome is a distinct
5//! [`StaticTokenDecision`] variant precisely so an application can say, in its
6//! own words and naming its own settings, what it decided (a static-only
7//! deployment deserves a startup nudge towards OAuth; an ignored token deserves
8//! an explanation).
9
10use crate::config::ResolvedOAuthConfig;
11
12/// The outcome of [`static_token_policy`].
13#[derive(Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum StaticTokenDecision {
16 /// Dual mode: the static token and OAuth access tokens are both accepted.
17 StaticAndOAuth(String),
18 /// Only the static token protects the routes (OAuth is off).
19 StaticOnly(String),
20 /// Only OAuth access tokens are accepted; no static token was configured.
21 OAuthOnly,
22 /// A static token was configured but OAuth's
23 /// [`accept_static_bearer`](crate::OAuthConfig::accept_static_bearer) is
24 /// false, so it is ignored and only OAuth access tokens are accepted.
25 StaticIgnored,
26 /// Nothing is configured and the caller explicitly allowed unauthenticated
27 /// access: the routes are open. Build the pass-through deliberately (with the
28 /// `axum` feature, `AuthLayer::allow_unauthenticated`) and say so loudly at
29 /// startup.
30 Unauthenticated,
31}
32
33impl StaticTokenDecision {
34 /// Whether the decision was made with OAuth on (a resolved OAuth config was
35 /// passed to [`static_token_policy`]), i.e. whether an OAuth validator must
36 /// accompany it.
37 pub fn oauth_enabled(&self) -> bool {
38 match self {
39 Self::StaticAndOAuth(_) | Self::OAuthOnly | Self::StaticIgnored => true,
40 Self::StaticOnly(_) | Self::Unauthenticated => false,
41 }
42 }
43
44 /// The static token to accept, if any.
45 pub fn static_token(&self) -> Option<&str> {
46 match self {
47 Self::StaticAndOAuth(t) | Self::StaticOnly(t) => Some(t),
48 Self::OAuthOnly | Self::StaticIgnored | Self::Unauthenticated => None,
49 }
50 }
51
52 /// Consume the decision, yielding the static token to accept, if any.
53 pub fn into_static_token(self) -> Option<String> {
54 match self {
55 Self::StaticAndOAuth(t) | Self::StaticOnly(t) => Some(t),
56 Self::OAuthOnly | Self::StaticIgnored | Self::Unauthenticated => None,
57 }
58 }
59}
60
61/// Hand-written so the token itself never reaches a log line through `{:?}`.
62impl std::fmt::Debug for StaticTokenDecision {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::StaticAndOAuth(_) => f.write_str("StaticAndOAuth(<redacted>)"),
66 Self::StaticOnly(_) => f.write_str("StaticOnly(<redacted>)"),
67 Self::OAuthOnly => f.write_str("OAuthOnly"),
68 Self::StaticIgnored => f.write_str("StaticIgnored"),
69 Self::Unauthenticated => f.write_str("Unauthenticated"),
70 }
71 }
72}
73
74/// Neither a static token nor OAuth is configured, and unauthenticated access
75/// was not explicitly allowed. The application should refuse to start, telling
76/// the operator in its own terms how to configure one of the two (or how to opt
77/// out explicitly).
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79#[error(
80 "no authentication is configured: set a static token or enable OAuth, or explicitly \
81 allow unauthenticated access"
82)]
83#[non_exhaustive]
84pub struct NoAuthConfigured;
85
86/// Decide which static token the auth layer holds.
87///
88/// - `static_token`: the configured secret, read by the caller (from an
89/// environment variable, a file, a vault...) so this stays a pure, testable
90/// function. `Some("")` counts as unset. A whitespace-only value (a
91/// secret loader that trims, such as the `env` feature's, never returns
92/// one) can never be matched, so it is not a static token either — but it
93/// is never "nothing configured": with no OAuth it is [`NoAuthConfigured`]
94/// even when `allow_unauthenticated` is set, so a blank secret can neither
95/// open the routes nor build a layer that silently admits nobody.
96/// - `oauth`: the RESOLVED OAuth config — `Some` only when OAuth is genuinely
97/// on ([`crate::OAuthConfig::resolve`] returns `None` for a disabled block) —
98/// so neither `accept_static_bearer: false` nor a disabled OAuth block can be
99/// what leaves the routes open.
100/// - `allow_unauthenticated`: the explicit opt-out. It matters only when
101/// nothing else is configured; it never weakens a configured credential.
102///
103/// This is the only reader of [`accept_static_bearer`](crate::OAuthConfig::accept_static_bearer):
104/// build the auth layer from the decision (with the `axum` feature,
105/// `AuthLayer::from_decision`), not from the raw static token, or that setting
106/// has no effect.
107///
108/// # Errors
109///
110/// [`NoAuthConfigured`] when the result would leave the routes with no
111/// authentication and `allow_unauthenticated` is false.
112///
113/// # Examples
114///
115/// ```
116/// use oauth_resource_server::{StaticTokenDecision, static_token_policy};
117///
118/// // A static token and no OAuth.
119/// let decision = static_token_policy(Some("k3y".into()), None, false).unwrap();
120/// assert_eq!(decision, StaticTokenDecision::StaticOnly("k3y".into()));
121/// assert_eq!(decision.static_token(), Some("k3y"));
122/// assert!(!decision.oauth_enabled());
123///
124/// // Nothing configured: refused unless unauthenticated access is asked for by name.
125/// assert!(static_token_policy(None, None, false).is_err());
126/// assert_eq!(
127/// static_token_policy(None, None, true).unwrap(),
128/// StaticTokenDecision::Unauthenticated
129/// );
130/// ```
131pub fn static_token_policy(
132 static_token: Option<String>,
133 oauth: Option<&ResolvedOAuthConfig>,
134 allow_unauthenticated: bool,
135) -> Result<StaticTokenDecision, NoAuthConfigured> {
136 // Set but blank: unusable as a secret, and never a reason to open up.
137 let blank = static_token
138 .as_deref()
139 .is_some_and(|v| !v.is_empty() && v.trim().is_empty());
140 let static_token = static_token.filter(|v| !v.trim().is_empty());
141 match (static_token, oauth) {
142 (Some(_), Some(o)) if !o.accept_static_bearer => Ok(StaticTokenDecision::StaticIgnored),
143 (Some(token), Some(_)) => Ok(StaticTokenDecision::StaticAndOAuth(token)),
144 (Some(token), None) => Ok(StaticTokenDecision::StaticOnly(token)),
145 (None, Some(_)) => Ok(StaticTokenDecision::OAuthOnly),
146 (None, None) if allow_unauthenticated && !blank => Ok(StaticTokenDecision::Unauthenticated),
147 (None, None) => Err(NoAuthConfigured),
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::testing;
155
156 use StaticTokenDecision::*;
157
158 fn tok() -> Option<String> {
159 Some("secret".to_string())
160 }
161
162 /// Ported from mcp-md-wiki's `static_bearer_token_resolution_matrix`: the
163 /// token each case yields is unchanged, and the variant says why.
164 #[test]
165 fn static_token_resolution_matrix() {
166 let mut oauth = testing::resolved_config("http://127.0.0.1:1/jwks");
167
168 // Dual mode, static-only, OAuth-only.
169 let d = static_token_policy(tok(), Some(&oauth), false).unwrap();
170 assert_eq!(d, StaticAndOAuth("secret".into()));
171 assert_eq!(d.static_token(), Some("secret"));
172 let d = static_token_policy(tok(), None, false).unwrap();
173 assert_eq!(d, StaticOnly("secret".into()));
174 assert_eq!(d.into_static_token(), tok());
175 assert_eq!(
176 static_token_policy(None, Some(&oauth), false).unwrap(),
177 OAuthOnly
178 );
179 assert_eq!(
180 static_token_policy(Some(String::new()), Some(&oauth), false).unwrap(),
181 OAuthOnly
182 );
183 // No credential of either kind: refuse unless explicitly opted out.
184 assert_eq!(
185 static_token_policy(None, None, false).unwrap_err(),
186 NoAuthConfigured
187 );
188 assert_eq!(
189 static_token_policy(Some(String::new()), None, false).unwrap_err(),
190 NoAuthConfigured
191 );
192 let d = static_token_policy(None, None, true).unwrap();
193 assert_eq!(d, Unauthenticated);
194 assert_eq!(d.static_token(), None);
195
196 // `accept_static_bearer: false` drops a set token when OAuth is on.
197 oauth.accept_static_bearer = false;
198 let d = static_token_policy(tok(), Some(&oauth), false).unwrap();
199 assert_eq!(d, StaticIgnored);
200 assert_eq!(d.into_static_token(), None);
201 // ...and without a token it is simply OAuth-only.
202 assert_eq!(
203 static_token_policy(None, Some(&oauth), false).unwrap(),
204 OAuthOnly
205 );
206 }
207
208 #[test]
209 fn allow_unauthenticated_never_weakens_a_configured_credential() {
210 let mut oauth = testing::resolved_config("http://127.0.0.1:1/jwks");
211 assert_eq!(
212 static_token_policy(tok(), None, true).unwrap(),
213 StaticOnly("secret".into())
214 );
215 assert_eq!(
216 static_token_policy(None, Some(&oauth), true).unwrap(),
217 OAuthOnly
218 );
219 assert_eq!(
220 static_token_policy(tok(), Some(&oauth), true).unwrap(),
221 StaticAndOAuth("secret".into())
222 );
223 oauth.accept_static_bearer = false;
224 assert_eq!(
225 static_token_policy(tok(), Some(&oauth), true).unwrap(),
226 StaticIgnored
227 );
228 }
229
230 #[test]
231 fn a_whitespace_token_is_never_a_credential_and_never_opens_the_routes() {
232 // Kept, it would build a layer that admits nobody without saying so;
233 // treated as unset, `allow_unauthenticated` would open the server.
234 // Neither: with nothing else configured it is refused either way.
235 for blank in [" ", " ", "\t\n"] {
236 for allow in [false, true] {
237 assert!(
238 static_token_policy(Some(blank.into()), None, allow).is_err(),
239 "{blank:?} allow_unauthenticated={allow}"
240 );
241 }
242 let oauth = testing::resolved_config("http://127.0.0.1:1/jwks");
243 assert_eq!(
244 static_token_policy(Some(blank.into()), Some(&oauth), true).unwrap(),
245 OAuthOnly
246 );
247 }
248 // An empty one is still plain "unset".
249 assert_eq!(
250 static_token_policy(Some(String::new()), None, true).unwrap(),
251 Unauthenticated
252 );
253 }
254
255 #[test]
256 fn oauth_enabled_says_whether_a_validator_must_accompany_the_decision() {
257 let oauth = testing::resolved_config("http://127.0.0.1:1/jwks");
258 let mut ignoring = oauth.clone();
259 ignoring.accept_static_bearer = false;
260 for (decision, expected) in [
261 (static_token_policy(tok(), Some(&oauth), false), true),
262 (static_token_policy(None, Some(&oauth), false), true),
263 (static_token_policy(tok(), Some(&ignoring), false), true),
264 (static_token_policy(tok(), None, false), false),
265 (static_token_policy(None, None, true), false),
266 ] {
267 let decision = decision.unwrap();
268 assert_eq!(decision.oauth_enabled(), expected, "{decision:?}");
269 }
270 }
271
272 #[test]
273 fn debug_never_prints_the_token() {
274 for d in [
275 StaticAndOAuth("hunter2".into()),
276 StaticOnly("hunter2".into()),
277 ] {
278 let rendered = format!("{d:?}");
279 assert!(!rendered.contains("hunter2"), "{rendered}");
280 }
281 }
282}