Skip to main content

rustlavel_oauth/
provider.rs

1//! One provider's endpoints and quirks.
2//!
3//! Every provider in this file implements the same RFC, and every one of them
4//! differs somewhere: where the credentials go, what the userinfo fields are
5//! called, whether the token endpoint answers in JSON at all. A `Provider` is
6//! the place those differences are written down once, so the client above it
7//! stays a single implementation of the spec rather than a pile of `if
8//! provider == "github"`.
9
10use crate::scope::Scopes;
11
12/// How the client proves who it is at the token endpoint, RFC 6749 §2.3.1.
13///
14/// The RFC gives two ways and says a server MUST support the first, but plenty
15/// only implement the second — and a provider handed credentials the way it
16/// does not expect answers `invalid_client`, which reads exactly like a wrong
17/// secret. That is why this is a property of the provider and not a guess.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum ClientAuth {
20    /// HTTP Basic, with the id and secret percent-encoded before they are
21    /// joined and base64'd — §2.3.1 is explicit about the encoding, and a
22    /// secret containing `:` or `+` is silently wrong without it.
23    #[default]
24    Basic,
25    /// `client_id` and `client_secret` as ordinary form fields.
26    Body,
27}
28
29/// Where a provider keeps each field of a user profile.
30///
31/// Candidates are tried in order, so `["name", "login"]` means "the real name
32/// if there is one, otherwise the handle" — which is what GitHub needs, because
33/// `name` is optional there and frequently null.
34#[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    /// How to turn the avatar field into a URL, when it is not one already.
41    ///
42    /// Discord sends an image *hash*; the URL is assembled from it and the
43    /// user id. `{id}` and `{avatar}` are substituted.
44    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/// Everything the client needs to know about one provider.
79#[derive(Debug, Clone)]
80pub struct Provider {
81    /// The name used in `/auth/{provider}/redirect` and stored on a
82    /// [`crate::SocialUser`], so two providers' user ids cannot be confused.
83    pub name: String,
84    pub authorize_url: String,
85    pub token_url: String,
86    pub userinfo_url: Option<String>,
87    /// RFC 7009. Absent for the providers that never implemented it.
88    pub revoke_url: Option<String>,
89    /// What is asked for when the application does not say.
90    pub scopes: Scopes,
91    pub client_auth: ClientAuth,
92    /// Sent with every request this client makes to the provider.
93    ///
94    /// This exists for GitHub, which answers its token endpoint in
95    /// `application/x-www-form-urlencoded` unless asked for JSON.
96    pub headers: Vec<(String, String)>,
97    /// Added to every authorisation URL, before the application's own.
98    pub authorize_params: Vec<(String, String)>,
99    pub map: UserMap,
100}
101
102impl Provider {
103    /// A provider the presets do not cover — a self-hosted GitLab, Keycloak,
104    /// an internal identity server.
105    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            // The OpenID Connect spelling, which is what a standards-compliant
121            // provider will use.
122            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    /// Clear the headers a preset sends. Only useful for showing what a
156    /// provider does without them.
157    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    /// Google, over OpenID Connect.
177    ///
178    /// A refresh token only ever arrives with `access_type=offline` *and*
179    /// `prompt=consent`, and only on the first grant — add them with
180    /// `OAuthClient::with` when the application needs offline access.
181    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    /// GitHub, which is the awkward one.
194    ///
195    /// Its token endpoint answers `application/x-www-form-urlencoded` unless
196    /// asked for JSON, and it reports failures as HTTP 200 with an `error`
197    /// field. The header below fixes the first; the second is handled by
198    /// [`crate::TokenResponse::from_json`], which reads `error` whatever the
199    /// status says. GitHub also authenticates in the body rather than by Basic.
200    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` is optional on GitHub and is null more often than not.
214                .name(["name", "login"])
215                .email(["email"])
216                .avatar(["avatar_url"]),
217        )
218    }
219
220    /// GitLab.com. Point the URLs elsewhere for a self-hosted instance.
221    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    /// Microsoft identity platform, against the `common` tenant.
241    ///
242    /// Graph has no photo *URL* — `/me/photo/$value` returns image bytes — so
243    /// there is no avatar to map, and inventing one would hand every caller a
244    /// link that 404s.
245    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                // A work account often has no `mail`, only the principal name.
259                .email(["mail", "userPrincipalName"])
260                .avatar(Vec::<String>::new()),
261        )
262    }
263
264    /// Discord.
265    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        // And nobody else needs the workaround.
312        assert!(Provider::google().headers.is_empty());
313    }
314
315    #[test]
316    fn providers_disagree_about_where_the_credentials_go() {
317        // The whole reason ClientAuth exists: sending Basic to a body-only
318        // endpoint is an `invalid_client` that looks like a wrong secret.
319        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}