protocol_droid/application/oauth/
flow.rs1use crate::domain::oauth::challenge::BearerChallenge;
17
18pub const MAX_STEP_UP_ATTEMPTS: u32 = 3;
21
22pub const OFFLINE_ACCESS: &str = "offline_access";
24
25#[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#[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#[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#[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}