Repository: frankie567/httpx-oauth

Issue:
Provide Discord OAuth2 implementation
I may work on this at some point if I understand the lib well enough.

In any case, I appreciate all the work that is put into httpx-oauth :D

Comments:

Patch:
diff --git a/docs/oauth2.md b/docs/oauth2.md
index dbb7178..35f0ce1 100644
--- a/docs/oauth2.md
+++ b/docs/oauth2.md
@@ -123,6 +123,23 @@ A utility method is provided to quickly determine if the token is still valid or
 
 We provide several ready-to-use clients for widely used services with configured endpoints and specificites took into account.
 
+### Discord
+
+Contributed by [William Hatcher](https://github.com/williamhatcher)
+
+```py
+from httpx_oauth.clients.discord import DiscordOAuth2
+
+client = DiscordOAuth2("CLIENT_ID", "CLIENT_SECRET")
+```
+
+* ✅ `refresh_token`
+* ✅ `revoke_token`
+
+!!! warning "Warning about get_id_email()"
+    Email is optional for Discord accounts. This method will error if the user does not have a verified email address.
+    Furthermore, you should always make sure to include `identify`, and `email` in your scopes. (This is the default)
+
 ### Facebook
 
 ```py
diff --git a/httpx_oauth/clients/discord.py b/httpx_oauth/clients/discord.py
new file mode 100644
index 0000000..fe3473f
--- /dev/null
+++ b/httpx_oauth/clients/discord.py
@@ -0,0 +1,53 @@
+from typing import Any, Dict, List, Optional, Tuple, cast
+
+from httpx_oauth.errors import GetIdEmailError
+from httpx_oauth.oauth2 import BaseOAuth2
+
+AUTHORIZE_ENDPOINT = "https://discord.com/api/oauth2/authorize"
+ACCESS_TOKEN_ENDPOINT = "https://discord.com/api/oauth2/token"
+REVOKE_TOKEN_ENDPOINT = "https://discord.com/api/oauth2/token/revoke"
+BASE_SCOPES = ("identify", "email")
+PROFILE_ENDPOINT = "https://discord.com/api/users/@me"
+
+
+class DiscordOAuth2(BaseOAuth2[Dict[str, Any]]):
+    def __init__(
+        self,
+        client_id: str,
+        client_secret: str,
+        scopes: Optional[List[str]] = BASE_SCOPES,
+        name: str = "discord",
+    ):
+        super().__init__(
+            client_id,
+            client_secret,
+            AUTHORIZE_ENDPOINT,
+            ACCESS_TOKEN_ENDPOINT,
+            ACCESS_TOKEN_ENDPOINT,
+            REVOKE_TOKEN_ENDPOINT,
+            name=name,
+            base_scopes=scopes,
+        )
+
+    async def get_id_email(self, token: str) -> Tuple[str, str]:
+        async with self.get_httpx_client() as client:
+            response = await client.get(
+                PROFILE_ENDPOINT,
+                headers={**self.request_headers, "Authorization": f"Bearer {token}"},
+            )
+
+            if response.status_code >= 400:
+                raise GetIdEmailError(response.json())
+
+            data = cast(Dict[str, Any], response.json())
+
+            user_id = data["id"]
+
+            if 'verified' not in data or 'email' not in data:  # No email on discord account
+                raise GetIdEmailError({'error': 'Email not provided'})
+            elif not data['verified']:  # Email present, but not verified
+                raise GetIdEmailError({'error': 'Email not verified'})
+            else:
+                user_email = data["email"]
+
+            return user_id, user_email


Test patch:
diff --git a/tests/test_clients_discord.py b/tests/test_clients_discord.py
new file mode 100644
index 0000000..87c432e
--- /dev/null
+++ b/tests/test_clients_discord.py
@@ -0,0 +1,116 @@
+import re
+
+import pytest
+import respx
+from httpx import Response
+
+from httpx_oauth.clients.discord import DiscordOAuth2, PROFILE_ENDPOINT
+from httpx_oauth.errors import GetIdEmailError
+
+client = DiscordOAuth2("CLIENT_ID", "CLIENT_SECRET")
+
+
+def test_discord_oauth2():
+    assert client.authorize_endpoint == "https://discord.com/api/oauth2/authorize"
+    assert client.access_token_endpoint == "https://discord.com/api/oauth2/token"
+    assert client.refresh_token_endpoint == "https://discord.com/api/oauth2/token"
+    assert client.revoke_token_endpoint == "https://discord.com/api/oauth2/token/revoke"
+    assert client.base_scopes == ("identify", "email")
+    assert client.name == "discord"
+
+
+profile_verified_email_response = {
+    "id": "80351110224678912",
+    "username": "Nelly",
+    "discriminator": "1337",
+    "avatar": "8342729096ea3675442027381ff50dfe",
+    "verified": True,
+    "email": "nelly@discord.com",
+    "flags": 64,
+    "banner": "06c16474723fe537c283b8efa61a30c8",
+    "accent_color": 16711680,
+    "premium_type": 1,
+    "public_flags": 64
+}
+
+profile_no_email_response = {
+    "id": "80351110224678912",
+    "username": "Nelly",
+    "discriminator": "1337",
+    "avatar": "8342729096ea3675442027381ff50dfe",
+    "flags": 64,
+    "banner": "06c16474723fe537c283b8efa61a30c8",
+    "accent_color": 16711680,
+    "premium_type": 1,
+    "public_flags": 64
+}
+
+profile_not_verified_email_response = {
+    "id": "80351110224678912",
+    "username": "Nelly",
+    "discriminator": "1337",
+    "avatar": "8342729096ea3675442027381ff50dfe",
+    "verified": False,
+    "email": "nelly@discord.com",
+    "flags": 64,
+    "banner": "06c16474723fe537c283b8efa61a30c8",
+    "accent_color": 16711680,
+    "premium_type": 1,
+    "public_flags": 64
+}
+
+
+class TestDiscordGetIdEmail:
+    @pytest.mark.asyncio
+    @respx.mock
+    async def test_success(self, get_respx_call_args):
+        request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock(
+            return_value=Response(200, json=profile_verified_email_response)
+        )
+
+        user_id, user_email = await client.get_id_email("TOKEN")
+        _, headers, _ = await get_respx_call_args(request)
+
+        assert headers["Authorization"] == "Bearer TOKEN"
+        assert headers["Accept"] == "application/json"
+        assert user_id == "80351110224678912"
+        assert user_email == "nelly@discord.com"
+
+    @pytest.mark.asyncio
+    @respx.mock
+    async def test_error(self):
+        respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock(
+            return_value=Response(400, json={"error": "message"})
+        )
+
+        with pytest.raises(GetIdEmailError) as excinfo:
+            await client.get_id_email("TOKEN")
+
+        assert type(excinfo.value.args[0]) == dict
+        assert excinfo.value.args[0] == {"error": "message"}
+
+    @pytest.mark.asyncio
+    @respx.mock
+    async def test_no_email_error(self):
+        respx.get(re.compile(f"^{PROFILE_ENDPOINT}$")).mock(
+            return_value=Response(200, json=profile_no_email_response)
+        )
+
+        with pytest.raises(GetIdEmailError) as excinfo:
+            await client.get_id_email("TOKEN")
+
+        assert type(excinfo.value.args[0]) == dict
+        assert excinfo.value.args[0] == {"error": "Email not provided"}
+
+    @pytest.mark.asyncio
+    @respx.mock
+    async def test_email_not_verified_error(self):
+        respx.get(re.compile(f"^{PROFILE_ENDPOINT}$")).mock(
+            return_value=Response(200, json=profile_not_verified_email_response)
+        )
+
+        with pytest.raises(GetIdEmailError) as excinfo:
+            await client.get_id_email("TOKEN")
+
+        assert type(excinfo.value.args[0]) == dict
+        assert excinfo.value.args[0] == {"error": "Email not verified"}