From d08e22e7192b0802b72985d02cdb8e9f8aedb6a3 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 10 Apr 2023 10:14:31 -0400 Subject: [PATCH] Implement oauth2 authorization --- discord/__init__.py | 1 + discord/channel.py | 32 ++++-- discord/client.py | 164 +++++++++++++++++++++++++++++ discord/guild.py | 41 ++++++-- discord/http.py | 188 +++++++++++++++++++++++---------- discord/oauth2.py | 228 ++++++++++++++++++++++++++++++++++++++++ discord/types/oauth2.py | 68 ++++++++++++ docs/api.rst | 27 +++++ 8 files changed, 680 insertions(+), 69 deletions(-) create mode 100644 discord/oauth2.py create mode 100644 discord/types/oauth2.py diff --git a/discord/__init__.py b/discord/__init__.py index f1df5a328..426e7c338 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -54,6 +54,7 @@ from .message import * from .metadata import * from .modal import * from .object import * +from .oauth2 import * from .partial_emoji import * from .payments import * from .permissions import * diff --git a/discord/channel.py b/discord/channel.py index 9fddb5e92..34033edd5 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -103,7 +103,7 @@ if TYPE_CHECKING: ForumChannel as ForumChannelPayload, ForumTag as ForumTagPayload, ) - + from .types.oauth2 import WebhookChannel as WebhookChannelPayload from .types.snowflake import SnowflakeList OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object]) @@ -3602,8 +3602,6 @@ class PartialMessageable(discord.abc.Messageable, Hashable): Note that this class is trimmed down and has no rich attributes. - .. versionadded:: 2.0 - .. container:: operations .. describe:: x == y @@ -3618,21 +3616,34 @@ class PartialMessageable(discord.abc.Messageable, Hashable): Returns the partial messageable's hash. + .. versionadded:: 2.0 + Attributes ----------- id: :class:`int` The channel ID associated with this partial messageable. - guild_id: Optional[:class:`int`] - The guild ID associated with this partial messageable. type: Optional[:class:`ChannelType`] The channel type associated with this partial messageable, if given. + name: Optional[:class:`str`] + The channel name associated with this partial messageable, if given. + guild_id: Optional[:class:`int`] + The guild ID associated with this partial messageable. """ - def __init__(self, state: ConnectionState, id: int, guild_id: Optional[int] = None, type: Optional[ChannelType] = None): + def __init__( + self, + *, + state: ConnectionState, + id: int, + guild_id: Optional[int] = None, + type: Optional[ChannelType] = None, + name: Optional[str] = None, + ): self._state: ConnectionState = state self.id: int = id self.guild_id: Optional[int] = guild_id self.type: Optional[ChannelType] = type + self.name: Optional[str] = name self.last_message_id: Optional[int] = None self.last_pin_timestamp: Optional[datetime.datetime] = None @@ -3642,6 +3653,15 @@ class PartialMessageable(discord.abc.Messageable, Hashable): async def _get_channel(self) -> PartialMessageable: return self + @classmethod + def _from_webhook_channel(cls, guild: Guild, channel: WebhookChannelPayload) -> Self: + return cls( + state=guild._state, + id=int(channel['id']), + guild_id=guild.id, + name=channel['name'], + ) + @property def guild(self) -> Optional[Guild]: """Optional[:class:`Guild`]: The guild this partial messageable is in.""" diff --git a/discord/client.py b/discord/client.py index 6de23ee68..6f22f611c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -31,6 +31,7 @@ from typing import ( Any, AsyncIterator, Callable, + Collection, Coroutine, Dict, Generator, @@ -88,6 +89,7 @@ from .library import LibraryApplication from .relationship import FriendSuggestion, Relationship from .settings import UserSettings, LegacyUserSettings, TrackingSettings, EmailSettings from .affinity import * +from .oauth2 import OAuth2Authorization, OAuth2Token if TYPE_CHECKING: from typing_extensions import Self @@ -102,6 +104,7 @@ if TYPE_CHECKING: from .billing import BillingAddress from .enums import PaymentGateway, RequiredActionType from .metadata import MetadataObject + from .permissions import Permissions from .types.snowflake import Snowflake as _Snowflake PrivateChannel = Union[DMChannel, GroupChannel] @@ -3897,6 +3900,167 @@ class Client: data = await state.http.get_library_entries(state.country_code or 'US') return [LibraryApplication(state=state, data=d) for d in data] + async def authorizations(self) -> List[OAuth2Token]: + """|coro| + + Retrieves the OAuth2 applications authorized on your account. + + .. versionadded:: 2.1 + + Raises + ------- + HTTPException + Retrieving the authorized applications failed. + + Returns + ------- + List[:class:`.OAuth2Token`] + The OAuth2 applications authorized on your account. + """ + state = self._connection + data = await state.http.get_oauth2_tokens() + return [OAuth2Token(state=state, data=d) for d in data] + + async def fetch_authorization( + self, + application_id: int, + /, + *, + scopes: Collection[str], + response_type: Optional[str] = None, + redirect_uri: Optional[str] = None, + code_challenge_method: Optional[str] = None, + code_challenge: Optional[str] = None, + state: Optional[str] = None, + ) -> OAuth2Authorization: + """|coro| + + Retrieves an OAuth2 authorization for the given application. + This provides information about the application before you authorize it. + + .. versionadded:: 2.1 + + Parameters + ----------- + application_id: :class:`int` + The ID of the application to fetch the authorization for. + scopes: List[:class:`str`] + The scopes to request for the authorization. + response_type: Optional[:class:`str`] + The response type that will be used for the authorization, if using the full OAuth2 flow. + redirect_uri: Optional[:class:`str`] + The redirect URI that will be used for the authorization, if using the full OAuth2 flow. + If this isn't provided and ``response_type`` is provided, then the default redirect URI + for the application will be provided in the returned authorization. + code_challenge_method: Optional[:class:`str`] + The code challenge method that will be used for the PKCE authorization, if using the full OAuth2 flow. + code_challenge: Optional[:class:`str`] + The code challenge that will be used for the PKCE authorization, if using the full OAuth2 flow. + state: Optional[:class:`str`] + The state that will be used for authorization security. + + Raises + ------- + HTTPException + Fetching the authorization failed. + + Returns + ------- + :class:`.OAuth2Authorization` + The authorization for the application. + """ + _state = self._connection + data = await _state.http.get_oauth2_authorization( + application_id, + list(scopes), + response_type, + redirect_uri, + code_challenge_method, + code_challenge, + state, + ) + return OAuth2Authorization( + _state=_state, + data=data, + scopes=list(scopes), + response_type=response_type, + code_challenge_method=code_challenge_method, + code_challenge=code_challenge, + state=state, + ) + + async def create_authorization( + self, + application_id: int, + /, + *, + scopes: Collection[str], + response_type: Optional[str] = None, + redirect_uri: Optional[str] = None, + code_challenge_method: Optional[str] = None, + code_challenge: Optional[str] = None, + state: Optional[str] = None, + guild: Snowflake = MISSING, + channel: Snowflake = MISSING, + permissions: Permissions = MISSING, + ) -> str: + """|coro| + + Creates an OAuth2 authorization for the given application. It is recommended to instead first + fetch the authorization information using :meth:`fetch_authorization` and then call :meth:`.OAuth2Authorization.authorize`. + + .. versionadded:: 2.1 + + Parameters + ----------- + application_id: :class:`int` + The ID of the application to create the authorization for. + scopes: List[:class:`str`] + The scopes to request for the authorization. + response_type: Optional[:class:`str`] + The response type to use for the authorization, if using the full OAuth2 flow. + redirect_uri: Optional[:class:`str`] + The redirect URI to use for the authorization, if using the full OAuth2 flow. + If this isn't provided and ``response_type`` is provided, then the default redirect URI + for the application will be used. + code_challenge_method: Optional[:class:`str`] + The code challenge method to use for the PKCE authorization, if using the full OAuth2 flow. + code_challenge: Optional[:class:`str`] + The code challenge to use for the PKCE authorization, if using the full OAuth2 flow. + state: Optional[:class:`str`] + The state to use for authorization security. + guild: :class:`.Guild` + The guild to authorize for, if authorizing with the ``applications.commands`` or ``bot`` scopes. + channel: Union[:class:`.TextChannel`, :class:`.VoiceChannel`, :class:`.StageChannel`] + The channel to authorize for, if authorizing with the ``webhooks.incoming`` scope. See :meth:`.Guild.webhook_channels`. + permissions: :class:`.Permissions` + The permissions to grant, if authorizing with the ``bot`` scope. + + Raises + ------- + HTTPException + Creating the authorization failed. + + Returns + ------- + :class:`str` + The URL to redirect the user to for authorization. + """ + _state = self._connection + data = await _state.http.authorize_oauth2( + application_id, + list(scopes), + response_type, + redirect_uri, + code_challenge_method, + code_challenge, + state, + guild_id=guild.id if guild else None, + webhook_channel_id=channel.id if channel else None, + permissions=permissions.value if permissions else None, + ) + return data['location'] + async def entitlements( self, *, with_sku: bool = True, with_application: bool = True, entitlement_type: Optional[EntitlementType] = None ) -> List[Entitlement]: diff --git a/discord/guild.py b/discord/guild.py index 33f1b2a1c..2cce537c3 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -132,6 +132,7 @@ if TYPE_CHECKING: from .types.message import MessageSearchAuthorType, MessageSearchHasType from .types.snowflake import SnowflakeList, Snowflake as _Snowflake from .types.widget import EditWidgetSettings + from .types.oauth2 import OAuth2Guild as OAuth2GuildPayload from .message import EmojiInputType, Message VocalGuildChannel = Union[VoiceChannel, StageChannel] @@ -197,15 +198,19 @@ class UserGuild(Hashable): The guild name. features: List[:class:`str`] A list of features that the guild has. The features that a guild can have are - subject to arbitrary change by Discord. + subject to arbitrary change by Discord. Incomplete when retrieved from :attr:`OAuth2Authorization.guilds`. owner: :class:`bool` - Whether the current user is the owner of the guild. + Whether the current user is the owner of the guild. Inaccurate when retrieved from :attr:`OAuth2Authorization.guilds`. + mfa_level: :class:`MFALevel` + The guild's Multi-Factor Authentication requirement level. Only available from :attr:`OAuth2Authorization.guilds`. + + .. versionadded:: 2.1 approximate_member_count: Optional[:class:`int`] - The approximate number of members in the guild. This is ``None`` unless the guild is obtained + The approximate number of members in the guild. Only available using using :meth:`Client.fetch_guilds` with ``with_counts=True``. approximate_presence_count: Optional[:class:`int`] The approximate number of members currently active in the guild. - Offline members are excluded. This is ``None`` unless the guild is obtained using + Offline members are excluded. Only available using :meth:`Client.fetch_guilds` with ``with_counts=True``. """ @@ -215,19 +220,21 @@ class UserGuild(Hashable): '_icon', 'owner', '_permissions', + 'mfa_level', 'features', 'approximate_member_count', 'approximate_presence_count', '_state', ) - def __init__(self, *, state: ConnectionState, data: UserGuildPayload): + def __init__(self, *, state: ConnectionState, data: Union[UserGuildPayload, OAuth2GuildPayload]): self._state: ConnectionState = state self.id: int = int(data['id']) self.name: str = data['name'] self._icon: Optional[str] = data.get('icon') self.owner: bool = data.get('owner', False) self._permissions: int = int(data.get('permissions', 0)) + self.mfa_level: MFALevel = try_enum(MFALevel, data.get('mfa_level', 0)) self.features: List[str] = data.get('features', []) self.approximate_member_count: Optional[int] = data.get('approximate_member_count') self.approximate_presence_count: Optional[int] = data.get('approximate_presence_count') @@ -2282,13 +2289,35 @@ class Guild(Hashable): Returns -------- - List[Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`StageChannel`]] + List[Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`StageChannel`, :class:`PartialMessageable`]] The top 10 most read channels. Falls back to :class:`PartialMessageable` if the channel is not found in cache. """ state = self._state data = await state.http.get_top_guild_channels(self.id) return [self.get_channel(int(c)) or PartialMessageable(id=int(c), state=state, guild_id=self.id) for c in data] # type: ignore + async def webhook_channels(self) -> List[Union[TextChannel, VoiceChannel, StageChannel, PartialMessageable]]: + """|coro| + + Retrieves the channels that the current user can create webhooks in for the guild. + + .. versionadded:: 2.1 + + Raises + ------- + HTTPException + Retrieving the webhook channels failed. + + Returns + -------- + List[Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`StageChannel`, :class:`PartialMessageable`]] + The channels that the current user can create webhooks in. Falls back to :class:`PartialMessageable` if the channel is not found in cache. + Any :class:`PartialMessageable` will have its :attr:`PartialMessageable.name` filled in. + """ + state = self._state + data = await state.http.get_guild_webhook_channels(self.id) + return [self.get_channel(int(c['id'])) or PartialMessageable._from_webhook_channel(self, c) for c in data] # type: ignore + async def fetch_channels(self) -> Sequence[GuildChannel]: """|coro| diff --git a/discord/http.py b/discord/http.py index 5f9079765..e4d83e815 100644 --- a/discord/http.py +++ b/discord/http.py @@ -96,6 +96,7 @@ if TYPE_CHECKING: library, member, message, + oauth2, payments, profile, promotions, @@ -2570,6 +2571,63 @@ class HTTPClient: params=params, ) + def get_auto_moderation_rules(self, guild_id: Snowflake) -> Response[List[automod.AutoModerationRule]]: + return self.request(Route('GET', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id)) + + def get_auto_moderation_rule(self, guild_id: Snowflake, rule_id: Snowflake) -> Response[automod.AutoModerationRule]: + return self.request( + Route('GET', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) + ) + + def create_auto_moderation_rule( + self, guild_id: Snowflake, *, reason: Optional[str], **payload: Any + ) -> Response[automod.AutoModerationRule]: + valid_keys = ( + 'name', + 'event_type', + 'trigger_type', + 'trigger_metadata', + 'actions', + 'enabled', + 'exempt_roles', + 'exempt_channels', + ) + + payload = {k: v for k, v in payload.items() if k in valid_keys and v is not None} + + return self.request( + Route('POST', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id), json=payload, reason=reason + ) + + def edit_auto_moderation_rule( + self, guild_id: Snowflake, rule_id: Snowflake, *, reason: Optional[str], **payload: Any + ) -> Response[automod.AutoModerationRule]: + valid_keys = ( + 'name', + 'event_type', + 'trigger_metadata', + 'actions', + 'enabled', + 'exempt_roles', + 'exempt_channels', + ) + + payload = {k: v for k, v in payload.items() if k in valid_keys and v is not None} + + return self.request( + Route('PATCH', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id), + json=payload, + reason=reason, + ) + + def delete_auto_moderation_rule( + self, guild_id: Snowflake, rule_id: Snowflake, *, reason: Optional[str] + ) -> Response[None]: + return self.request( + Route('DELETE', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id), + reason=reason, + ) + # Relationships def get_relationships(self) -> Response[List[user.Relationship]]: @@ -4022,72 +4080,88 @@ class HTTPClient: def get_premium_usage(self) -> Response[billing.PremiumUsage]: return self.request(Route('GET', '/users/@me/premium-usage')) - def enroll_active_developer( - self, application_id: Snowflake, channel_id: Snowflake - ) -> Response[application.ActiveDeveloperResponse]: - payload = {'application_id': application_id, 'channel_id': channel_id} + # OAuth2 - return self.request(Route('POST', '/developers/active-program'), json=payload, super_properties_to_track=True) + def get_oauth2_tokens(self) -> Response[List[oauth2.OAuth2Token]]: + return self.request(Route('GET', '/oauth2/tokens')) - def unenroll_active_developer(self) -> Response[None]: - return self.request(Route('DELETE', '/developers/active-program'), super_properties_to_track=True) - - def get_auto_moderation_rules(self, guild_id: Snowflake) -> Response[List[automod.AutoModerationRule]]: - return self.request(Route('GET', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id)) + def revoke_oauth2_token(self, token_id: Snowflake) -> Response[None]: + return self.request(Route('DELETE', '/oauth2/tokens/{token_id}', token_id=token_id)) - def get_auto_moderation_rule(self, guild_id: Snowflake, rule_id: Snowflake) -> Response[automod.AutoModerationRule]: - return self.request( - Route('GET', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) - ) + def get_guild_webhook_channels(self, guild_id: Snowflake) -> Response[List[oauth2.WebhookChannel]]: + params = {'guild_id': guild_id} + return self.request(Route('GET', '/oauth2/authorize/webhook-channels'), params=params) - def create_auto_moderation_rule( - self, guild_id: Snowflake, *, reason: Optional[str], **payload: Any - ) -> Response[automod.AutoModerationRule]: - valid_keys = ( - 'name', - 'event_type', - 'trigger_type', - 'trigger_metadata', - 'actions', - 'enabled', - 'exempt_roles', - 'exempt_channels', - ) - - payload = {k: v for k, v in payload.items() if k in valid_keys and v is not None} + def get_oauth2_authorization( + self, + application_id: Snowflake, + scopes: List[str], + response_type: Optional[str] = None, + redirect_uri: Optional[str] = None, + code_challenge_method: Optional[str] = None, + code_challenge: Optional[str] = None, + state: Optional[str] = None, + ) -> Response[oauth2.OAuth2Authorization]: + params = {'client_id': application_id, 'scope': ' '.join(scopes)} + if response_type: + params['response_type'] = response_type + if redirect_uri: + params['redirect_uri'] = redirect_uri + if code_challenge_method: + params['code_challenge_method'] = code_challenge_method + if code_challenge: + params['code_challenge'] = code_challenge + if state: + params['state'] = state + + return self.request(Route('GET', '/oauth2/authorize'), params=params) + + def authorize_oauth2( + self, + application_id: Snowflake, + scopes: List[str], + response_type: Optional[str] = None, + redirect_uri: Optional[str] = None, + code_challenge_method: Optional[str] = None, + code_challenge: Optional[str] = None, + state: Optional[str] = None, + guild_id: Optional[Snowflake] = None, + webhook_channel_id: Optional[Snowflake] = None, + permissions: Optional[Snowflake] = None, + ) -> Response[oauth2.OAuth2Location]: + params = {'client_id': application_id, 'scope': ' '.join(scopes)} + payload: Dict[str, Any] = {'authorize': True} + if response_type: + params['response_type'] = response_type + if redirect_uri: + params['redirect_uri'] = redirect_uri + if code_challenge_method: + params['code_challenge_method'] = code_challenge_method + if code_challenge: + params['code_challenge'] = code_challenge + if state: + params['state'] = state + if guild_id: + payload['guild_id'] = str(guild_id) + payload['permissions'] = '0' + if webhook_channel_id: + payload['webhook_channel_id'] = str(webhook_channel_id) + if permissions: + payload['permissions'] = str(permissions) - return self.request( - Route('POST', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id), json=payload, reason=reason - ) + return self.request(Route('POST', '/oauth2/authorize'), params=params, json=payload) - def edit_auto_moderation_rule( - self, guild_id: Snowflake, rule_id: Snowflake, *, reason: Optional[str], **payload: Any - ) -> Response[automod.AutoModerationRule]: - valid_keys = ( - 'name', - 'event_type', - 'trigger_metadata', - 'actions', - 'enabled', - 'exempt_roles', - 'exempt_channels', - ) + # Active Developer Program - payload = {k: v for k, v in payload.items() if k in valid_keys and v is not None} + def enroll_active_developer( + self, application_id: Snowflake, channel_id: Snowflake + ) -> Response[application.ActiveDeveloperResponse]: + payload = {'application_id': application_id, 'channel_id': channel_id} - return self.request( - Route('PATCH', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id), - json=payload, - reason=reason, - ) + return self.request(Route('POST', '/developers/active-program'), json=payload, super_properties_to_track=True) - def delete_auto_moderation_rule( - self, guild_id: Snowflake, rule_id: Snowflake, *, reason: Optional[str] - ) -> Response[None]: - return self.request( - Route('DELETE', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id), - reason=reason, - ) + def unenroll_active_developer(self) -> Response[None]: + return self.request(Route('DELETE', '/developers/active-program'), super_properties_to_track=True) # Misc diff --git a/discord/oauth2.py b/discord/oauth2.py new file mode 100644 index 000000000..4a4dddb49 --- /dev/null +++ b/discord/oauth2.py @@ -0,0 +1,228 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional + +from .application import PartialApplication +from .guild import UserGuild +from .mixins import Hashable +from .utils import MISSING + +if TYPE_CHECKING: + from .abc import Snowflake + from .permissions import Permissions + from .state import ConnectionState + from .user import User + from .types.oauth2 import OAuth2Authorization as OAuth2AuthorizationPayload, OAuth2Token as OAuth2TokenPayload + +__all__ = ( + 'OAuth2Token', + 'OAuth2Authorization', +) + + +class OAuth2Token(Hashable): + """Represents an authorized OAuth2 application for a user. + + .. container:: operations + + .. describe:: x == y + + Checks if two authorizations are equal. + + .. describe:: x != y + + Checks if two authorizations are not equal. + + .. describe:: hash(x) + + Return the authorizations's hash. + + .. describe:: str(x) + + Returns the authorizations's name. + + .. versionadded:: 2.1 + + Attributes + ----------- + id: :class:`int` + The ID of the authorization. + application: :class:`PartialApplication` + The application that the authorization is for. + scopes: List[:class:`str`] + The scopes that the authorization has. + """ + + __slots__ = ('id', 'application', 'scopes', '_state') + + def __init__(self, *, state: ConnectionState, data: OAuth2TokenPayload): + self._state = state + self.id: int = int(data['id']) + self.application: PartialApplication = PartialApplication(state=state, data=data['application']) + self.scopes: List[str] = data['scopes'] + + def __repr__(self): + return f'' + + def __str__(self): + return self.application.name + + @property + def authorized(self) -> bool: + """:class:`bool`: Whether the user has already authorized the application. + + This is here for compatibility purposes and is always ``True``. + """ + return True + + async def revoke(self): + """|coro| + + Revokes the application's authorization. + + Raises + ------- + HTTPException + Deauthorizing the application failed. + """ + await self._state.http.revoke_oauth2_token(self.id) + + +class OAuth2Authorization: + """Represents a Discord OAuth2 application authorization. + + .. versionadded:: 2.1 + + Attributes + ----------- + scopes: List[:class:`str`] + The scopes that the authorization has. + response_type: Optional[:class:`str`] + The response type that will be used for the authorization, if using the full OAuth2 flow. + code_challenge_method: Optional[:class:`str`] + The code challenge method that will be used for the PKCE authorization, if using the full OAuth2 flow. + code_challenge: Optional[:class:`str`] + The code challenge that will be used for the PKCE authorization, if using the full OAuth2 flow. + state: Optional[:class:`str`] + The state that will be used for authorization security. + authorized: :class:`bool` + Whether the user has already authorized the application. + application: :class:`PartialApplication` + The application that the authorization is for. + bot: Optional[:class:`User`] + The bot user associated with the application, provided if authorizing with the ``bot`` scope. + approximate_guild_count: Optional[:class:`int`] + The approximate number of guilds the bot is in, provided if authorizing with the ``bot`` scope. + guilds: List[:class:`UserGuild`] + The guilds the current user is in, provided if authorizing with the ``bot`` scope. + redirect_uri: Optional[:class:`str`] + The redirect URI that will be used for the authorization, if using the full OAuth2 flow and a redirect URI exists. + """ + + __slots__ = ( + 'authorized', + 'application', + 'bot', + 'approximate_guild_count', + 'guilds', + 'redirect_uri', + 'scopes', + 'response_type', + 'code_challenge_method', + 'code_challenge', + 'state', + '_state', + ) + + def __init__( + self, + *, + _state: ConnectionState, + data: OAuth2AuthorizationPayload, + scopes: List[str], + response_type: Optional[str], + code_challenge_method: Optional[str] = None, + code_challenge: Optional[str] = None, + state: Optional[str], + ): + self._state = _state + self.scopes: List[str] = scopes + self.response_type: Optional[str] = response_type + self.code_challenge_method: Optional[str] = code_challenge_method + self.code_challenge: Optional[str] = code_challenge + self.state: Optional[str] = state + self.authorized: bool = data['authorized'] + self.application: PartialApplication = PartialApplication(state=_state, data=data['application']) + self.bot: Optional[User] = _state.store_user(data['bot']) if 'bot' in data else None + self.approximate_guild_count: Optional[int] = ( + data['bot'].get('approximate_guild_count', 0) if 'bot' in data else None + ) + self.guilds: List[UserGuild] = [UserGuild(state=_state, data=g) for g in data.get('guilds', [])] + self.redirect_uri: Optional[str] = data.get('redirect_uri') + + def __repr__(self): + return f'' + + async def authorize( + self, *, guild: Snowflake = MISSING, channel: Snowflake = MISSING, permissions: Permissions = MISSING + ) -> str: + """|coro| + + Authorizes the application for the user. A shortcut for :meth:`Client.create_authorization`. + + Parameters + ----------- + guild: :class:`Guild` + The guild to authorize for, if authorizing with the ``applications.commands`` or ``bot`` scopes. + channel: Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`StageChannel`] + The channel to authorize for, if authorizing with the ``webhooks.incoming`` scope. See :meth:`Guild.webhook_channels`. + permissions: :class:`Permissions` + The permissions to grant, if authorizing with the ``bot`` scope. + + Raises + ------- + HTTPException + Authorizing the application failed. + + Returns + -------- + :class:`str` + The URL to redirect the user to. May be an error page. + """ + data = await self._state.http.authorize_oauth2( + self.application.id, + self.scopes, + self.response_type, + self.redirect_uri, + self.code_challenge_method, + self.code_challenge, + self.state, + guild_id=guild.id if guild else None, + webhook_channel_id=channel.id if channel else None, + permissions=permissions.value if permissions else None, + ) + return data['location'] diff --git a/discord/types/oauth2.py b/discord/types/oauth2.py new file mode 100644 index 000000000..439442cc0 --- /dev/null +++ b/discord/types/oauth2.py @@ -0,0 +1,68 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import List, Optional, TypedDict +from typing_extensions import NotRequired + +from .application import PartialApplication +from .snowflake import Snowflake +from .user import PartialUser + + +class OAuth2Token(TypedDict): + id: Snowflake + application: PartialApplication + scopes: List[str] + + +class BotUser(PartialUser): + approximate_guild_count: int + + +class OAuth2Guild(TypedDict): + id: Snowflake + name: str + icon: Optional[str] + permissions: str + mfa_level: int + + +class OAuth2Authorization(TypedDict): + authorized: bool + user: PartialUser + application: PartialApplication + bot: NotRequired[BotUser] + guilds: NotRequired[List[OAuth2Guild]] + redirect_uri: NotRequired[Optional[str]] + + +class OAuth2Location(TypedDict): + location: str + + +class WebhookChannel(TypedDict): + id: Snowflake + name: str diff --git a/docs/api.rst b/docs/api.rst index aae02156e..5b470c1d8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -701,9 +701,23 @@ Notes Called when a :class:`User`\'s note is updated. + .. versionadded:: 2.0 + :param note: The note that was updated. :type note: :class:`Note` +OAuth2 +~~~~~~~ + +.. function:: on_oauth2_token_revoke(token) + + Called when an authorized application is revoked. + + .. versionadded:: 2.0 + + :param token: The token that was revoked. + :type token: :class:`str` + Calls ~~~~~ @@ -6666,6 +6680,19 @@ Library .. autoclass:: LibrarySKU() :members: +OAuth2 +~~~~~~ + +.. attributetable:: OAuth2Token + +.. autoclass:: OAuth2Token() + :members: + +.. attributetable:: OAuth2Authorization + +.. autoclass:: OAuth2Authorization() + :members: + Promotion ~~~~~~~~~