Ajouter la connexion à une application React Router

Ce démarrage rapide ajoute la connexion KeenAuth à une application React Router 8 en mode framework, avec le client OpenID Connect standard openid-client. Les loaders et les actions s'exécutent côté serveur : votre application est donc le client OAuth. Elle conserve chaque jeton dans une session côté serveur, et le navigateur ne reçoit que le HTML et un cookie d'identifiant de session signé. Vous ajoutez les routes de connexion, protégez une page, appelez une API protégée avec le jeton d'accès de l'utilisateur, vous déconnectez et testez l'application hors ligne.

Pour une application monopage sans serveur, consultez Ajouter la connexion à une application React monopage.

L'application expose les routes suivantes :

Route Rôle
GET / Page d'accueil publique : un bouton de connexion, ou le nom de l'utilisateur et un bouton de déconnexion
GET /dashboard Nécessite une session. Affiche l'utilisateur et les messages renvoyés par votre API
POST /login Démarre la connexion. Accepté uniquement depuis une page de cette application
GET /auth/callback Vérifie la réponse, échange le code et démarre la session
POST /logout Termine la session ici et chez KeenAuth. Accepté uniquement depuis une page de cette application

Avant de commencer

1. Installer

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

openid-client implémente la partie relying party d'OpenID Connect : la découverte, le flux à code d'autorisation avec PKCE, la vérification iss de la RFC 9207 et les contrôles de l'ID token. L'exemple épingle chaque paquet à une version exacte et installe depuis package-lock.json avec npm ci --ignore-scripts, de sorte qu'aucun script de paquet ne s'exécute pendant l'installation.

2. Configurer

L'application lit sa configuration uniquement depuis des variables d'environnement. Placez-les dans .env, que npm run dev et npm start chargent avec node --env-file=.env, et ne versionnez jamais ce fichier.

Variable Obligatoire Signification
KEENAUTH_ISSUER oui L'émetteur de votre environnement, en https
KEENAUTH_CLIENT_ID oui Le client_id de l'application web
KEENAUTH_CLIENT_SECRET oui Son secret client
APP_BASE_URL oui L'URL publique de l'application. L'URI de redirection est construite à partir d'elle. http uniquement pour localhost, 127.0.0.1 ou [::1]
SESSION_SECRET oui Au moins 32 caractères ; signe le cookie de session. Générez-en un avec openssl rand -base64 32
API_URL oui L'API appelée par l'application, par exemple http://localhost:8080
KEENAUTH_API_AUDIENCE oui L'identifiant de cette API dans KeenAuth

loadConfig dans app/lib/config.server.ts vérifie ces valeurs à la première requête. Une valeur manquante ou inutilisable interrompt la requête, et aucun message d'erreur ne contient de valeur :

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

Les fichiers qui se terminent par .server.ts n'atteignent jamais le bundle du navigateur ; React Router interrompt la compilation si une page les importe pour le navigateur.

3. Créer le client OpenID Connect

app/lib/keenauth.server.ts construit le client une seule fois à partir de la découverte (<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],
    })

Par défaut, openid-client fait confiance à un ID token qui provient directement du endpoint de jeton via TLS. enableNonRepudiationChecks vérifie en plus sa signature avec les clés publiées par votre environnement.

4. Conserver la session côté serveur

La session réside dans des fichiers sur le serveur ; le cookie ne contient qu'un identifiant de session signé :

  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 stockage de fichiers ne sert qu'un seul serveur. Avec plusieurs serveurs, écrivez un stockage de session avec createSessionStorage et un magasin partagé, comme Redis. N'utilisez pas le stockage de session par cookie pour les jetons : deux jetons peuvent dépasser la limite de 4 Ko d'un cookie, et le navigateur les conserverait.

5. Démarrer la connexion

Les routes sont listées dans 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;

La connexion est un formulaire qui envoie une requête POST vers /login :

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

L'action crée un nouveau state, un nouveau nonce et un nouveau verifier PKCE pour chaque connexion, et les conserve dans la session :

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 requête d'autorisation envoie le challenge S256 du verifier et l'identifiant de votre API comme resource (RFC 8707). Sans resource, le jeton d'accès n'est pas destiné à votre API, et celle-ci le rejette.

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

N'accepter le POST que depuis vos propres pages

React Router refuse un POST inter-site vers l'action d'une page, mais /login et /logout sont des routes de ressource (une action sans page), et pour celles-ci la vérification vous incombe. assertSameOrigin n'accepte le POST que si son en-tête Origin (ou, à défaut, Referer) désigne APP_BASE_URL :

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

6. Traiter le callback

Le callback retire d'abord la transaction de la session, de sorte qu'un state ne peut servir qu'une fois :

  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 vérifie le state et l'iss que votre environnement envoie dans chaque réponse d'autorisation (RFC 9207), échange le code avec le verifier PKCE et vérifie l'ID token : sa signature, son iss, son aud, son exp, son iat et le nonce de cette connexion. L'URI de redirection est construite à partir de APP_BASE_URL, jamais à partir de l'en-tête 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,
  });

Une vérification échouée répond 401 avec un code d'erreur fixe. Après une connexion réussie, l'application supprime l'ancienne session et conserve l'utilisateur et les jetons dans une nouvelle session :

  // 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. Protéger les pages et appeler votre API

Un loader qui nécessite un utilisateur appelle requireUser, qui redirige vers la page d'accueil en l'absence de session. L'exemple n'a pas de refresh token : lorsque le jeton d'accès expire, l'utilisateur se reconnecte.

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

Le loader appelle votre API avec le jeton d'accès comme bearer token. Le jeton reste sur le serveur ; la page ne reçoit que l'utilisateur et les messages.

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

8. Se déconnecter

La déconnexion est un POST depuis une page de cette application. L'action termine la session locale et envoie le navigateur vers le endpoint de déconnexion de votre environnement (OpenID Connect RP-Initiated Logout) avec id_token_hint, client_id et post_logout_redirect_uri, qui le ramène dans votre application :

  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. Exécuter l'application

Démarrez l'API du démarrage rapide Express (ou toute API qui accepte les jetons), puis lancez l'application :

npm run dev

Ouvrez http://localhost:5173, choisissez Sign in, connectez-vous chez KeenAuth et ouvrez le tableau de bord.

10. Tester sans réseau

Les tests n'ont besoin ni de réseau ni d'un compte KeenAuth. npm test compile l'application et exécute la compilation via le gestionnaire de requêtes de React Router. Un faux fetch joue le rôle de votre environnement (découverte, jeu de clés créé pour l'exécution des tests, endpoint de jeton) et celui de l'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

Les tests vérifient que la configuration est validée (et qu'aucune erreur ne contient de secret) ; que la connexion envoie PKCE S256, state, nonce et resource, et qu'elle est refusée depuis un autre site ou avec un GET ; que le code est échangé avec le verifier correspondant et l'authentification du client ; que le jeton d'accès est envoyé à l'API dans l'en-tête Authorization et jamais au navigateur ; qu'un state inconnu ou réutilisé, un iss absent ou incorrect, une erreur d'autorisation, ainsi qu'un ID token émis pour un autre client, avec un autre nonce ou signé par une autre clé, sont refusés sans session ; que la connexion démarre un nouvel identifiant de session ; qu'une page sans session redirige vers la page d'accueil ; et que la déconnexion est refusée depuis un autre site et se rend au endpoint de déconnexion avec id_token_hint.

La connexion complète dans un vrai navigateur nécessite un environnement KeenAuth. Elle ne fait pas partie des tests hors ligne.

Derrière un proxy ou un répartiteur de charge

Définissez APP_BASE_URL sur l'URL publique de l'application ; l'URI de redirection et la vérification de l'Origin l'utilisent, jamais l'en-tête Host. Si un proxy termine le TLS, APP_BASE_URL reste l'URL https, de sorte que le cookie de session reste Secure.

Limites actuelles

Étapes suivantes