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. Whitespace is not trimmed here — a
91/// secret loader that trims (such as the `env` feature's) does it first — and
92/// an untrimmed whitespace-only value stays configured: a blank credential is
93/// never matched, so it admits nobody rather than opening anything.
94/// - `oauth`: the RESOLVED OAuth config — `Some` only when OAuth is genuinely
95/// on ([`crate::OAuthConfig::resolve`] returns `None` for a disabled block) —
96/// so neither `accept_static_bearer: false` nor a disabled OAuth block can be
97/// what leaves the routes open.
98/// - `allow_unauthenticated`: the explicit opt-out. It matters only when
99/// nothing else is configured; it never weakens a configured credential.
100///
101/// This is the only reader of [`accept_static_bearer`](crate::OAuthConfig::accept_static_bearer):
102/// build the auth layer from the decision (with the `axum` feature,
103/// `AuthLayer::from_decision`), not from the raw static token, or that setting
104/// has no effect.
105///
106/// # Errors
107///
108/// [`NoAuthConfigured`] when the result would leave the routes with no
109/// authentication and `allow_unauthenticated` is false.
110///
111/// # Examples
112///
113/// ```
114/// use oauth_resource_server::{StaticTokenDecision, static_token_policy};
115///
116/// // A static token and no OAuth.
117/// let decision = static_token_policy(Some("k3y".into()), None, false).unwrap();
118/// assert_eq!(decision, StaticTokenDecision::StaticOnly("k3y".into()));
119/// assert_eq!(decision.static_token(), Some("k3y"));
120/// assert!(!decision.oauth_enabled());
121///
122/// // Nothing configured: refused unless unauthenticated access is asked for by name.
123/// assert!(static_token_policy(None, None, false).is_err());
124/// assert_eq!(
125/// static_token_policy(None, None, true).unwrap(),
126/// StaticTokenDecision::Unauthenticated
127/// );
128/// ```
129pub fn static_token_policy(
130 static_token: Option<String>,
131 oauth: Option<&ResolvedOAuthConfig>,
132 allow_unauthenticated: bool,
133) -> Result<StaticTokenDecision, NoAuthConfigured> {
134 let static_token = static_token.filter(|v| !v.is_empty());
135 match (static_token, oauth) {
136 (Some(_), Some(o)) if !o.accept_static_bearer => Ok(StaticTokenDecision::StaticIgnored),
137 (Some(token), Some(_)) => Ok(StaticTokenDecision::StaticAndOAuth(token)),
138 (Some(token), None) => Ok(StaticTokenDecision::StaticOnly(token)),
139 (None, Some(_)) => Ok(StaticTokenDecision::OAuthOnly),
140 (None, None) if allow_unauthenticated => Ok(StaticTokenDecision::Unauthenticated),
141 (None, None) => Err(NoAuthConfigured),
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::testing;
149
150 use StaticTokenDecision::*;
151
152 fn tok() -> Option<String> {
153 Some("secret".to_string())
154 }
155
156 /// Ported from mcp-md-wiki's `static_bearer_token_resolution_matrix`: the
157 /// token each case yields is unchanged, and the variant says why.
158 #[test]
159 fn static_token_resolution_matrix() {
160 let mut oauth = testing::resolved_config("http://127.0.0.1:1/jwks");
161
162 // Dual mode, static-only, OAuth-only.
163 let d = static_token_policy(tok(), Some(&oauth), false).unwrap();
164 assert_eq!(d, StaticAndOAuth("secret".into()));
165 assert_eq!(d.static_token(), Some("secret"));
166 let d = static_token_policy(tok(), None, false).unwrap();
167 assert_eq!(d, StaticOnly("secret".into()));
168 assert_eq!(d.into_static_token(), tok());
169 assert_eq!(
170 static_token_policy(None, Some(&oauth), false).unwrap(),
171 OAuthOnly
172 );
173 assert_eq!(
174 static_token_policy(Some(String::new()), Some(&oauth), false).unwrap(),
175 OAuthOnly
176 );
177 // No credential of either kind: refuse unless explicitly opted out.
178 assert_eq!(
179 static_token_policy(None, None, false).unwrap_err(),
180 NoAuthConfigured
181 );
182 assert_eq!(
183 static_token_policy(Some(String::new()), None, false).unwrap_err(),
184 NoAuthConfigured
185 );
186 let d = static_token_policy(None, None, true).unwrap();
187 assert_eq!(d, Unauthenticated);
188 assert_eq!(d.static_token(), None);
189
190 // `accept_static_bearer: false` drops a set token when OAuth is on.
191 oauth.accept_static_bearer = false;
192 let d = static_token_policy(tok(), Some(&oauth), false).unwrap();
193 assert_eq!(d, StaticIgnored);
194 assert_eq!(d.into_static_token(), None);
195 // ...and without a token it is simply OAuth-only.
196 assert_eq!(
197 static_token_policy(None, Some(&oauth), false).unwrap(),
198 OAuthOnly
199 );
200 }
201
202 #[test]
203 fn allow_unauthenticated_never_weakens_a_configured_credential() {
204 let mut oauth = testing::resolved_config("http://127.0.0.1:1/jwks");
205 assert_eq!(
206 static_token_policy(tok(), None, true).unwrap(),
207 StaticOnly("secret".into())
208 );
209 assert_eq!(
210 static_token_policy(None, Some(&oauth), true).unwrap(),
211 OAuthOnly
212 );
213 assert_eq!(
214 static_token_policy(tok(), Some(&oauth), true).unwrap(),
215 StaticAndOAuth("secret".into())
216 );
217 oauth.accept_static_bearer = false;
218 assert_eq!(
219 static_token_policy(tok(), Some(&oauth), true).unwrap(),
220 StaticIgnored
221 );
222 }
223
224 #[test]
225 fn a_whitespace_token_stays_configured() {
226 // Not trimmed here: treating it as unset would let `allow_unauthenticated`
227 // turn it into an open server; kept, it admits nobody.
228 assert_eq!(
229 static_token_policy(Some(" ".into()), None, true).unwrap(),
230 StaticOnly(" ".into())
231 );
232 }
233
234 #[test]
235 fn oauth_enabled_says_whether_a_validator_must_accompany_the_decision() {
236 let oauth = testing::resolved_config("http://127.0.0.1:1/jwks");
237 let mut ignoring = oauth.clone();
238 ignoring.accept_static_bearer = false;
239 for (decision, expected) in [
240 (static_token_policy(tok(), Some(&oauth), false), true),
241 (static_token_policy(None, Some(&oauth), false), true),
242 (static_token_policy(tok(), Some(&ignoring), false), true),
243 (static_token_policy(tok(), None, false), false),
244 (static_token_policy(None, None, true), false),
245 ] {
246 let decision = decision.unwrap();
247 assert_eq!(decision.oauth_enabled(), expected, "{decision:?}");
248 }
249 }
250
251 #[test]
252 fn debug_never_prints_the_token() {
253 for d in [
254 StaticAndOAuth("hunter2".into()),
255 StaticOnly("hunter2".into()),
256 ] {
257 let rendered = format!("{d:?}");
258 assert!(!rendered.contains("hunter2"), "{rendered}");
259 }
260 }
261}