1use origin_domain::{AppError, Result};
2use origin_secrets::Secret;
3
4const 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#[derive(Debug, Clone)]
20pub struct OAuthConfig {
21 pub client_id: String,
22
23 pub client_secret: Option<Secret>,
29
30 pub authorization_endpoint: String,
31 pub token_endpoint: String,
32 pub scopes: Vec<String>,
33
34 pub extra_authorization_params: Vec<(String, String)>,
37}
38
39impl OAuthConfig {
40 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 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 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}