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.
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
<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.
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);
},
};<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
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.
| Escopo | O que escrever |
|---|---|
openid | Confirmar quem você é |
email | Ver seu endereço de e-mail |
profile | Ver seu nome e sua foto |
phone | Ver 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.