Skip to main content

origin_auth/
config.rs

1use origin_domain::{AppError, Result};
2use origin_secrets::Secret;
3
4/// Authorization parameters the flow itself sets (`crates/origin-auth/src/flow.rs`).
5/// Letting a provider-specific extra param reuse one of these would let it silently
6/// override `state` or `code_challenge` — exactly the values PKCE and CSRF protection
7/// depend on being what the flow generated, not what a caller passed in.
8const RESERVED_AUTHORIZATION_PARAMS: &[&str] = &[
9    "response_type",
10    "client_id",
11    "redirect_uri",
12    "state",
13    "code_challenge",
14    "code_challenge_method",
15    "scope",
16];
17
18/// Everything needed to talk to one OAuth provider.
19#[derive(Debug, Clone)]
20pub struct OAuthConfig {
21    pub client_id: String,
22
23    /// Present only for confidential clients.
24    ///
25    /// A desktop application cannot keep a secret from its user, so most providers
26    /// issue public clients and PKCE replaces the secret entirely. When a provider
27    /// insists on one, it is stored like any other credential.
28    pub client_secret: Option<Secret>,
29
30    pub authorization_endpoint: String,
31    pub token_endpoint: String,
32    pub scopes: Vec<String>,
33
34    /// Provider-specific authorization parameters, e.g. Google's
35    /// `access_type=offline` (without which no refresh token is issued).
36    pub extra_authorization_params: Vec<(String, String)>,
37}
38
39impl OAuthConfig {
40    /// Fails if either endpoint is not `https://` — a plaintext authorization or token
41    /// endpoint lets a network observer read codes, tokens and (for a confidential
42    /// client) the client secret. Use [`OAuthConfig::insecure_for_testing`] for a local
43    /// provider double.
44    pub fn new(
45        client_id: impl Into<String>,
46        authorization_endpoint: impl Into<String>,
47        token_endpoint: impl Into<String>,
48    ) -> Result<Self> {
49        let authorization_endpoint = authorization_endpoint.into();
50        let token_endpoint = token_endpoint.into();
51        require_https("authorization_endpoint", &authorization_endpoint)?;
52        require_https("token_endpoint", &token_endpoint)?;
53
54        Ok(Self::new_unchecked(
55            client_id,
56            authorization_endpoint,
57            token_endpoint,
58        ))
59    }
60
61    /// Skips the HTTPS requirement [`OAuthConfig::new`] enforces. For a local provider
62    /// double in a test, never for a real endpoint — the override is a deliberate,
63    /// separate call so it cannot happen by omission.
64    pub fn insecure_for_testing(
65        client_id: impl Into<String>,
66        authorization_endpoint: impl Into<String>,
67        token_endpoint: impl Into<String>,
68    ) -> Self {
69        Self::new_unchecked(client_id, authorization_endpoint, token_endpoint)
70    }
71
72    fn new_unchecked(
73        client_id: impl Into<String>,
74        authorization_endpoint: impl Into<String>,
75        token_endpoint: impl Into<String>,
76    ) -> Self {
77        Self {
78            client_id: client_id.into(),
79            client_secret: None,
80            authorization_endpoint: authorization_endpoint.into(),
81            token_endpoint: token_endpoint.into(),
82            scopes: Vec::new(),
83            extra_authorization_params: Vec::new(),
84        }
85    }
86
87    pub fn with_scopes<S: Into<String>>(mut self, scopes: impl IntoIterator<Item = S>) -> Self {
88        self.scopes = scopes.into_iter().map(Into::into).collect();
89        self
90    }
91
92    pub fn with_client_secret(mut self, secret: Secret) -> Self {
93        self.client_secret = Some(secret);
94        self
95    }
96
97    /// Adds a provider-specific authorization parameter, e.g. Google's
98    /// `access_type=offline`.
99    ///
100    /// Fails if `key` is one the flow already sets itself (`response_type`, `client_id`,
101    /// `redirect_uri`, `state`, `code_challenge`, `code_challenge_method`, `scope`);
102    /// silently letting a later value win would mean a provider-specific param can
103    /// override `state` or `code_challenge` without that ever being a visible decision.
104    pub fn with_authorization_param(
105        mut self,
106        key: impl Into<String>,
107        value: impl Into<String>,
108    ) -> Result<Self> {
109        let key = key.into();
110        if RESERVED_AUTHORIZATION_PARAMS.contains(&key.as_str()) {
111            return Err(AppError::configuration(format!(
112                "`{key}` is set by the authorization flow itself and cannot be overridden"
113            )));
114        }
115
116        self.extra_authorization_params.push((key, value.into()));
117        Ok(self)
118    }
119
120    pub(crate) fn scope_parameter(&self) -> String {
121        self.scopes.join(" ")
122    }
123}
124
125fn require_https(field: &str, endpoint: &str) -> Result<()> {
126    if endpoint.starts_with("https://") {
127        Ok(())
128    } else {
129        Err(AppError::configuration(format!(
130            "{field} must be https, got `{endpoint}`"
131        )))
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use origin_domain::ErrorKind;
139
140    #[test]
141    fn a_plaintext_authorization_endpoint_is_rejected() {
142        let error = OAuthConfig::new(
143            "client",
144            "http://provider.example/authorize",
145            "https://provider.example/token",
146        )
147        .unwrap_err();
148
149        assert_eq!(error.kind(), ErrorKind::Configuration);
150        assert!(error.to_string().contains("authorization_endpoint"));
151    }
152
153    #[test]
154    fn a_plaintext_token_endpoint_is_rejected() {
155        let error = OAuthConfig::new(
156            "client",
157            "https://provider.example/authorize",
158            "http://provider.example/token",
159        )
160        .unwrap_err();
161
162        assert_eq!(error.kind(), ErrorKind::Configuration);
163        assert!(error.to_string().contains("token_endpoint"));
164    }
165
166    #[test]
167    fn https_endpoints_are_accepted() {
168        assert!(
169            OAuthConfig::new(
170                "client",
171                "https://provider.example/authorize",
172                "https://provider.example/token"
173            )
174            .is_ok()
175        );
176    }
177
178    #[test]
179    fn insecure_for_testing_skips_the_https_requirement() {
180        let config = OAuthConfig::insecure_for_testing(
181            "client",
182            "http://127.0.0.1:4000/authorize",
183            "http://127.0.0.1:4000/token",
184        );
185
186        assert_eq!(
187            config.authorization_endpoint,
188            "http://127.0.0.1:4000/authorize"
189        );
190    }
191
192    #[test]
193    fn a_reserved_authorization_param_is_rejected() {
194        let error = OAuthConfig::insecure_for_testing(
195            "client",
196            "http://provider.example/authorize",
197            "http://provider.example/token",
198        )
199        .with_authorization_param("state", "attacker-controlled")
200        .unwrap_err();
201
202        assert_eq!(error.kind(), ErrorKind::Configuration);
203        assert!(error.to_string().contains("state"));
204    }
205
206    #[test]
207    fn a_provider_specific_authorization_param_is_accepted() {
208        let config = OAuthConfig::insecure_for_testing(
209            "client",
210            "http://provider.example/authorize",
211            "http://provider.example/token",
212        )
213        .with_authorization_param("access_type", "offline")
214        .unwrap();
215
216        assert_eq!(
217            config.extra_authorization_params,
218            vec![("access_type".to_owned(), "offline".to_owned())]
219        );
220    }
221}