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. 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}