Skip to main content

cratefield_oauth_client/
config.rs

1//! The provider as configuration: endpoints, credentials, scopes.
2
3/// One OAuth 2.0 provider, as far as this crate needs to know it. Built once
4/// per call site (the credentials usually come from config), never global.
5#[derive(Clone)]
6pub struct ProviderConfig {
7    /// The authorization endpoint a human's browser opens.
8    pub authorize_url: String,
9    /// The token endpoint, `POST`, form-encoded (RFC 6749 §3.2).
10    pub token_url: String,
11    /// The revocation endpoint (RFC 7009), when the provider documents one.
12    /// [`OAuthClient::revoke`](crate::OAuthClient::revoke) refuses to run
13    /// without it rather than guessing.
14    pub revoke_url: Option<String>,
15    /// The client id the provider issued.
16    pub client_id: String,
17    /// The client secret, for the providers that issue one.
18    pub client_secret: Option<String>,
19    /// Scopes requested at authorize time, joined with
20    /// [`ProviderConfig::scope_separator`]. Token responses carry the scopes
21    /// the provider actually granted; ask at authorize, verify after.
22    pub scopes: Vec<String>,
23    /// How the scope list is joined in the authorize URL. Space per
24    /// RFC 6749 §3.3, which every provider this crate has met uses.
25    pub scope_separator: &'static str,
26    /// Where the client credentials ride on token calls.
27    pub client_auth: ClientAuth,
28}
29
30impl std::fmt::Debug for ProviderConfig {
31    /// Configs end up in logs when a connect fails, so the secret is named,
32    /// never printed.
33    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/// How client credentials are presented at the token endpoint (RFC 6749 §2.3).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ClientAuth {
53    /// `client_id` and `client_secret` in the form body. What LinkedIn,
54    /// `Stripe` and most SaaS-style providers do.
55    ClientSecretPost,
56    /// HTTP Basic, per RFC 6749 §2.3.1. What the RFC prefers and Google does.
57    ClientSecretBasic,
58}
59
60/// The authorization URL a human's browser opens: the provider's authorize
61/// endpoint with `response_type=code`, the client, the redirect, the state
62/// and the scopes (RFC 6749 §4.1.1), plus the PKCE pair when the caller is
63/// handed one. No PKCE where the provider does not document it — LinkedIn
64/// does not, and a `code_challenge` it ignores is fine, but one it rejects
65/// is a connect that never worked, so the caller decides.
66#[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    // Some providers document their authorize endpoint with query parameters
92    // already on it (a tenant, a prompt). Join on the right separator, and
93    // never double one left hanging.
94    let base = config.authorize_url.trim_end_matches(['?', '&']);
95    let separator = if base.contains('?') { "&" } else { "?" };
96    format!("{base}{separator}{query}")
97}
98
99/// `application/x-www-form-urlencoded` escaping, for query strings and form
100/// bodies alike. Small and explicit rather than a dependency: unreserved
101/// bytes pass through, space becomes `+`, everything else is `%XX` (with
102/// uppercase hex, which is what the examples in RFC 6749 show).
103pub(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    /// Some providers document their authorize endpoint with parameters
169    /// already on it; the ask must join, not overwrite or double a separator.
170    #[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    /// Configs reach logs on a failed connect; the secret must not.
207    #[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}