Documentação
Servidor OAuthTela de consentimento

Tela de consentimento

A página da sua aplicação onde o usuário aprova um app de terceiro, em Next.js, Nuxt, SvelteKit e Remix.

A tela de consentimento é a única parte do fluxo OAuth que você constrói. A nulvless não renderiza, e isso é deliberado: no momento em que alguém concede a outra empresa acesso à própria conta, essa pessoa deve estar olhando para o seu produto.

Ela vive no Caminho de autorização que você define no Console, dentro do seu Site URL. O usuário chega com um parâmetro:

https://seuapp.com/oauth/consent?authorization_id=auth_…

O que a página precisa fazer

Quatro coisas, nesta ordem.

Garantir que alguém está logado

Os endpoints de autorização agem em nome de uma sessão. Se a pessoa está deslogada, mande para o login e traga de volta com o authorization_id intacto — perdê-lo deixa o usuário travado no meio do fluxo sem saída.

Ler o pedido

getAuthorization(id) devolve o cliente, os escopos e se já foi aprovado antes. Tudo que você renderiza vem daí. Não leia o nome do cliente da sua própria query string; ele não veio do cliente, e confiar nisso é como telas de consentimento são falsificadas.

Se puder, saia da frente

Se autoApproved for true, este usuário já concedeu exatamente esses escopos a este cliente. Aprove e redirecione sem renderizar. Uma tela que reaparece para permissões já dadas ensina as pessoas a clicar sem ler.

Registrar a resposta e redirecionar para onde mandarem

consent(id, 'approve' | 'deny') devolve redirect.uri. Mande o navegador para lá. Não monte essa URL — ela carrega o código e o state, e montá-la você mesmo é como eles se perdem ou vazam.

Next.js — App Router

Server component para ler, server action para responder.

app/oauth/consent/page.tsx
import { redirect } from 'next/navigation';
import { createServerClient, forwardUserHeaders } from '@nulvless/nulvless-js';
import { headers } from 'next/headers';

export default async function ConsentPage({
  searchParams,
}: {
  searchParams: Promise<{ authorization_id?: string }>;
}) {
  const { authorization_id: id } = await searchParams;
  if (!id) redirect('/');

  const nulvless = createServerClient(process.env.NULVLESS_URL!, process.env.NULVLESS_KEY!);
  const { data, error } = await nulvless.auth.oauth.getAuthorization(id, {
    headers: forwardUserHeaders(new Request('https://x', { headers: await headers() })),
  });

  if (error) redirect(`/login?next=/oauth/consent?authorization_id=${id}`);
  if (data.autoApproved) redirect((await decide(id, 'approve')) as string);

  return (
    <main>
      <h1>{data.client.name} quer acessar sua conta</h1>
      <ul>
        {data.scope.map((scope) => (
          <li key={scope}>{scopeLabel(scope)}</li>
        ))}
      </ul>
      <form action={approve}><button type="submit">Permitir</button></form>
      <form action={deny}><button type="submit">Negar</button></form>
    </main>
  );

  async function approve() { 'use server'; redirect(await decide(id!, 'approve')); }
  async function deny() { 'use server'; redirect(await decide(id!, 'deny')); }
}

async function decide(id: string, action: 'approve' | 'deny') {
  'use server';
  const nulvless = createServerClient(process.env.NULVLESS_URL!, process.env.NULVLESS_KEY!);
  const { data } = await nulvless.auth.oauth.consent(id, action);
  return data.redirect.uri;
}

Nuxt

pages/oauth/consent.vue
<script setup lang="ts">
const route = useRoute();
const id = route.query.authorization_id as string;

if (!id) await navigateTo('/');

const { data, error } = await useAsyncData(`consent-${id}`, () =>
  $fetch(`/api/oauth/authorizations/${id}`),
);

if (error.value) {
  await navigateTo(`/login?next=${encodeURIComponent(route.fullPath)}`);
} else if (data.value?.autoApproved) {
  await decide('approve');
}

async function decide(action: 'approve' | 'deny') {
  const result = await $fetch<{ redirect: { uri: string } }>(
    `/api/oauth/authorizations/${id}/consent`,
    { method: 'POST', body: { action } },
  );

  await navigateTo(result.redirect.uri, { external: true });
}
</script>

<template>
  <main v-if="data">
    <h1>{{ data.client.name }} quer acessar sua conta</h1>
    <ul>
      <li v-for="scope in data.scope" :key="scope">{{ scopeLabel(scope) }}</li>
    </ul>
    <button @click="decide('approve')">Permitir</button>
    <button @click="decide('deny')">Negar</button>
  </main>
</template>

external: true importa

navigateTo usa roteamento no cliente por padrão. O redirect URI pertence ao app de terceiro, então precisa sair da sua aplicação — sem external: true o Nuxt tenta resolvê-lo como uma rota sua e o fluxo morre ali.

SvelteKit

Loader lê, form action responde.

src/routes/oauth/consent/+page.server.ts
import { redirect, error } from '@sveltejs/kit';
import { nulvless } from '$lib/server/nulvless';

export async function load({ url, locals }) {
  const id = url.searchParams.get('authorization_id');
  if (!id) redirect(303, '/');
  if (!locals.session) redirect(303, `/login?next=${encodeURIComponent(url.pathname + url.search)}`);

  const { data, error: failure } = await nulvless.auth.oauth.getAuthorization(id);
  if (failure) error(400, failure.message);

  if (data.autoApproved) {
    const { data: result } = await nulvless.auth.oauth.consent(id, 'approve');
    redirect(303, result.redirect.uri);
  }

  return { id, client: data.client, scope: data.scope };
}

export const actions = {
  decide: async ({ request, url }) => {
    const id = url.searchParams.get('authorization_id')!;
    const action = (await request.formData()).get('action') as 'approve' | 'deny';

    const { data } = await nulvless.auth.oauth.consent(id, action);
    redirect(303, data.redirect.uri);
  },
};
src/routes/oauth/consent/+page.svelte
<script lang="ts">
  let { data } = $props();
</script>

<main>
  <h1>{data.client.name} quer acessar sua conta</h1>
  <ul>
    {#each data.scope as scope}<li>{scopeLabel(scope)}</li>{/each}
  </ul>
  <form method="POST" action="?/decide">
    <button name="action" value="approve">Permitir</button>
    <button name="action" value="deny">Negar</button>
  </form>
</main>

Remix / React Router

app/routes/oauth.consent.tsx
import { redirect, Form, useLoaderData } from 'react-router';
import { nulvless } from '~/lib/nulvless.server';

export async function loader({ request }: { request: Request }) {
  const url = new URL(request.url);
  const id = url.searchParams.get('authorization_id');
  if (!id) throw redirect('/');

  const { data, error } = await nulvless.auth.oauth.getAuthorization(id);
  if (error) throw redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`);

  if (data.autoApproved) {
    const { data: result } = await nulvless.auth.oauth.consent(id, 'approve');
    throw redirect(result.redirect.uri);
  }

  return { id, client: data.client, scope: data.scope };
}

export async function action({ request }: { request: Request }) {
  const form = await request.formData();
  const id = new URL(request.url).searchParams.get('authorization_id')!;

  const { data } = await nulvless.auth.oauth.consent(
    id,
    form.get('action') as 'approve' | 'deny',
  );

  return redirect(data.redirect.uri);
}

export default function Consent() {
  const { client, scope } = useLoaderData<typeof loader>();

  return (
    <main>
      <h1>{client.name} quer acessar sua conta</h1>
      <ul>{scope.map((s) => <li key={s}>{scopeLabel(s)}</li>)}</ul>
      <Form method="post">
        <button name="action" value="approve">Permitir</button>
        <button name="action" value="deny">Negar</button>
      </Form>
    </main>
  );
}

Escrevendo a tela em si

O código é a metade fácil. O que a página diz decide se o consentimento significa alguma coisa.

Nomeie o app, não o pedido. "Acme Analytics quer acessar sua conta" diz quem está pedindo. "Autorizar aplicação" não diz nada.

Traduza escopos em consequências. profile é um token de protocolo, não uma frase. "Ver seu nome e sua foto" é com o que a pessoa está concordando.

EscopoO que escrever
openidConfirmar quem você é
emailVer seu endereço de e-mail
profileVer seu nome e sua foto
phoneVer seu telefone

Mostre o logo e a URL que você recebeu, de client.logoUri e client.uri — ajudam a reconhecer um app conhecido e a estranhar um desconhecido.

Faça do "Negar" um botão de verdade. Uma tela de consentimento com uma única ação óbvia não é consentimento, é quebra-molas. Negar é uma resposta legítima e deve parecer uma.

Diga quem está logado. Se a pessoa tem duas contas, a única coisa que mostra qual delas está prestes a ser conectada é a sua página.

Não renderize nada vindo da query string

authorization_id é o único parâmetro em que confiar, e apenas como chave de busca. Todo nome, logo e escopo exibido tem que vir do getAuthorization — essa resposta veio do seu projeto. O que está na URL veio de quem mandou o usuário, que é justamente a parte sendo avalizada.

Nesta página