Aller au contenu

OAuth2 et rôles liés

Bostie brandissant une clé étincelante

OAuth2 permet à un utilisateur d'accorder à votre application l'accès à son compte : son profil, ses serveurs, ses comptes connectés, le droit de l'ajouter à un serveur, ou une connexion de rôle pour les rôles liés. bostie construit les URL d'autorisation et appelle l'API avec le token que l'utilisateur accorde.

import base64
import hashlib
import os
import secrets

import bostie
from bostie import (
    AccessToken,
    ApplicationRoleConnectionMetadata,
    ApplicationRoleConnectionMetadataType,
    OAuth2Scope,
    oauth2,
)

CLIENT_ID = int(os.environ.get("CLIENT_ID", "0"))
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "")
REDIRECT_URI = "https://example.com/callback"


def login_url() -> tuple[str, str, str]:
    state = secrets.token_urlsafe(16)
    verifier = secrets.token_urlsafe(48)
    digest = hashlib.sha256(verifier.encode()).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
    url = oauth2.authorize_url(
        CLIENT_ID,
        scopes=[
            OAuth2Scope.IDENTIFY,
            OAuth2Scope.GUILDS,
            OAuth2Scope.ROLE_CONNECTIONS_WRITE,
        ],
        redirect_uri=REDIRECT_URI,
        state=state,
        code_challenge=challenge,
    )
    return url, state, verifier


async def finish_login(code: str, verifier: str) -> AccessToken:
    async with bostie.BearerClient() as anonymous:
        return await anonymous.rest.oauth2.exchange_code(
            client_id=CLIENT_ID,
            client_secret=CLIENT_SECRET,
            code=code,
            redirect_uri=REDIRECT_URI,
            code_verifier=verifier,
        )


async def describe_user(token: AccessToken) -> str:
    async with bostie.BearerClient(token.access_token) as user:
        me = await user.rest.users.fetch_current()
        guilds = [guild.name async for guild in user.rest.guilds.list()]
    return f"{me.username} is in {len(guilds)} guilds"


async def refresh(token: AccessToken) -> AccessToken:
    if token.refresh_token is None:
        return token
    async with bostie.BearerClient() as anonymous:
        return await anonymous.rest.oauth2.refresh(
            client_id=CLIENT_ID,
            client_secret=CLIENT_SECRET,
            refresh_token=token.refresh_token,
        )


async def register_metadata() -> None:
    async with bostie.RESTClient() as client:
        await client.rest.application.update_role_connection_metadata(
            CLIENT_ID,
            [
                ApplicationRoleConnectionMetadata(
                    type=ApplicationRoleConnectionMetadataType.INTEGER_GREATER_THAN_OR_EQUAL,
                    key="level",
                    name="Level",
                    description="The player's level in the game",
                )
            ],
        )

URL d'autorisation

oauth2.authorize_url(client_id, scopes=[...], redirect_uri=..., state=...) renvoie l'URL qui demande à l'utilisateur d'accorder les scopes et le redirige vers redirect_uri avec un code. Les scopes sont des membres d'OAuth2Scope, comme OAuth2Scope.IDENTIFY pour identify, un pour chaque scope que Discord documente ; une simple chaîne fonctionne pour un scope que Discord ajoute avant que bostie ne le nomme. Vérifiez que la redirection porte le state que vous avez envoyé, pour qu'un autre site ne puisse pas faire connecter l'utilisateur avec son propre code. La fonction accepte aussi :

  • code_challenge, le challenge S256 d'un verifier PKCE, que exchange_code envoie ensuite comme code_verifier ; l'extrait en calcule un ;
  • prompt="none" pour sauter l'écran de consentement pour un utilisateur qui a déjà accordé les scopes, ou "consent" pour l'afficher de nouveau ;
  • integration_type pour installer l'application sur un serveur ou pour l'utilisateur.

bostie ne construit que l'autorisation par code (authorization code grant). Discord déconseille l'autorisation implicite (implicit grant), qui place le token dans l'URL de redirection et ne fournit pas de token de rafraîchissement, et recommande à la place l'autorisation par code avec PKCE.

oauth2.invite_url(client_id, permissions=..., scopes=[...]) renvoie le lien d'installation de l'application, avec integration_type, guild_id pour présélectionner un serveur, et disable_guild_select. Tout ce qu'elle omet provient des paramètres d'installation de l'application dans le Developer Portal.

Tokens

BearerClient() sans token n'atteint que rest.oauth2 :

Méthode Effet
exchange_code(client_id=, client_secret=, code=, redirect_uri=, code_verifier=None) échange le code de la redirection contre un AccessToken
refresh(client_id=, client_secret=, refresh_token=) un nouveau token avant que l'ancien n'expire
revoke(client_id=, client_secret=, token=) met fin à l'autorisation d'un token et de tous les tokens qu'il a émis
exchange_client_credentials(client_id=, client_secret=, scopes=[...]) un token pour le propriétaire de l'application, ou son équipe

Un AccessToken a access_token, token_type, expires_in en secondes, scope (les scopes accordés, séparés par des espaces) et refresh_token. Une autorisation par code qui incluait le scope bot renvoie aussi le guild que le bot a rejoint, et une autorisation avec webhook.incoming le webhook qu'elle a créé. Le repr d'un token masque les secrets : le journaliser ne les divulgue donc pas.

Appeler l'API au nom de l'utilisateur

async with BearerClient(access_token) as user: atteint ce que permettent les scopes du token :

user.rest. Méthodes Scope
users fetch_current identify
users list_connections connections
users fetch_role_connection, update_role_connection, delete_role_connection role_connections.write
guilds list guilds
members fetch_current(guild_id) guilds.members.read
commands fetch_permissions, list_permissions, edit_permissions applications.commands.permissions.update pour modifier
oauth2 fetch_authorization, qui indique ce que le token accorde et à qui n'importe lequel
lobbies le côté utilisateur des lobbies du Social SDK sdk.social_layer

Avec le scope guilds.join, le bot peut ajouter l'utilisateur à un serveur dont il fait partie : await bot.rest.members.add(guild_id, user_id, access_token=token.access_token), qui renvoie le nouveau Member, ou None si l'utilisateur était déjà membre.

Rôles liés

Un rôle lié est un rôle de serveur que les utilisateurs obtiennent en remplissant des conditions sur une connexion à votre application, comme « niveau 10 au moins ». L'application déclare une fois les valeurs qu'elle rapporte, puis rapporte les valeurs de chaque utilisateur avec son token :

  1. Enregistrez jusqu'à 5 entrées de métadonnées avec bot.rest.application.update_role_connection_metadata(application_id, [...]), chacune étant un ApplicationRoleConnectionMetadata avec un type, une key, un name et une description. register_metadata, dans l'extrait, le fait avec un RESTClient.
  2. Dans le Developer Portal, réglez la Linked Roles Verification URL de l'application sur une page qui lance le flux OAuth2 avec le scope role_connections.write.
  3. Après l'échange, appelez user.rest.users.update_role_connection(application_id, platform_name=..., platform_username=..., metadata={"level": "12"}). Les valeurs de métadonnées sont des chaînes, comme l'API de Discord les accepte.

L'exemple de rôles liés est une page de vérification complète. fmt.guild_navigation("linked-roles", role_id) crée un lien vers le rôle lié d'un serveur dans le chat.