Pular para conteúdo

OAuth2 e cargos vinculados

Bostie erguendo uma chave brilhante

O OAuth2 permite que um usuário dê ao seu app acesso à conta dele: o perfil, os servidores, as contas conectadas, o direito de adicioná-lo a um servidor ou uma conexão de cargo para cargos vinculados. O bostie monta as URLs de autorização e chama a API com o token que o usuário concede.

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",
                )
            ],
        )

URLs de autorização

oauth2.authorize_url(client_id, scopes=[...], redirect_uri=..., state=...) retorna a URL que pede ao usuário que conceda scopes e o redireciona de volta para redirect_uri com um code. Os escopos são membros de OAuth2Scope, como OAuth2Scope.IDENTIFY para identify, um para cada escopo que o Discord documenta; uma string simples funciona para um escopo que o Discord adicionar antes que o bostie lhe dê um nome. Confira se o redirecionamento traz o state que você enviou, para que outro site não possa fazer o usuário entrar com o código dele. A função também aceita:

  • code_challenge, o desafio S256 de um verificador PKCE, que exchange_code depois envia como code_verifier; o trecho de código calcula um;
  • prompt="none" para pular a tela de consentimento para um usuário que já concedeu os escopos, ou "consent" para mostrá-la de novo;
  • integration_type para instalar o app em um servidor ou no usuário.

O bostie só monta o fluxo de código de autorização. O Discord desaconselha o fluxo implícito, que coloca o token na URL de redirecionamento e não dá refresh token, e recomenda no lugar dele o fluxo de código com PKCE.

oauth2.invite_url(client_id, permissions=..., scopes=[...]) retorna o link de instalação do app, com integration_type, guild_id para pré-selecionar um servidor e disable_guild_select. O que ele deixa de fora vem das configurações de instalação do app no Developer Portal.

Tokens

BearerClient() sem um token só alcança rest.oauth2:

Método Faz
exchange_code(client_id=, client_secret=, code=, redirect_uri=, code_verifier=None) transforma o código do redirecionamento em um AccessToken
refresh(client_id=, client_secret=, refresh_token=) um token novo antes que o antigo expire
revoke(client_id=, client_secret=, token=) encerra a autorização de um token e de todos os tokens que ele emitiu
exchange_client_credentials(client_id=, client_secret=, scopes=[...]) um token para o dono do app, ou para a equipe dele

Um AccessToken tem access_token, token_type, expires_in em segundos, scope (os escopos concedidos, separados por espaços) e refresh_token. Uma troca de código que incluiu o escopo bot também retorna o guild em que o bot entrou, e uma com webhook.incoming o webhook que ela criou. O repr de um token esconde os segredos, então escrever um no log não o vaza.

Chamando a API como o usuário

async with BearerClient(access_token) as user: alcança o que os escopos do token permitem:

user.rest. Métodos Escopo
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 diz o que o token concede e a quem qualquer um
lobbies o lado do usuário dos lobbies do Social SDK sdk.social_layer

Com o escopo guilds.join, o bot pode adicionar o usuário a um servidor em que ele está: await bot.rest.members.add(guild_id, user_id, access_token=token.access_token), que retorna o novo Member, ou None quando o usuário já era membro.

Cargos vinculados

Um cargo vinculado é um cargo de servidor que os usuários ganham ao cumprir condições em uma conexão com o seu app, como "nível pelo menos 10". O app declara uma vez os valores que informa e depois informa os valores de cada usuário com o token dele:

  1. Registre até 5 registros de metadados com bot.rest.application.update_role_connection_metadata(application_id, [...]), cada um sendo um ApplicationRoleConnectionMetadata com type, key, name e description. register_metadata no trecho de código faz isso com um RESTClient.
  2. Defina a Linked Roles Verification URL do app no Developer Portal como uma página que inicia o fluxo OAuth2 com o escopo role_connections.write.
  3. Depois da troca, chame user.rest.users.update_role_connection(application_id, platform_name=..., platform_username=..., metadata={"level": "12"}). Os valores dos metadados são strings, como a API do Discord os aceita.

O exemplo de cargos vinculados é uma página de verificação completa. fmt.guild_navigation("linked-roles", role_id) cria no chat um link para o cargo vinculado de um servidor.