Skip to main content

protocol_droid/application/oauth/
flow.rs

1//! The client-side decisions of the authorization flow that need no network.
2//!
3//! Three rules from the authorization page, each a pure function so it can be
4//! tested without an authorization server:
5//! <https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#scope-selection-strategy>
6//!
7//! - **Initial scope**: use the `scope` from the `WWW-Authenticate` challenge;
8//!   failing that, all of `scopes_supported`; failing that, send none.
9//! - **Step-up**: re-authorize with the union of what was requested before and
10//!   what the new challenge names, so earlier permissions are not lost.
11//! - **Retry limit**: a few attempts, then a permanent failure.
12//!
13//! `offline_access` is added only when the server lists it in its own
14//! `scopes_supported`, and a resource must never advertise it.
15
16use crate::domain::oauth::challenge::BearerChallenge;
17
18/// How many step-up authorizations are attempted for one operation before the
19/// failure is treated as permanent. "No more than a few times".
20pub const MAX_STEP_UP_ATTEMPTS: u32 = 3;
21
22/// The `offline_access` scope, which asks for a refresh token.
23pub const OFFLINE_ACCESS: &str = "offline_access";
24
25/// Picks the scope for the first authorization request.
26///
27/// `None` means send no `scope` parameter at all, which is what the page asks
28/// when neither the challenge nor `scopes_supported` names any.
29#[must_use]
30pub fn initial_scope(
31    challenge: &BearerChallenge,
32    resource_scopes_supported: Option<&[String]>,
33) -> Option<String> {
34    let challenged = challenge.scopes();
35    if !challenged.is_empty() {
36        return Some(challenged.join(" "));
37    }
38    resource_scopes_supported
39        .filter(|scopes| !scopes.is_empty())
40        .map(|scopes| scopes.join(" "))
41}
42
43/// The scope for a step-up: everything requested before, plus the challenge.
44///
45/// Order is kept and duplicates dropped, so the request is stable and does not
46/// grow on a retry that asks for what it already has.
47#[must_use]
48pub fn step_up_scope(previous: Option<&str>, challenge: &BearerChallenge) -> Option<String> {
49    let mut merged: Vec<&str> = Vec::new();
50    for scope in previous
51        .into_iter()
52        .flat_map(str::split_ascii_whitespace)
53        .chain(challenge.scopes())
54    {
55        if !merged.contains(&scope) {
56            merged.push(scope);
57        }
58    }
59    (!merged.is_empty()).then(|| merged.join(" "))
60}
61
62/// Adds `offline_access` to `scope` when the authorization server lists it.
63///
64/// A client that wants a refresh token "MAY add `offline_access`" only when the
65/// server's metadata contains it in `scopes_supported`; asking for it
66/// otherwise requests something the server never offered.
67#[must_use]
68pub fn with_offline_access(
69    scope: Option<String>,
70    server_scopes_supported: &[String],
71) -> Option<String> {
72    if !server_scopes_supported.iter().any(|s| s == OFFLINE_ACCESS) {
73        return scope;
74    }
75    match scope {
76        Some(existing)
77            if existing
78                .split_ascii_whitespace()
79                .any(|s| s == OFFLINE_ACCESS) =>
80        {
81            Some(existing)
82        }
83        Some(existing) => Some(format!("{existing} {OFFLINE_ACCESS}")),
84        None => Some(OFFLINE_ACCESS.to_owned()),
85    }
86}
87
88/// Whether another step-up may be attempted after `attempts` have been made.
89#[must_use]
90pub const fn may_step_up(attempts: u32) -> bool {
91    attempts < MAX_STEP_UP_ATTEMPTS
92}
93
94#[cfg(test)]
95mod tests {
96    use super::{initial_scope, may_step_up, step_up_scope, with_offline_access};
97    use crate::domain::oauth::challenge::parse_bearer;
98
99    fn challenge(header: &str) -> crate::domain::oauth::challenge::BearerChallenge {
100        parse_bearer(header).unwrap_or_default()
101    }
102
103    #[test]
104    fn the_challenge_wins_over_scopes_supported() {
105        let supported = vec!["x".to_owned()];
106        assert_eq!(
107            initial_scope(&challenge(r#"Bearer scope="a b""#), Some(&supported)),
108            Some("a b".to_owned())
109        );
110    }
111
112    #[test]
113    fn scopes_supported_is_the_fallback_and_absence_sends_none() {
114        let supported = vec!["x".to_owned(), "y".to_owned()];
115        assert_eq!(
116            initial_scope(&challenge("Bearer"), Some(&supported)),
117            Some("x y".to_owned())
118        );
119        assert_eq!(initial_scope(&challenge("Bearer"), None), None);
120    }
121
122    #[test]
123    fn step_up_keeps_what_was_granted() {
124        assert_eq!(
125            step_up_scope(Some("read"), &challenge(r#"Bearer scope="write read""#)),
126            Some("read write".to_owned())
127        );
128    }
129
130    #[test]
131    fn offline_access_only_when_the_server_lists_it() {
132        let listed = vec!["offline_access".to_owned()];
133        assert_eq!(
134            with_offline_access(Some("a".to_owned()), &listed),
135            Some("a offline_access".to_owned())
136        );
137        assert_eq!(
138            with_offline_access(Some("a".to_owned()), &[]),
139            Some("a".to_owned())
140        );
141        assert_eq!(
142            with_offline_access(None, &listed),
143            Some("offline_access".to_owned())
144        );
145    }
146
147    #[test]
148    fn step_up_stops_after_a_few_attempts() {
149        assert!(may_step_up(2));
150        assert!(!may_step_up(3));
151    }
152}