Skip to main content

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}