Pular para o conteúdo principal

Login e Callback OAuth

Última atualização: 14 de julho de 2026

Visão Geral

O login não abre uma tela de usuário e senha dentro do app. Ele delega essa etapa inteira para o navegador do dispositivo, seguindo o fluxo OAuth com PKCE, e só volta a ter controle quando o Keycloak redireciona de volta para o app através de um deep link contendo o código de autorização. Esse desenho existe porque o Keycloak fica responsável por toda a validação de credenciais, enquanto o app só precisa saber trocar esse código por tokens de acesso.

Arquivos-chave

ArquivoResponsabilidade
services/auth/auth.service.tsGera o PKCE, monta a URL de autorização, abre o navegador e troca o código pelos tokens
services/auth/auth.storage.tsGuarda o codeVerifier pendente entre o momento em que o navegador abre e o momento em que o deep link chega
providers/auth/auth.provider.tsxDetecta o deep link de retorno, chama o serviço de troca de código e resolve a promise que a tela de login está esperando
features/auth/login.auth.controller.tsController da tela de login, dispara o signIn() e reage à autenticação concluída

PKCE: por que o código de autorização não basta por si só

PKCE existe para proteger o fluxo contra um código de autorização interceptado no caminho entre o navegador e o app. Antes de abrir o navegador, o serviço gera um par aleatório de valores, o codeVerifier e o codeChallenge, sendo o segundo derivado do primeiro através de um hash SHA-256:

// auth.service.ts
async function generatePkce(): Promise<{
codeVerifier: string;
codeChallenge: string;
method: "S256";
}> {
const codeVerifier = base64UrlEncode(generateRandomBytes(32));

const hashBase64 = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
codeVerifier,
{ encoding: Crypto.CryptoEncoding.BASE64 },
);

const codeChallenge = hashBase64
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return { codeVerifier, codeChallenge, method: "S256" };
}

O codeChallenge vai na URL de autorização, visível para quem observar a navegação, enquanto o codeVerifier fica guardado só no dispositivo. Quando o app for trocar o código de autorização por tokens, ele precisa enviar esse mesmo codeVerifier de volta ao Keycloak, que confere se ele realmente corresponde ao codeChallenge que recebeu no início. Um código de autorização interceptado sem o codeVerifier correspondente não serve para nada.

Abrindo o navegador

AuthService.signIn() salva o verifier pendente antes de abrir o navegador, porque a resposta do Keycloak pode chegar bem depois desse ponto do código já ter terminado de executar:

// auth.service.ts
async signIn(): Promise<void> {
const redirectUri = AUTH_CONFIG.KEYCLOAK_REDIRECT_URI;
const { codeVerifier, codeChallenge, method } = await generatePkce();

AuthStorage.savePendingPkce(codeVerifier, redirectUri);

const params = new URLSearchParams({
client_id: AUTH_CONFIG.KEYCLOAK_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: AUTH_CONFIG.SCOPES.join(" "),
code_challenge: codeChallenge,
code_challenge_method: method,
prompt: "login",
});

const authUrl = `${AUTH_CONFIG.KEYCLOAK_AUTHORIZATION_ENDPOINT}?${params.toString()}`;

await WebBrowser.openBrowserAsync(authUrl, {
showInRecents: true,
...(Platform.OS === "ios" && {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.PAGE_SHEET,
}),
});
},

O parâmetro prompt: "login" força o Keycloak a sempre pedir credenciais, mesmo que exista uma sessão de navegador ainda válida de um login anterior. Isso é intencional neste app de campo, onde diferentes vendedores podem compartilhar o mesmo aparelho.

Depois que o usuário se autentica no Keycloak, ele é redirecionado para com.vidyacode.force:///?code=..., o redirect URI configurado em AUTH_CONFIG. É o AuthProvider quem escuta esse retorno, através do hook useLinkingURL, e ele se protege contra processar o mesmo link duas vezes guardando a última URL já tratada:

// auth.provider.tsx
useEffect(() => {
if (!linkingUrl) return;
if (!linkingUrl.includes("code=")) return;
if (handledUrlRef.current === linkingUrl) return;
handledUrlRef.current = linkingUrl;

(async () => {
try {
const success = await AuthService.handleDeepLinkCallback(linkingUrl);
setHasPendingPostLoginSync(success);
await dismissBrowserIfOpen();

if (deepLinkResolverRef.current) {
deepLinkResolverRef.current(success);
deepLinkResolverRef.current = null;
} else if (success) {
router.replace("/auth/login.auth" as any);
}
} catch (error) {
console.error("[AuthProvider] Deep link callback failed:", error);
if (deepLinkResolverRef.current) {
deepLinkResolverRef.current(false);
deepLinkResolverRef.current = null;
}
}
})();
}, [dismissBrowserIfOpen, linkingUrl]);

Há dois cenários diferentes de onde esse deep link pode chegar, e o código trata os dois. No cenário comum, o app já estava aberto esperando o retorno do navegador, e a promise criada em signIn() está esperando ser resolvida através de deepLinkResolverRef. No cenário de cold start, o sistema operacional abre o app diretamente a partir do deep link, sem que nenhuma tela tenha chamado signIn() antes. Nesse segundo caso não existe uma promise pendente para resolver, então o provider simplesmente navega para a tela de login, que por sua vez detecta que isAuthenticated já é verdadeiro e continua o fluxo normal de pós-login.

Trocando o código por tokens

handleDeepLinkCallback extrai o código da URL, recupera o codeVerifier salvo e faz a troca:

// auth.service.ts
async handleDeepLinkCallback(url: string): Promise<boolean> {
const code = this.extractCodeFromUrl(url);
if (!code) return false;

const pending = AuthStorage.readPendingPkce();
if (!pending) {
const existingTokens = AuthStorage.readTokens();
if (existingTokens) return false; // callback já tratado antes
return false;
}

AuthStorage.clearPendingPkce();

const tokens = await exchangeCodeForTokens(code, pending.codeVerifier, pending.redirectUri);
AuthStorage.saveTokens(tokens);

return true;
},

A verificação de que já existem tokens salvos quando não há PKCE pendente evita um problema real de deep link duplicado, que pode acontecer se o sistema operacional entregar o mesmo link mais de uma vez. Sem tokens ainda salvos e sem PKCE pendente, o callback é tratado como inválido e simplesmente ignorado.

O que fica persistido em AuthStorage depois de uma troca bem-sucedida é exatamente o que veio na resposta do Keycloak, com o prazo de expiração já convertido para um timestamp absoluto:

// auth.service.ts
function buildTokensFromResponse(data: TokenResponse): StoredTokens {
const expiresAt = nowMs() + data.expires_in * 1000;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenType: data.token_type,
expiresAt,
idToken: data.id_token,
};
}

O que acontece depois que os tokens são salvos

Salvar os tokens dispara os hooks reativos (useAuthTokens), então qualquer parte da UI que dependa de isAuthenticated atualiza automaticamente. A tela de login tem um efeito específico para esse momento, que consome a flag de sincronização pendente e navega para a rota inicial resolvida:

// login.auth.controller.ts
useEffect(() => {
if (!isAuthenticated) return;

const shouldRunPostLoginSync = consumePendingPostLoginSync();

if (shouldRunPostLoginSync) {
reset();
void startSyncAll();
}

router.replace(resolveStartupRoute({ isAuthenticated: true }) as any);
}, [consumePendingPostLoginSync, isAuthenticated, reset, router, startSyncAll]);

Essa sincronização completa só é disparada depois de um login recém-concluído através do navegador, marcada por hasPendingPostLoginSync. Ela não roda de novo, por exemplo, se o app for simplesmente reaberto com uma sessão que já estava válida, já que nesse caso não existe callback de deep link para acionar a flag.

Armadilhas conhecidas

Não implemente nenhuma etapa desse fluxo fora de AuthService. A tela e o controller de login existem só para acionar signIn() e reagir ao resultado, nunca para montar a URL de autorização ou fazer a troca de código diretamente. Também não gere o codeVerifier e o codeChallenge em chamadas separadas, já que fazer isso corre o risco de os dois ficarem fora de sincronia caso alguma chamada assíncrona seja intercalada entre as duas.