alibi_plugins/oauth/providers/
discord.rs1use super::{OAuthAuthorizationPolicy, OAuthProvider, OAuthScopeOrder, OAuthUserInfo, Value};
2use serde_json::Map;
3impl OAuthProvider {
4 #[must_use]
5 pub fn discord(client_id: &str, client_secret: &str) -> Self {
6 Self {
7 client_id: client_id.to_owned(),
8 additional_client_ids: Vec::new(),
9 hosted_domain: None,
10 require_email_verification: false,
11 client_secret: client_secret.to_owned(),
12 auth_url: "https://discord.com/api/oauth2/authorize".to_owned(),
13 token_url: "https://discord.com/api/oauth2/token".to_owned(),
14 user_info_url: Some("https://discord.com/api/users/@me".to_owned()),
15 scopes: vec!["identify".to_owned(), "email".to_owned()],
16 authorization: Some(OAuthAuthorizationPolicy {
17 scope_order: OAuthScopeOrder::RequestedThenConfigured,
18 pkce: false,
19 default_prompt: Some("none".into()),
20 ..OAuthAuthorizationPolicy::default()
21 }),
22 authorization_params: Vec::new(),
23 account_subject: None,
24 map_user_info: Some(discord_user_info),
25 get_user_info: None,
26 refresh_access_token: None,
27 verify_id_token: None,
28 id_token: None,
29 disable_id_token_sign_in: false,
30 disable_implicit_sign_up: false,
31 disable_sign_up: false,
32 allow_idp_initiated: false,
33 override_user_info_on_sign_in: false,
34 }
35 }
36}
37
38#[expect(
40 clippy::needless_pass_by_value,
41 reason = "Match the public provider callback type, which owns its JSON profile"
42)]
43pub(in crate::oauth::providers) fn discord_user_info(
44 profile: Value,
45) -> Result<OAuthUserInfo, String> {
46 let id = profile
47 .get("id")
48 .and_then(Value::as_str)
49 .ok_or("missing id")?;
50 let email = profile
51 .get("email")
52 .and_then(Value::as_str)
53 .ok_or("missing email")?;
54 let image = if profile.get("avatar").is_some_and(Value::is_null) {
55 let discriminator = profile
56 .get("discriminator")
57 .and_then(Value::as_str)
58 .ok_or("missing discriminator")?;
59 let index = if discriminator == "0" {
60 if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_digit()) {
63 return Err("invalid Discord decimal snowflake".to_owned());
64 }
65 let snowflake =
66 rsa::BigUint::parse_bytes(id.as_bytes(), 10).ok_or("invalid Discord snowflake")?;
67 let shifted = (snowflake >> 22usize)
68 .to_str_radix(10)
69 .parse::<f64>()
70 .map_err(|_error| "invalid Discord snowflake number")?;
71 shifted % 6.0
72 } else {
73 discriminator
75 .parse::<f64>()
76 .map_err(|_error| "invalid Discord discriminator")?
77 % 5.0
78 };
79 format!("https://cdn.discordapp.com/embed/avatars/{index}.png")
80 } else {
81 let avatar = profile
82 .get("avatar")
83 .and_then(Value::as_str)
84 .ok_or("missing avatar")?;
85 let format = if avatar.starts_with("a_") {
86 "gif"
87 } else {
88 "png"
89 };
90 format!("https://cdn.discordapp.com/avatars/{id}/{avatar}.{format}")
91 };
92 Ok(OAuthUserInfo {
93 additional_fields: Map::default(),
94 id: id.to_owned(),
95 email: email.to_owned(),
96 name: Some(
97 profile
98 .get("global_name")
99 .and_then(Value::as_str)
100 .filter(|name| !name.is_empty())
101 .or_else(|| profile.get("username").and_then(Value::as_str))
102 .unwrap_or_default()
103 .to_owned(),
104 ),
105 image: Some(image),
106 email_verified: profile
107 .get("verified")
108 .and_then(Value::as_bool)
109 .unwrap_or(false),
110 })
111}