@geekapps/auth-react-native
v0.9.2
Published
Geekapps Auth SDK for React Native (Expo and bare) apps — PKCE login, device flow, token refresh.
Readme
@geekapps/auth-react-native
SDK de login para apps React Native (Expo ou bare) que usam o Geekapps Auth (app-api) como Authorization Server.
Cobre:
- Login via Authorization Code + PKCE, abrindo o navegador do sistema (
Linking.openURLpor padrão, ou umbrowserOpenercustomizado — ex:expo-web-browser); - Troca de
codepor tokens, com renovação automática (refresh_token) quando o access token está perto de expirar; - Device Authorization Grant (útil pra fluxos de segundo dispositivo);
- Storage de tokens plugável — a lib não força nenhuma dependência nativa; em produção, injete um adapter sobre
expo-secure-storeoureact-native-keychain; request()— fetch autenticado contra oapp-api, injeta o Bearer token e renova sozinho se necessário;- Gerenciamento de organizações — organizações, membros, convites, roles, visibilidade pública/privada e solicitações de entrada (
useOrg(),useMembers(),useRoles(),usePublicOrganizations(),useJoinRequests()).
Núcleo (core.ts) é JS puro, sem imports de react-native — só client.tsx depende de Linking. crypto.getRandomValues precisa estar disponível no runtime (ver "Instalação").
Instalação
bun add @geekapps/auth-react-native
# polyfill de crypto.getRandomValues, necessário pro PKCE
bun add react-native-get-random-valuesNo topo do index.js/App.tsx (antes de qualquer outro import):
import "react-native-get-random-values";Expo SDK 53+: adicione ao .env do app:
EXPO_PUBLIC_USE_RN_FETCH=1A partir do SDK 53 o Expo substitui o fetch global pelo runtime "Winter" (WinterCG), que só aceita Blob/File de verdade em FormData — não o formato clássico do React Native ({ uri, type, name }) que uploadAvatar() usa. Sem essa env var, o upload de avatar falha com Unsupported FormDataPart implementation antes mesmo de qualquer chamada de rede. EXPO_PUBLIC_USE_RN_FETCH=1 reverte o app para o fetch clássico do React Native.
Uso
import { GeekappsAuthProvider, useAuth } from "@geekapps/auth-react-native";
export function App() {
return (
<GeekappsAuthProvider
config={{
issuer: "https://auth.suaapp.com",
clientId: "gk_client_xxx",
redirectUri: "myapp://callback", // precisa de pkceFlowEnabled=true na Application
scopes: ["openid", "profile", "email"],
}}
>
<Home />
</GeekappsAuthProvider>
);
}
function Home() {
const { isAuthenticated, isLoading, user, signIn, signOut, request } = useAuth();
if (isLoading) return null;
if (!isAuthenticated) {
return <Button title="Entrar" onPress={() => signIn()} />;
}
return (
<View>
<Text>Olá, {user?.name as string}</Text>
<Button title="Sair" onPress={signOut} />
</View>
);
}Storage seguro (recomendado em produção)
import * as SecureStore from "expo-secure-store";
import type { TokenStorage } from "@geekapps/auth-react-native";
const secureStorage: TokenStorage = {
getItem: (key) => SecureStore.getItemAsync(key),
setItem: (key, value) => SecureStore.setItemAsync(key, value),
removeItem: (key) => SecureStore.deleteItemAsync(key),
};
<GeekappsAuthProvider config={config} storage={secureStorage}>Browser nativo em apps Expo
Por padrão o login abre no navegador externo (Linking.openURL). Em apps Expo, expo-web-browser dá uma UX melhor (Custom Tabs/ASWebAuthenticationSession, sem sair do app):
import * as WebBrowser from "expo-web-browser";
<GeekappsAuthProvider
config={config}
browserOpener={(url) => WebBrowser.openBrowserAsync(url).then(() => undefined)}
>Device flow (segundo dispositivo)
const { startDeviceFlow, waitForDeviceAuthorization } = useAuth();
const device = await startDeviceFlow();
// mostra device.userCode e device.verificationUri pro usuário
await waitForDeviceAuthorization(device.deviceCode, device.interval);Upload de avatar
import * as ImagePicker from "expo-image-picker";
const { uploadAvatar } = useAuth();
async function pickAndUploadAvatar() {
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: "images" });
if (result.canceled) return;
const asset = result.assets[0];
// Um único POST multipart/form-data pro app-api, que armazena o arquivo
// e recarrega o user — imageUrl/picture já vem assinada quando necessário
// (é o app-api quem decide/assina; este SDK nunca fala com o storage).
const updatedUser = await uploadAvatar({
uri: asset.uri,
name: asset.fileName ?? "avatar.jpg",
type: asset.mimeType ?? "image/jpeg",
size: asset.fileSize,
});
console.log(updatedUser?.picture);
}Requer
EXPO_PUBLIC_USE_RN_FETCH=1em apps Expo SDK 53+ — ver "Instalação" acima. Sem isso, falha comUnsupported FormDataPart implementation.
Organizações
O usuário logado pode pertencer a várias organizações (Organization). O provider já carrega a lista automaticamente após o login (organizations, currentOrg, currentOrgId, isLoadingOrganizations) — não precisa buscar manualmente.
import { useOrg } from "@geekapps/auth-react-native";
function OrgSwitcher() {
const { organizations, currentOrg, setCurrentOrgId, isLoadingOrganizations } = useOrg();
if (isLoadingOrganizations) return null;
return (
<View>
<Text>Organização atual: {currentOrg?.name ?? "nenhuma"}</Text>
{organizations.map((org) => (
<Button key={org.id} title={org.name} onPress={() => setCurrentOrgId(org.id)} />
))}
</View>
);
}currentOrgId é só um seletor local (qual organização a UI está exibindo) — trocar não faz nenhuma chamada de rede, e não afeta os tokens/sessão atuais.
CRUD de organizações
const { createOrganization, updateOrganization, deleteOrganization } = useOrg();
// applicationId é preenchido automaticamente com o clientId da config — não precisa passar.
const org = await createOrganization({
name: "Acme Inc",
slug: "acme-inc",
description: "Minha empresa",
imageUrl: "https://...",
});
await updateOrganization(org.id, { name: "Acme Inc.", status: "active" });
// Soft delete (marca status: "inactive") — só o dono da organização pode.
await deleteOrganization(org.id);Organizações públicas x privadas
Toda organização nasce privada (visibility: "private") — só aparece pra quem já é membro/dono, e a única forma de entrar é convite ou adição direta por um admin. Marcando visibility: "public" (na criação ou depois via updateOrganization), ela passa a aparecer na listagem pública do app e qualquer usuário autenticado pode solicitar entrada, sem precisar de convite.
// Cria já como pública
const org = await createOrganization({ name: "Comunidade Acme", slug: "comunidade-acme", visibility: "public" });
// Ou torna pública uma organização que já existia como privada
await updateOrganization(org.id, { visibility: "public" });
// Volta a ser privada — some da listagem pública, mas quem já é membro continua sendo
await updateOrganization(org.id, { visibility: "private" });Esse é o gancho pra apps que fazem sentido com "comunidades" ou "times abertos" que qualquer usuário pode pedir pra entrar (ex: um app de bairro, uma liga de jogo, um espaço de coworking) — sem precisar gerenciar convite por convite.
Descobrindo organizações públicas
usePublicOrganizations() é totalmente separado de useOrg(): useOrg().organizations são as organizações do usuário logado (as que ele já é membro/dono); listPublicOrganizations() é a vitrine pública do app inteiro — organizações visibility: "public" e ativas, visível a qualquer usuário autenticado, sem exigir membership. Organizações privadas nunca aparecem aqui.
import { usePublicOrganizations } from "@geekapps/auth-react-native";
function DiscoverOrganizations() {
const { listPublicOrganizations } = usePublicOrganizations();
const [orgs, setOrgs] = useState<PublicOrganization[]>([]);
useEffect(() => {
listPublicOrganizations().then(setOrgs);
}, [listPublicOrganizations]);
return (
<FlatList
data={orgs}
keyExtractor={(org) => org.id}
renderItem={({ item }) => <Text>{item.name}</Text>}
/>
);
}Solicitar entrada numa organização pública (join request)
import { useJoinRequests } from "@geekapps/auth-react-native";
const {
requestToJoin,
getMyJoinRequest,
cancelJoinRequest,
listJoinRequests,
approveJoinRequest,
rejectJoinRequest,
} = useJoinRequests();
// Usuário pede pra entrar (falha com 403 se a org for privada)
const joinRequest = await requestToJoin(org.id, { message: "Trabalho na região, posso entrar?" });
console.log(joinRequest.status); // "pending"
// O próprio usuário consulta o status da sua solicitação
const mine = await getMyJoinRequest(org.id); // null se nunca solicitou
// E pode cancelar enquanto estiver pendente
await cancelJoinRequest(org.id);
// Quem administra a organização (dono ou quem tem a permissão org:members:add)
const pending = await listJoinRequests(org.id);
await approveJoinRequest(org.id, joinRequest.id); // cria a membership (role default do app)
await rejectJoinRequest(org.id, joinRequest.id); // não cria membershipSolicitar entrada numa organização onde já existe uma solicitação rejected/cancelled reabre a mesma solicitação (volta pra pending) em vez de criar uma nova — não acumula histórico duplicado por usuário/organização.
Membros e convites
import { useMembers } from "@geekapps/auth-react-native";
const {
listMembers,
getMember,
addMember,
updateMember,
removeMember,
inviteMember,
listInvites,
revokeInvite,
} = useMembers();
const members = await listMembers(org.id); // OrganizationMember[] — cada um já traz `user` (nome/email/foto)
console.log(members[0].user.name, members[0].user.email);
const member = await getMember(org.id, members[0].id);
// `user` só existe aqui — colegas de organização. O SDK não expõe nenhuma
// forma de ler dados de um usuário que não seja membro da mesma org que
// você está consultando (nada de telefone/endereço/sessões, também).
// Adiciona um usuário que já existe pelo id (sem passar roleId, cai na role default do app)
await addMember(org.id, { userId: "user-uuid", roleId: "role-uuid" });
// Convida por email quem ainda não tem conta/membership
const invite = await inviteMember(org.id, { email: "[email protected]", roleId: "role-uuid" });
await updateMember(org.id, memberId, { roleId: "outra-role-uuid" });
await removeMember(org.id, memberId);
const invites = await listInvites(org.id); // pendentes, aceitos, expirados, revogados
await revokeInvite(org.id, invite.id);Convite por email (redirectUri obrigatório)
redirectUri é sempre obrigatório ao convidar — o auth-server nunca hospeda uma tela de convite, então este app é quem trata o ?invite_token=... recebido nessa URI. Ela precisa estar cadastrada em Application.redirectUris, mesma validação do /authorize, sem exceção.
await inviteMember(org.id, {
email: "[email protected]",
roleId: "role-uuid",
redirectUri: "myapp://invite", // precisa estar em Application.redirectUris
emailTitle: "Você foi chamado!", // opcional — padrão: "Entre em {organização}"
emailSubtitle: "Time de vendas", // opcional
emailMessage: "Estamos ansiosos pra te ter no time.", // opcional
});Deep link (myapp://invite) é o de costume pra apps React Native — o próprio SDK já escuta Linking e popula pendingInviteToken automaticamente (ver seção abaixo). redirectUri também pode ser uma URL https:// normal (útil se o convite for aceito por uma página web do seu próprio backend em vez do app).
Roles customizadas
import { useRoles } from "@geekapps/auth-react-native";
const { listRoles, getRole, createRole, updateRole, deleteRole } = useRoles();
const roles = await listRoles(org.id); // inclui roles do sistema e customizadas do app
const role = await createRole(org.id, {
key: "editor",
name: "Editor",
permissionIds: ["perm-uuid-1", "perm-uuid-2"],
});
await updateRole(org.id, role.id, { permissionIds: ["perm-uuid-1"] });
await deleteRole(org.id, role.id);Todas as chamadas de organização/membros/roles passam pelo mesmo
request()autenticado douseAuth()— exigem sessão ativa e respeitam as permissões do usuário logado na organização (org:read,org:members:add,org:roles:update, etc. — verificadas pelo Auth Server, não pelo SDK).
Convites por email (aceitar direto no app)
O link do email de convite (redirectUri, deep link do app, ex: myapp://invite) tenta abrir o app diretamente com fallback pra loja se não estiver instalado. useInvites() (ou os mesmos campos em useAuth()) é como este app trata o ?invite_token=... recebido.
import { useEffect } from "react";
import { useAuth, useInvites } from "@geekapps/auth-react-native";
function InviteHandler() {
const { pendingInviteToken, clearPendingInvite, isAuthenticated, signIn } = useAuth();
const { getInvitePreview, acceptInvite, rejectInvite } = useInvites();
useEffect(() => {
if (!pendingInviteToken) return;
// navegue pra uma tela própria de convite, passando pendingInviteToken
}, [pendingInviteToken]);
async function handleOpen(token: string) {
const preview = await getInvitePreview(token);
if (preview.alreadyAccepted) return; // já é membro
if (!isAuthenticated) {
await signIn(); // volta com sessão ativa; getInvitePreview de novo já traz currentAccount
return;
}
// usuário logado — currentAccount presente, mostra tela de aceitar/recusar
}
async function handleAccept(token: string) {
const result = await acceptInvite(token);
if (result.success) {
clearPendingInvite();
console.log(`Entrou em ${result.organizationName}`);
} else if (result.loginRequired) {
await signIn();
} else if (result.wrongAccount) {
console.log(`Convite é para ${result.invitedEmail}, logado como ${result.currentEmail}`);
}
}
async function handleReject(token: string) {
await rejectInvite(token); // não exige estar logado
clearPendingInvite();
}
return null;
}pendingInviteToken é populado automaticamente sempre que o app recebe um deep link com ?invite_token=... — inclusive com o app já aberto e logado, ou como cold start (Linking.getInitialURL()). Chame clearPendingInvite() depois de tratar o convite (ex: ao navegar pra fora da tela).
Configuração necessária na Application (dashboard)
pkceFlowEnabled = true— permiteredirect_uricom custom scheme (ex:myapp://callback).- Cadastrar
myapp://callbackna lista deredirect_urisda Application. - Pra aceitar convites direto no app, cadastrar também o deep link usado no convite (ex:
myapp://invite) e configurarappStoreUrl/playStoreUrlda Application (fallback quando o app não está instalado).
