
# Añadir inicio de sesión a una aplicación de React Router

Este inicio rápido añade el inicio de sesión de KeenAuth a una aplicación de React Router 8 en modo framework, con el cliente estándar de OpenID Connect `openid-client`. Los loaders y las actions se ejecutan en el servidor, así que su aplicación es el cliente OAuth: guarda todos los tokens en una sesión del lado del servidor, y el navegador solo recibe el HTML y una cookie con el id de sesión firmado. Añadirá las rutas de inicio de sesión, protegerá una página, llamará a una API protegida con el token de acceso del usuario, cerrará la sesión y probará la aplicación sin conexión.

Para una aplicación de una sola página sin servidor, consulte [Añadir inicio de sesión a una aplicación de React de una sola página](/docs/quickstarts/react).

La aplicación tiene estas rutas:

| Ruta | Qué hace |
|---|---|
| `GET /` | Página de inicio pública: un botón para iniciar sesión, o el nombre del usuario y un botón para cerrar sesión |
| `GET /dashboard` | Requiere una sesión. Muestra el usuario y los mensajes que devuelve su API |
| `POST /login` | Inicia el inicio de sesión. Solo desde una página de esta aplicación |
| `GET /auth/callback` | Comprueba la respuesta, intercambia el código e inicia la sesión |
| `POST /logout` | Termina la sesión aquí y en KeenAuth. Solo desde una página de esta aplicación |

## Antes de empezar

- Un entorno de KeenAuth. Su emisor tiene un aspecto como `https://your-environment.eu.keenauth.app`.
- Una aplicación web en el entorno ([Crear una aplicación](/docs/api/management/applications/create)) con un secreto de cliente (`client_secret_basic`). En un entorno `development`, registre el URI de redirección `http://localhost/auth/callback` y el URI de redirección tras cerrar sesión `http://localhost/`; un URI de loopback se registra sin puerto y coincide con cualquier puerto, como `5173`.
- Una API registrada en el entorno ([Crear una API](/docs/api/management/apis/create)) con el ámbito `messages:read`. Su `identifier`, por ejemplo `https://api.example.com`, es la audiencia de sus tokens de acceso. El [inicio rápido de Express](/docs/quickstarts/express) construye una API que acepta estos tokens.
- Node.js 22.22 o posterior, y una aplicación de React Router en modo framework (`npx create-react-router@8.5.0`).

## 1. Instalar

```sh
npm install --save-exact openid-client@6.8.8
```

`openid-client` implementa la parte relying party de OpenID Connect: descubrimiento, el flujo de código de autorización con PKCE, la comprobación `iss` de RFC 9207 y las comprobaciones del ID token. El ejemplo fija cada paquete con una versión exacta e instala desde `package-lock.json` con `npm ci --ignore-scripts`, de modo que no se ejecuta ningún script de paquete durante la instalación.

## 2. Configurar

La aplicación lee su configuración solo desde variables de entorno. Colóquelas en `.env`, que `npm run dev` y `npm start` cargan con `node --env-file=.env`, y nunca suba ese archivo al control de versiones.

| Variable | Obligatoria | Significado |
|---|---|---|
| `KEENAUTH_ISSUER` | sí | El emisor de su entorno, `https` |
| `KEENAUTH_CLIENT_ID` | sí | El `client_id` de la aplicación web |
| `KEENAUTH_CLIENT_SECRET` | sí | Su secreto de cliente |
| `APP_BASE_URL` | sí | La URL pública de la aplicación. El URI de redirección se construye a partir de ella. `http` solo para `localhost`, `127.0.0.1` o `[::1]` |
| `SESSION_SECRET` | sí | Al menos 32 caracteres; firma la cookie de sesión. Genere uno con `openssl rand -base64 32` |
| `API_URL` | sí | La API que llama la aplicación, por ejemplo `http://localhost:8080` |
| `KEENAUTH_API_AUDIENCE` | sí | El identificador de esa API en KeenAuth |

`loadConfig` en `app/lib/config.server.ts` comprueba estos valores en la primera solicitud. Un valor que falta o no es utilizable detiene la solicitud, y ningún mensaje de error contiene un valor:

```ts
  const issuer = url("KEENAUTH_ISSUER", false);
  const appBaseUrl = url("APP_BASE_URL", true).origin;
  const sessionSecret = need("SESSION_SECRET");
  if (sessionSecret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
```

Los archivos que terminan en `.server.ts` nunca llegan al bundle del navegador; React Router detiene la compilación si una página importa uno para el navegador.

## 3. Crear el cliente de OpenID Connect

`app/lib/keenauth.server.ts` construye el cliente una sola vez a partir del descubrimiento (`<issuer>/.well-known/openid-configuration`):

```ts
export function keenauth(): Promise<client.Configuration> {
  discovered ??= client
    .discovery(config().issuer, config().clientId, undefined, client.ClientSecretBasic(config().clientSecret), {
      // Also check the ID token signature with your environment's keys (jwks_uri), not only the TLS connection.
      execute: [client.enableNonRepudiationChecks],
    })
```

Por defecto, `openid-client` confía en un ID token que llega directamente del endpoint de token por TLS. `enableNonRepudiationChecks` comprueba además su firma con las claves que publica su entorno.

## 4. Mantener la sesión en el servidor

La sesión vive en archivos del servidor; la cookie solo contiene un id de sesión firmado:

```ts
  storage ??= createFileSessionStorage<SessionData>({
    dir: ".sessions",
    cookie: {
      name: "__keenauth_session",
      httpOnly: true,
      sameSite: "lax",
      // Secure whenever the app is served over https; plain http only for localhost.
      secure: config().redirectUri.startsWith("https:"),
      path: "/",
      maxAge: 8 * 60 * 60,
      secrets: [config().sessionSecret],
    },
  });
```

Un almacén de archivos sirve a un solo servidor. Con varios servidores, escriba un almacenamiento de sesiones con `createSessionStorage` y un almacén compartido, como Redis. No use el almacenamiento de sesiones en cookies para los tokens: dos tokens pueden superar el límite de 4 KB de la cookie, y el navegador los guardaría.

## 5. Iniciar el inicio de sesión

Las rutas se enumeran en `app/routes.ts`:

```ts
export default [
  index("routes/home.tsx"),
  route("dashboard", "routes/dashboard.tsx"),
  route("login", "routes/login.ts"),
  route("auth/callback", "routes/callback.ts"),
  route("logout", "routes/logout.ts"),
] satisfies RouteConfig;
```

El inicio de sesión es un formulario que envía un POST a `/login`:

```tsx
        <Form method="post" action="/login" reloadDocument>
          <button type="submit">Sign in</button>
        </Form>
```

La action genera un nuevo `state`, `nonce` y verificador PKCE para cada inicio de sesión, y los guarda en la sesión:

```ts
export async function action({ request }: Route.ActionArgs) {
  assertSameOrigin(request);
  const session = await getSession(request);
  const transaction = newTransaction();
  session.set("transaction", transaction);
  const url = await authorizationUrl(transaction);
  return redirect(url.href, { headers: { "Set-Cookie": await sessions().commitSession(session) } });
}
```

La solicitud de autorización envía el desafío S256 del verificador y el identificador de su API como `resource` (RFC 8707). Sin `resource`, el token de acceso no está destinado a su API y la API lo rechaza.

```ts
  return client.buildAuthorizationUrl(await keenauth(), {
    redirect_uri: config().redirectUri,
    // messages:read is a scope of your API; the example API requires it on /api/messages.
    scope: "openid profile email messages:read",
    state: transaction.state,
    nonce: transaction.nonce,
    // PKCE (RFC 7636): S256 of the verifier that only this session knows.
    code_challenge: await client.calculatePKCECodeChallenge(transaction.codeVerifier),
    code_challenge_method: "S256",
    // RFC 8707: an access token for your API.
    resource: config().apiAudience,
  });
```

### Aceptar el POST solo desde sus propias páginas

React Router rechaza un POST entre sitios a la action de una página, pero `/login` y `/logout` son rutas de recurso (una action sin página), y en ese caso la comprobación corre por su cuenta. `assertSameOrigin` acepta el POST solo cuando su cabecera `Origin` (o, si no existe, `Referer`) nombra `APP_BASE_URL`:

```ts
  const app = new URL(config().redirectUri).origin;
  const source = request.headers.get("Origin") ?? request.headers.get("Referer");
```

## 6. Gestionar el callback

El callback saca primero la transacción de la sesión, así que un `state` funciona una sola vez:

```ts
  const old = await getSession(request);
  const transaction = old.get("transaction");
  // The transaction works once: it leaves the session before anything else happens.
  old.unset("transaction");
```

`authorizationCodeGrant` comprueba el `state` y el `iss` que su entorno envía en cada respuesta de autorización (RFC 9207), intercambia el código con el verificador PKCE y comprueba el ID token: su firma, `iss`, `aud`, `exp`, `iat` y el `nonce` de este inicio de sesión. El URI de redirección se construye a partir de `APP_BASE_URL`, nunca a partir de la cabecera `Host`:

```ts
  const callbackUrl = new URL(config().redirectUri);
  callbackUrl.search = new URL(request.url).search;
  return client.authorizationCodeGrant(await keenauth(), callbackUrl, {
    pkceCodeVerifier: transaction.codeVerifier,
    expectedState: transaction.state,
    expectedNonce: transaction.nonce,
    idTokenExpected: true,
  });
```

Una comprobación fallida responde `401` con un código de error fijo. Tras un inicio de sesión correcto, la aplicación elimina la sesión anterior y guarda el usuario y los tokens en una nueva:

```ts
  // A new session id after sign-in prevents session fixation: the old session is deleted, a new one is created.
  await sessions().destroySession(old);
  const session = await sessions().getSession(null);
```

## 7. Proteger páginas y llamar a su API

Un loader que necesita un usuario llama a `requireUser`, que redirige a la página de inicio si no hay sesión. El ejemplo no tiene refresh token: cuando el token de acceso caduca, el usuario vuelve a iniciar sesión.

```ts
export async function loader({ request }: Route.LoaderArgs) {
  const { user, accessToken } = await requireUser(request);
  const api = await fetchMessages(accessToken);
  return { user, api };
}
```

El loader llama a su API con el token de acceso como token bearer. El token permanece en el servidor; la página recibe solo el usuario y los mensajes.

```ts
    response = await fetch(`${config().apiUrl}/api/messages`, {
      headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
    });
```

## 8. Cerrar sesión

Cerrar sesión es un `POST` desde una página de esta aplicación. La action termina la sesión local y envía el navegador al endpoint de cierre de sesión de su entorno (OpenID Connect RP-Initiated Logout) con `id_token_hint`, `client_id` y `post_logout_redirect_uri`, que lo devuelve a su aplicación:

```ts
  const session = await getSession(request);
  const idToken = session.get("idToken");
  const destroyed = await sessions().destroySession(session);
  return redirect((await logoutUrl(idToken)).href, { headers: { "Set-Cookie": destroyed } });
```

## 9. Ejecutar la aplicación

Inicie la API del inicio rápido de Express (o cualquier API que acepte los tokens) y ejecute la aplicación:

```sh
npm run dev
```

Abra `http://localhost:5173`, elija **Iniciar sesión**, inicie sesión en KeenAuth y abra el panel.

## 10. Probar sin red

Las pruebas no necesitan red ni una cuenta de KeenAuth. `npm test` compila la aplicación y ejecuta la compilación a través del manejador de solicitudes de React Router. Un `fetch` simulado hace de su entorno (descubrimiento, un conjunto de claves creado para la ejecución de las pruebas, el endpoint de token) y de la API.

```ts
test("callback: an unknown or reused state is refused before any token request and starts no session", async () => {
  const b = browser();
  await startSignIn(b);
  const wrong = await returnFromKeenAuth(b, { state: "not-the-state" });
```

```sh
npm test
npm run deps    # exact pins, lockfile sources and licences
```

Las pruebas comprueban que la configuración se valida (y que ningún error contiene un secreto); que el inicio de sesión envía PKCE S256, `state`, `nonce` y `resource`, y que se rechaza desde otro sitio o con un `GET`; que el código se intercambia con el verificador correspondiente y la autenticación de cliente; que el token de acceso va a la API en la cabecera `Authorization` y nunca al navegador; que un `state` desconocido o reutilizado, un `iss` ausente o incorrecto, un error de autorización y un ID token de otro cliente, con otro nonce o firmado por otra clave, se rechazan sin crear sesión; que el inicio de sesión genera un nuevo id de sesión; que una página sin sesión redirige a la página de inicio; y que cerrar sesión se rechaza desde otro sitio y va al endpoint de cierre de sesión con `id_token_hint`.

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

## Detrás de un proxy o balanceador de carga

Defina `APP_BASE_URL` como la URL pública de la aplicación; el URI de redirección y la comprobación de Origin la usan, nunca la cabecera `Host`. Si un proxy termina TLS, `APP_BASE_URL` sigue siendo la URL `https`, de modo que la cookie de sesión conserva `Secure`.

## Límites actuales

- No hay refresh token ni inicio de sesión silencioso: el usuario vuelve a iniciar sesión cuando caduca el token de acceso.
- El almacén de sesiones en archivos guarda los tokens en archivos de texto plano bajo `.sessions/` y no elimina los archivos caducados. Proteja ese directorio, o use un almacén compartido que cifre y caduque las sesiones.
- El cliente se autentica con un secreto de cliente. `openid-client` también admite `private_key_jwt`; el ejemplo no lo muestra.

## Próximos pasos

- [Proteger una API de Express](/docs/quickstarts/express): la API que llama esta aplicación.
- [Verificación de tokens](/docs/sdk/token-verification): el verificador bajo el middleware de su API.
