1use crate::scope::Scopes;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum ClientAuth {
20 #[default]
24 Basic,
25 Body,
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct UserMap {
36 pub id: Vec<String>,
37 pub name: Vec<String>,
38 pub email: Vec<String>,
39 pub avatar: Vec<String>,
40 pub avatar_template: Option<String>,
45}
46
47impl UserMap {
48 pub fn new() -> UserMap {
49 UserMap::default()
50 }
51
52 pub fn id<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
53 self.id = paths.into_iter().map(Into::into).collect();
54 self
55 }
56
57 pub fn name<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
58 self.name = paths.into_iter().map(Into::into).collect();
59 self
60 }
61
62 pub fn email<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
63 self.email = paths.into_iter().map(Into::into).collect();
64 self
65 }
66
67 pub fn avatar<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
68 self.avatar = paths.into_iter().map(Into::into).collect();
69 self
70 }
71
72 pub fn avatar_from(mut self, template: impl Into<String>) -> UserMap {
73 self.avatar_template = Some(template.into());
74 self
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct Provider {
81 pub name: String,
84 pub authorize_url: String,
85 pub token_url: String,
86 pub userinfo_url: Option<String>,
87 pub revoke_url: Option<String>,
89 pub scopes: Scopes,
91 pub client_auth: ClientAuth,
92 pub headers: Vec<(String, String)>,
97 pub authorize_params: Vec<(String, String)>,
99 pub map: UserMap,
100}
101
102impl Provider {
103 pub fn custom(
106 name: impl Into<String>,
107 authorize_url: impl Into<String>,
108 token_url: impl Into<String>,
109 ) -> Provider {
110 Provider {
111 name: name.into(),
112 authorize_url: authorize_url.into(),
113 token_url: token_url.into(),
114 userinfo_url: None,
115 revoke_url: None,
116 scopes: Scopes::new(),
117 client_auth: ClientAuth::Basic,
118 headers: Vec::new(),
119 authorize_params: Vec::new(),
120 map: UserMap::new()
123 .id(["sub"])
124 .name(["name", "preferred_username"])
125 .email(["email"])
126 .avatar(["picture"]),
127 }
128 }
129
130 pub fn userinfo(mut self, url: impl Into<String>) -> Provider {
131 self.userinfo_url = Some(url.into());
132 self
133 }
134
135 pub fn revoke(mut self, url: impl Into<String>) -> Provider {
136 self.revoke_url = Some(url.into());
137 self
138 }
139
140 pub fn scopes(mut self, scopes: impl Into<Scopes>) -> Provider {
141 self.scopes = scopes.into();
142 self
143 }
144
145 pub fn client_auth(mut self, how: ClientAuth) -> Provider {
146 self.client_auth = how;
147 self
148 }
149
150 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Provider {
151 self.headers.push((name.into(), value.into()));
152 self
153 }
154
155 pub fn without_headers(mut self) -> Provider {
158 self.headers.clear();
159 self
160 }
161
162 pub fn authorize_param(
163 mut self,
164 name: impl Into<String>,
165 value: impl Into<String>,
166 ) -> Provider {
167 self.authorize_params.push((name.into(), value.into()));
168 self
169 }
170
171 pub fn map(mut self, map: UserMap) -> Provider {
172 self.map = map;
173 self
174 }
175
176 pub fn google() -> Provider {
182 Provider::custom(
183 "google",
184 "https://accounts.google.com/o/oauth2/v2/auth",
185 "https://oauth2.googleapis.com/token",
186 )
187 .userinfo("https://openidconnect.googleapis.com/v1/userinfo")
188 .revoke("https://oauth2.googleapis.com/revoke")
189 .scopes("openid email profile")
190 .client_auth(ClientAuth::Basic)
191 }
192
193 pub fn github() -> Provider {
201 Provider::custom(
202 "github",
203 "https://github.com/login/oauth/authorize",
204 "https://github.com/login/oauth/access_token",
205 )
206 .userinfo("https://api.github.com/user")
207 .scopes("read:user user:email")
208 .client_auth(ClientAuth::Body)
209 .header("accept", "application/json")
210 .map(
211 UserMap::new()
212 .id(["id"])
213 .name(["name", "login"])
215 .email(["email"])
216 .avatar(["avatar_url"]),
217 )
218 }
219
220 pub fn gitlab() -> Provider {
222 Provider::custom(
223 "gitlab",
224 "https://gitlab.com/oauth/authorize",
225 "https://gitlab.com/oauth/token",
226 )
227 .userinfo("https://gitlab.com/api/v4/user")
228 .revoke("https://gitlab.com/oauth/revoke")
229 .scopes("read_user")
230 .client_auth(ClientAuth::Body)
231 .map(
232 UserMap::new()
233 .id(["id"])
234 .name(["name", "username"])
235 .email(["email"])
236 .avatar(["avatar_url"]),
237 )
238 }
239
240 pub fn microsoft() -> Provider {
246 Provider::custom(
247 "microsoft",
248 "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
249 "https://login.microsoftonline.com/common/oauth2/v2.0/token",
250 )
251 .userinfo("https://graph.microsoft.com/v1.0/me")
252 .scopes("openid email profile User.Read")
253 .client_auth(ClientAuth::Body)
254 .map(
255 UserMap::new()
256 .id(["id"])
257 .name(["displayName"])
258 .email(["mail", "userPrincipalName"])
260 .avatar(Vec::<String>::new()),
261 )
262 }
263
264 pub fn discord() -> Provider {
266 Provider::custom(
267 "discord",
268 "https://discord.com/oauth2/authorize",
269 "https://discord.com/api/oauth2/token",
270 )
271 .userinfo("https://discord.com/api/users/@me")
272 .revoke("https://discord.com/api/oauth2/token/revoke")
273 .scopes("identify email")
274 .client_auth(ClientAuth::Basic)
275 .map(
276 UserMap::new()
277 .id(["id"])
278 .name(["global_name", "username"])
279 .email(["email"])
280 .avatar(["avatar"])
281 .avatar_from("https://cdn.discordapp.com/avatars/{id}/{avatar}.png"),
282 )
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn every_preset_has_the_two_endpoints_the_flow_cannot_run_without() {
292 for provider in [
293 Provider::google(),
294 Provider::github(),
295 Provider::gitlab(),
296 Provider::microsoft(),
297 Provider::discord(),
298 ] {
299 assert!(provider.authorize_url.starts_with("https://"), "{}", provider.name);
300 assert!(provider.token_url.starts_with("https://"), "{}", provider.name);
301 assert!(!provider.scopes.is_empty(), "{} asks for nothing", provider.name);
302 }
303 }
304
305 #[test]
306 fn github_asks_for_json_because_it_would_otherwise_send_a_form() {
307 assert_eq!(
308 Provider::github().headers,
309 vec![("accept".to_string(), "application/json".to_string())]
310 );
311 assert!(Provider::google().headers.is_empty());
313 }
314
315 #[test]
316 fn providers_disagree_about_where_the_credentials_go() {
317 assert_eq!(Provider::google().client_auth, ClientAuth::Basic);
320 assert_eq!(Provider::github().client_auth, ClientAuth::Body);
321 assert_eq!(Provider::microsoft().client_auth, ClientAuth::Body);
322 }
323
324 #[test]
325 fn a_custom_provider_defaults_to_the_openid_connect_field_names() {
326 let provider = Provider::custom("keycloak", "https://id.test/auth", "https://id.test/token");
327
328 assert_eq!(provider.map.id, vec!["sub".to_string()]);
329 assert_eq!(provider.userinfo_url, None);
330 assert!(provider.scopes.is_empty(), "a custom provider asks for nothing by default");
331 }
332
333 #[test]
334 fn only_discord_needs_its_avatar_assembled() {
335 assert!(Provider::discord().map.avatar_template.is_some());
336 assert!(Provider::github().map.avatar_template.is_none());
337 assert!(Provider::microsoft().map.avatar.is_empty(), "Graph has no photo URL");
338 }
339}