Saltar a contenido

OAuth2 y roles vinculados

Bostie sostiene en alto una llave brillante

OAuth2 permite que un usuario le dé a tu aplicación acceso a su cuenta: su perfil, sus servidores, sus cuentas vinculadas, el derecho a agregarlo a un servidor o una conexión de rol para los roles vinculados. bostie construye las URL de autorización y llama a la API con el token que otorga el usuario.

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 de autorización

oauth2.authorize_url(client_id, scopes=[...], redirect_uri=..., state=...) devuelve la URL que le pide al usuario que otorgue los scopes y lo redirige de vuelta a redirect_uri con un code. Los scopes son miembros de OAuth2Scope, como OAuth2Scope.IDENTIFY para identify, uno por cada scope que documenta Discord; una cadena simple sirve para un scope que Discord agregue antes de que bostie lo nombre. Comprueba que la redirección trae el state que enviaste, para que otro sitio no pueda hacer que el usuario inicie sesión con su código. También acepta:

  • code_challenge, el desafío S256 de un verificador PKCE, que luego exchange_code envía como code_verifier; el snippet calcula uno;
  • prompt="none" para omitir la pantalla de consentimiento si el usuario ya otorgó los scopes, o "consent" para volver a mostrarla;
  • integration_type para instalar la aplicación en un servidor o en el usuario.

bostie solo construye la concesión con código de autorización. Discord desaconseja la concesión implícita, que pone el token en la URL de redirección y no da refresh token, y recomienda en su lugar la concesión con código y PKCE.

oauth2.invite_url(client_id, permissions=..., scopes=[...]) devuelve el enlace de instalación de la aplicación, con integration_type, guild_id para preseleccionar un servidor y disable_guild_select. Lo que no indique sale de los ajustes de instalación de la aplicación en el Developer Portal.

Tokens

BearerClient() sin token solo llega a rest.oauth2:

Método Qué hace
exchange_code(client_id=, client_secret=, code=, redirect_uri=, code_verifier=None) convierte el código de la redirección en un AccessToken
refresh(client_id=, client_secret=, refresh_token=) un token nuevo antes de que venza el anterior
revoke(client_id=, client_secret=, token=) termina la autorización de un token y de todos los tokens que emitió
exchange_client_credentials(client_id=, client_secret=, scopes=[...]) un token para el propietario de la aplicación, o para su equipo

Un AccessToken tiene access_token, token_type, expires_in en segundos, scope (los scopes otorgados, separados por espacios) y refresh_token. Una concesión con código que incluyó el scope bot también devuelve el guild al que se unió el bot, y una con webhook.incoming, el webhook que creó. El repr de un token oculta los secretos, así que registrarlo en el log no los filtra.

Llamar a la API como el usuario

async with BearerClient(access_token) as user: llega a lo que permiten los scopes del token:

user.rest. Métodos 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 para editar
oauth2 fetch_authorization, que dice qué otorga el token y a quién cualquiera
lobbies el lado del usuario de los lobbies del Social SDK sdk.social_layer

Con el scope guilds.join, el bot puede agregar al usuario a un servidor en el que está: await bot.rest.members.add(guild_id, user_id, access_token=token.access_token), que devuelve el nuevo Member, o None si el usuario ya era miembro.

Roles vinculados

Un rol vinculado es un rol del servidor que los usuarios obtienen al cumplir condiciones sobre una conexión con tu aplicación, como "nivel 10 o más". La aplicación declara una vez los valores que informa y luego informa los valores de cada usuario con su token:

  1. Registra hasta 5 registros de metadatos con bot.rest.application.update_role_connection_metadata(application_id, [...]), cada uno un ApplicationRoleConnectionMetadata con un type, key, name y description. register_metadata, en el snippet, lo hace con un RESTClient.
  2. Configura la Linked Roles Verification URL de la aplicación en el Developer Portal para que apunte a una página que inicie el flujo de OAuth2 con el scope role_connections.write.
  3. Después del intercambio, llama a user.rest.users.update_role_connection(application_id, platform_name=..., platform_username=..., metadata={"level": "12"}). Los valores de los metadatos son cadenas, tal como los acepta la API de Discord.

El ejemplo de roles vinculados es una página de verificación completa. fmt.guild_navigation("linked-roles", role_id) enlaza en el chat el rol vinculado de un servidor.