use crate::scope::Scopes;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ClientAuth {
#[default]
Basic,
Body,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UserMap {
pub id: Vec<String>,
pub name: Vec<String>,
pub email: Vec<String>,
pub avatar: Vec<String>,
pub avatar_template: Option<String>,
}
impl UserMap {
pub fn new() -> UserMap {
UserMap::default()
}
pub fn id<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
self.id = paths.into_iter().map(Into::into).collect();
self
}
pub fn name<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
self.name = paths.into_iter().map(Into::into).collect();
self
}
pub fn email<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
self.email = paths.into_iter().map(Into::into).collect();
self
}
pub fn avatar<I: IntoIterator<Item = S>, S: Into<String>>(mut self, paths: I) -> UserMap {
self.avatar = paths.into_iter().map(Into::into).collect();
self
}
pub fn avatar_from(mut self, template: impl Into<String>) -> UserMap {
self.avatar_template = Some(template.into());
self
}
}
#[derive(Debug, Clone)]
pub struct Provider {
pub name: String,
pub authorize_url: String,
pub token_url: String,
pub userinfo_url: Option<String>,
pub revoke_url: Option<String>,
pub scopes: Scopes,
pub client_auth: ClientAuth,
pub headers: Vec<(String, String)>,
pub authorize_params: Vec<(String, String)>,
pub map: UserMap,
}
impl Provider {
pub fn custom(
name: impl Into<String>,
authorize_url: impl Into<String>,
token_url: impl Into<String>,
) -> Provider {
Provider {
name: name.into(),
authorize_url: authorize_url.into(),
token_url: token_url.into(),
userinfo_url: None,
revoke_url: None,
scopes: Scopes::new(),
client_auth: ClientAuth::Basic,
headers: Vec::new(),
authorize_params: Vec::new(),
map: UserMap::new()
.id(["sub"])
.name(["name", "preferred_username"])
.email(["email"])
.avatar(["picture"]),
}
}
pub fn userinfo(mut self, url: impl Into<String>) -> Provider {
self.userinfo_url = Some(url.into());
self
}
pub fn revoke(mut self, url: impl Into<String>) -> Provider {
self.revoke_url = Some(url.into());
self
}
pub fn scopes(mut self, scopes: impl Into<Scopes>) -> Provider {
self.scopes = scopes.into();
self
}
pub fn client_auth(mut self, how: ClientAuth) -> Provider {
self.client_auth = how;
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Provider {
self.headers.push((name.into(), value.into()));
self
}
pub fn without_headers(mut self) -> Provider {
self.headers.clear();
self
}
pub fn authorize_param(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Provider {
self.authorize_params.push((name.into(), value.into()));
self
}
pub fn map(mut self, map: UserMap) -> Provider {
self.map = map;
self
}
pub fn google() -> Provider {
Provider::custom(
"google",
"https://accounts.google.com/o/oauth2/v2/auth",
"https://oauth2.googleapis.com/token",
)
.userinfo("https://openidconnect.googleapis.com/v1/userinfo")
.revoke("https://oauth2.googleapis.com/revoke")
.scopes("openid email profile")
.client_auth(ClientAuth::Basic)
}
pub fn github() -> Provider {
Provider::custom(
"github",
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
)
.userinfo("https://api.github.com/user")
.scopes("read:user user:email")
.client_auth(ClientAuth::Body)
.header("accept", "application/json")
.map(
UserMap::new()
.id(["id"])
.name(["name", "login"])
.email(["email"])
.avatar(["avatar_url"]),
)
}
pub fn gitlab() -> Provider {
Provider::custom(
"gitlab",
"https://gitlab.com/oauth/authorize",
"https://gitlab.com/oauth/token",
)
.userinfo("https://gitlab.com/api/v4/user")
.revoke("https://gitlab.com/oauth/revoke")
.scopes("read_user")
.client_auth(ClientAuth::Body)
.map(
UserMap::new()
.id(["id"])
.name(["name", "username"])
.email(["email"])
.avatar(["avatar_url"]),
)
}
pub fn microsoft() -> Provider {
Provider::custom(
"microsoft",
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
)
.userinfo("https://graph.microsoft.com/v1.0/me")
.scopes("openid email profile User.Read")
.client_auth(ClientAuth::Body)
.map(
UserMap::new()
.id(["id"])
.name(["displayName"])
.email(["mail", "userPrincipalName"])
.avatar(Vec::<String>::new()),
)
}
pub fn discord() -> Provider {
Provider::custom(
"discord",
"https://discord.com/oauth2/authorize",
"https://discord.com/api/oauth2/token",
)
.userinfo("https://discord.com/api/users/@me")
.revoke("https://discord.com/api/oauth2/token/revoke")
.scopes("identify email")
.client_auth(ClientAuth::Basic)
.map(
UserMap::new()
.id(["id"])
.name(["global_name", "username"])
.email(["email"])
.avatar(["avatar"])
.avatar_from("https://cdn.discordapp.com/avatars/{id}/{avatar}.png"),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_preset_has_the_two_endpoints_the_flow_cannot_run_without() {
for provider in [
Provider::google(),
Provider::github(),
Provider::gitlab(),
Provider::microsoft(),
Provider::discord(),
] {
assert!(provider.authorize_url.starts_with("https://"), "{}", provider.name);
assert!(provider.token_url.starts_with("https://"), "{}", provider.name);
assert!(!provider.scopes.is_empty(), "{} asks for nothing", provider.name);
}
}
#[test]
fn github_asks_for_json_because_it_would_otherwise_send_a_form() {
assert_eq!(
Provider::github().headers,
vec![("accept".to_string(), "application/json".to_string())]
);
assert!(Provider::google().headers.is_empty());
}
#[test]
fn providers_disagree_about_where_the_credentials_go() {
assert_eq!(Provider::google().client_auth, ClientAuth::Basic);
assert_eq!(Provider::github().client_auth, ClientAuth::Body);
assert_eq!(Provider::microsoft().client_auth, ClientAuth::Body);
}
#[test]
fn a_custom_provider_defaults_to_the_openid_connect_field_names() {
let provider = Provider::custom("keycloak", "https://id.test/auth", "https://id.test/token");
assert_eq!(provider.map.id, vec!["sub".to_string()]);
assert_eq!(provider.userinfo_url, None);
assert!(provider.scopes.is_empty(), "a custom provider asks for nothing by default");
}
#[test]
fn only_discord_needs_its_avatar_assembled() {
assert!(Provider::discord().map.avatar_template.is_some());
assert!(Provider::github().map.avatar_template.is_none());
assert!(Provider::microsoft().map.avatar.is_empty(), "Graph has no photo URL");
}
}