cratefield_oauth_client/
config.rs1#[derive(Clone)]
6pub struct ProviderConfig {
7 pub authorize_url: String,
9 pub token_url: String,
11 pub revoke_url: Option<String>,
15 pub client_id: String,
17 pub client_secret: Option<String>,
19 pub scopes: Vec<String>,
23 pub scope_separator: &'static str,
26 pub client_auth: ClientAuth,
28}
29
30impl std::fmt::Debug for ProviderConfig {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.debug_struct("ProviderConfig")
35 .field("authorize_url", &self.authorize_url)
36 .field("token_url", &self.token_url)
37 .field("revoke_url", &self.revoke_url)
38 .field("client_id", &self.client_id)
39 .field(
40 "client_secret",
41 &self.client_secret.as_ref().map(|_| "<redacted>"),
42 )
43 .field("scopes", &self.scopes)
44 .field("scope_separator", &self.scope_separator)
45 .field("client_auth", &self.client_auth)
46 .finish()
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ClientAuth {
53 ClientSecretPost,
56 ClientSecretBasic,
58}
59
60#[must_use]
67pub fn authorize_url(
68 config: &ProviderConfig,
69 redirect_uri: &str,
70 state: &str,
71 pkce: Option<&crate::Pkce>,
72) -> String {
73 let mut params: Vec<(&str, String)> = vec![
74 ("response_type", "code".to_owned()),
75 ("client_id", config.client_id.clone()),
76 ("redirect_uri", redirect_uri.to_owned()),
77 ("state", state.to_owned()),
78 ];
79 if !config.scopes.is_empty() {
80 params.push(("scope", config.scopes.join(config.scope_separator)));
81 }
82 if let Some(pkce) = pkce {
83 params.push(("code_challenge", pkce.challenge().to_owned()));
84 params.push(("code_challenge_method", "S256".to_owned()));
85 }
86 let query = params
87 .into_iter()
88 .map(|(name, value)| format!("{name}={}", form_encode(&value)))
89 .collect::<Vec<_>>()
90 .join("&");
91 let base = config.authorize_url.trim_end_matches(['?', '&']);
95 let separator = if base.contains('?') { "&" } else { "?" };
96 format!("{base}{separator}{query}")
97}
98
99pub(crate) fn form_encode(value: &str) -> String {
104 let mut out = String::with_capacity(value.len());
105 for byte in value.bytes() {
106 match byte {
107 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
108 out.push(byte as char);
109 }
110 b' ' => out.push('+'),
111 other => {
112 const HEX: [u8; 16] = *b"0123456789ABCDEF";
113 out.push('%');
114 out.push(HEX[usize::from(other >> 4)] as char);
115 out.push(HEX[usize::from(other & 0x0f)] as char);
116 }
117 }
118 }
119 out
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn config() -> ProviderConfig {
127 ProviderConfig {
128 authorize_url: "https://provider.example/oauth/authorize".to_owned(),
129 token_url: "https://provider.example/oauth/token".to_owned(),
130 revoke_url: None,
131 client_id: "client".to_owned(),
132 client_secret: Some("hunter2-do-not-log".to_owned()),
133 scopes: vec!["read".to_owned(), "write things".to_owned()],
134 scope_separator: " ",
135 client_auth: ClientAuth::ClientSecretPost,
136 }
137 }
138
139 #[test]
140 fn form_encoding_escapes_what_matters() {
141 assert_eq!(form_encode("a b"), "a+b");
142 assert_eq!(form_encode("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e");
143 assert_eq!(form_encode("plain-value_1.~"), "plain-value_1.~");
144 }
145
146 #[test]
147 fn the_authorize_url_carries_the_whole_ask() {
148 let url = authorize_url(&config(), "https://api.test/v1/callback", "st.at.e", None);
149 assert_eq!(
150 url,
151 "https://provider.example/oauth/authorize?response_type=code&client_id=client\
152 &redirect_uri=https%3A%2F%2Fapi.test%2Fv1%2Fcallback&state=st.at.e\
153 &scope=read+write+things"
154 );
155 }
156
157 #[test]
158 fn pkce_adds_the_s256_pair_when_asked() {
159 let pkce = crate::Pkce::new().expect("randomness");
160 let url = authorize_url(&config(), "https://api.test/cb", "s", Some(&pkce));
161 assert!(url.contains("code_challenge_method=S256"), "{url}");
162 assert!(
163 url.contains(&format!("code_challenge={}", pkce.challenge())),
164 "{url}"
165 );
166 }
167
168 #[test]
171 fn an_authorize_url_with_a_query_already_on_it_is_joined_not_doubled() {
172 let mut with_query = config();
173 with_query.authorize_url =
174 "https://provider.example/oauth/authorize?tenant=acme".to_owned();
175 let url = authorize_url(&with_query, "https://api.test/cb", "s", None);
176 assert_eq!(
177 url.matches('?').count(),
178 1,
179 "one question mark, however the endpoint was written: {url}"
180 );
181 assert!(
182 url.starts_with(
183 "https://provider.example/oauth/authorize?tenant=acme&response_type=code"
184 ),
185 "{url}"
186 );
187
188 with_query.authorize_url = "https://provider.example/oauth/authorize?".to_owned();
189 let url = authorize_url(&with_query, "https://api.test/cb", "s", None);
190 assert!(
191 url.starts_with("https://provider.example/oauth/authorize?response_type=code"),
192 "a trailing ? must not double: {url}"
193 );
194
195 with_query.authorize_url =
196 "https://provider.example/oauth/authorize?tenant=acme&".to_owned();
197 let url = authorize_url(&with_query, "https://api.test/cb", "s", None);
198 assert!(
199 url.starts_with(
200 "https://provider.example/oauth/authorize?tenant=acme&response_type=code"
201 ),
202 "a trailing & must not double: {url}"
203 );
204 }
205
206 #[test]
208 fn debug_of_the_config_never_shows_the_client_secret() {
209 let debug = format!("{:?}", config());
210 assert!(!debug.contains("hunter2"), "{debug}");
211 assert!(debug.contains("<redacted>"), "{debug}");
212 }
213}