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.

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

1. Instalar

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:

  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):

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:

  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:

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:

        <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:

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.

  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:

  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:

  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:

  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:

  // 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.

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.

    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:

  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:

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.

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" });
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

Próximos pasos