Añadir inicio de sesión a una aplicación React de página única

Esta guía de inicio rápido añade el inicio de sesión de KeenAuth a una aplicación React de página única creada con Vite, usando el SDK para navegador @keenauth/js. La aplicación permite que los usuarios inicien sesión mediante una redirección, llama a una API protegida con un token de acceso vinculado a DPoP y permite cerrar sesión. También puede probarla sin conexión. El SDK se describe en SDK de JavaScript para navegador.

En una aplicación de página única, los tokens se guardan en el navegador. El SDK los mantiene solo en memoria y los vincula a una clave no extraíble mediante DPoP, de modo que un token robado no puede usarse sin esa clave. Si su aplicación tiene un servidor, la guía de inicio rápido de Next.js mantiene todos los tokens en el servidor (backend for frontend), que es la opción más segura.

La aplicación tiene estas rutas:

Ruta Qué hace
/ Un botón de inicio de sesión o, si el usuario ya ha iniciado sesión, el usuario, un botón que llama a su API y un botón de cierre de sesión
/callback El URI de redirección. El SDK completa aquí el inicio de sesión y la aplicación vuelve a la página desde la que el usuario comenzó

Antes de empezar

1. Instalar

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install @keenauth/js

El SDK todavía no está publicado. Hasta que lo esté, el ejemplo enlaza con el código fuente del SDK en su package.json. El ejemplo fija vite@8.3.4, @vitejs/plugin-react@6.1.2, react@19.3.1 y react-dom@19.3.1.

2. Configurar

La aplicación lee su configuración solo desde variables de entorno. Colóquelas en .env.local, que Vite carga, y nunca envíe ese archivo al control de versiones. Vite incluye en el JavaScript que descarga el navegador todas las variables VITE_, así que no coloque ahí ningún secreto. Esta aplicación no tiene ninguno.

Variable Obligatoria Significado
VITE_KEENAUTH_ISSUER sí El emisor de su entorno
VITE_KEENAUTH_CLIENT_ID sí El client_id de la aplicación de página única
VITE_KEENAUTH_API_AUDIENCE sí El identificador de su API en KeenAuth
VITE_API_URL no URL base de su API. Vacío significa el mismo origen que la aplicación
API_PROXY_TARGET no Solo para desarrollo: adónde reenvía el servidor de desarrollo /api; por defecto http://localhost:8080

loadConfig lee y comprueba estos valores. Si falta alguno, detiene la aplicación con un error que indica el nombre de la variable:

export function loadConfig(env: Readonly<Record<string, unknown>>): AppConfig {
  const need = (name: string): string => {
    const v = env[name];
    if (typeof v !== "string" || v.trim() === "") throw new Error(`${name} is required (see .env.example)`);
    return v.trim();
  };

3. Crear el cliente del SDK

export function createAuth(config: AppConfig, origin: string, inject: Injectables = {}): KeenAuth {
  return new KeenAuth({
    issuer: config.issuer,
    clientId: config.clientId,
    redirectUri: `${origin}${CALLBACK_PATH}`,
    postLogoutRedirectUri: `${origin}/`,
    // The access token is for this API: its aud is the API identifier (RFC 8707).
    resource: config.apiAudience,
    // messages:read is a scope of that API; the example API requires it on /api/messages.
    scope: "openid profile email offline_access messages:read",
    ...inject,
  });
}

resource es el identificador de su API (RFC 8707). El SDK lo envía en la solicitud de autorización, en el intercambio del código y en cada renovación, y la audiencia del token de acceso es su API. Sin este valor, el token de acceso no está destinado a su API y la API lo rechaza. El SDK comprueba cada opción al crear el cliente; un emisor o un resource no válidos lanzan un KeenAuthError con el código config_invalid.

Cree un único cliente para la página y páselo a un proveedor:

const config = loadConfig(import.meta.env);
const auth = createAuth(config, window.location.origin);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <AuthProvider auth={auth}>
      <App apiUrl={config.apiUrl} />
    </AuthProvider>
  </StrictMode>,
);

4. Iniciar sesión y gestionar la redirección de vuelta

El botón de inicio de sesión inicia el inicio de sesión por redirección: flujo de código de autorización con PKCE (S256), state y nonce. appState es la ruta a la que volver.

        <button onClick={() => auth.signInRedirect({ appState: window.location.pathname })}>Sign in</button>

Cuando KeenAuth redirige de vuelta a /callback, el proveedor completa el inicio de sesión. El SDK comprueba state, iss y el ID token, y canjea el código con una prueba DPoP. Una respuesta de autorización solo puede usarse una vez, y StrictMode de React ejecuta los efectos dos veces en desarrollo, así que completeSignIn recuerda el primer resultado:

export function completeSignIn(auth: KeenAuth, href: string): Promise<string | null> {
  const url = new URL(href);
  if (url.pathname !== CALLBACK_PATH || !(url.searchParams.has("code") || url.searchParams.has("error"))) {
    return Promise.resolve(null);
  }
  let done = completions.get(auth);
  if (!done) {
    done = auth.handleRedirectCallback(href).then((r) => safeReturnPath(r.appState));
    completions.set(auth, done);
  }
  return done;
}

Después, el proveedor elimina code y state de la barra de direcciones. Solo se usa como ruta de retorno una ruta dentro de su aplicación, así que un appState manipulado no puede enviar al usuario a otro sitio:

export function safeReturnPath(path: string | undefined): string {
  return path !== undefined && /^\/(?![/\\])/.test(path) ? path : "/";
}

Sirva también index.html para /callback en producción (el servidor de desarrollo de Vite ya lo hace).

5. Llamar a su API

authorizedFetch envía Authorization: DPoP <token> con una prueba nueva vinculada al token, y renueva el token cuando está a punto de caducar:

export async function fetchMessages(auth: KeenAuth, apiUrl: string): Promise<unknown> {
  const res = await auth.authorizedFetch(`${apiUrl}/api/messages`, { headers: { Accept: "application/json" } });
  if (!res.ok) throw new Error(`The API answered ${res.status}`);
  return res.json();
}

En desarrollo, el servidor de Vite reenvía /api a su API, de modo que el navegador la llama desde el mismo origen que la aplicación y la API no necesita CORS:

      proxy: { "/api": env.API_PROXY_TARGET || "http://localhost:8080" },

La API comprueba la prueba DPoP frente a la URL que llamó el navegador. Si ejecuta la API de la guía de inicio rápido de Express detrás de este proxy, establezca su PUBLIC_BASE_URL en http://localhost:5173. En producción, sirva la API desde el mismo origen o habilite CORS para el origen de su aplicación, y establezca VITE_API_URL.

6. Cerrar sesión

      <button onClick={() => auth.signOut()}>Sign out</button>

signOut elimina los tokens, revoca el token de renovación y envía el navegador al endpoint de cierre de sesión de su entorno, que lo devuelve a /.

7. Ejecutar la aplicación

Inicie la API de la guía de inicio rápido de Express con PUBLIC_BASE_URL=http://localhost:5173 y KEENAUTH_AUDIENCE establecido con el identificador de su API, y después ejecute la aplicación:

cp .env.example .env.local   # then fill it in
npm run dev

Abra http://localhost:5173, elija Sign in, inicie sesión en KeenAuth y elija Call the API.

El SDK mantiene los tokens solo en memoria. Tras recargar la página, el usuario queda desconectado en la aplicación y debe iniciar sesión de nuevo; la sesión de KeenAuth normalmente hace que esto sea una redirección sin solicitud de credenciales.

8. Probar sin red

Las pruebas no necesitan red ni una cuenta de KeenAuth. Un fetch simulado hace de servidor de autorización y de API, y el ID token se firma con una clave creada para la prueba. Node ejecuta directamente las pruebas en TypeScript.

  assert.deepEqual(await fetchMessages(s.auth, ""), { messages: ["hello"] });
  const call = s.seen.find((c) => c.url === `${ORIGIN}/api/messages`)!;
  assert.equal(call.headers.get("Authorization"), "DPoP access-token-1");
npm test
npm run typecheck
npm run deps    # pinned versions, lockfile sources and licences
npm run build

Las pruebas comprueban que la configuración se valida, que el SDK rechaza un emisor o un resource no válidos, que el inicio de sesión envía PKCE S256 y su API como resource, que el callback se completa una sola vez y envía resource junto con el código, que un state falsificado se rechaza antes de cualquier solicitud de token, que tras el inicio de sesión solo se usa una ruta dentro de la aplicación, que ningún token se escribe en el almacenamiento y que la llamada a la API lleva el token vinculado a DPoP y su prueba.

El inicio de sesión completo con un navegador real necesita un entorno de KeenAuth. No forma parte de las pruebas sin conexión.

Próximos pasos