L’authentification gérée de Relyt One, désormais en préversion

Inscription, connexion, sessions et organisations pour les utilisateurs de votre application, exécutées à côté de votre branche et stockées dans votre propre base de données. Les jetons sont des JWT EdDSA de courte durée que vous vérifiez avec JWKS, et la sécurité au niveau des lignes fait le reste.

Zhan LuMis à jour le 30 octobre 20269 min de lecture
Sur cette page

L’authentification gérée est désormais disponible en préversion sur Relyt One. Activez-la sur une branche, et cette branche dispose de son propre service de connexion pour les utilisateurs de votre application : e-mail et mot de passe, GitHub et Google, sessions, organisations avec rôles et autorisations, et clés de signature que vous pouvez faire pivoter. Les utilisateurs vivent dans un schéma relyt_auth au sein de la base de données de la branche, votre backend vérifie des JWT de courte durée à l’aide d’une JWKS URL, et la sécurité au niveau des lignes (row-level security) de Postgres empêche un utilisateur de voir les lignes d’un autre.

L’activation prend environ une minute, et le SDK open source @relyt/auth ainsi qu’un exemple multi-utilisateur complet sont sur GitHub. Si vous voulez vous lancer directement, le guide pas à pas est Premiers pas avec l’authentification gérée de Relyt One.

Cet article explique pourquoi nous l’avons conçue ainsi.

L’authentification est la première chose dont toute application a besoin, et la première à dériver

Dès qu’une application stocke quoi que ce soit de personnel, il lui faut trois choses : un moyen pour les utilisateurs de se connecter, un moyen pour le backend de savoir qui appelle, et la garantie qu’un utilisateur ne voit jamais les données d’un autre.

La méthode habituelle consiste à choisir un fournisseur d’authentification, à le brancher sur votre application, puis à maintenir deux systèmes en accord pendant toute la vie de l’application. C’est là que les choses se compliquent.

  • Vos utilisateurs vivent ailleurs. Le fournisseur d’authentification a sa propre table d’utilisateurs, dans sa propre base de données, avec ses propres identifiants. Chaque clé étrangère reliant vos données à un utilisateur franchit une frontière entre systèmes. Joindre les « commandes » aux « utilisateurs » passe par un appel d’API ou une tâche de synchronisation.
  • Les environnements ne concordent pas. Votre base de données a une copie de production, une copie de staging et une branche par pull request. Votre fournisseur d’authentification a généralement un tenant de production et parfois un tenant de test. Les environnements de préversion finissent par connecter des utilisateurs de production, ou par s’appuyer sur un tenant vide qui ne correspond à aucune donnée de test.
  • L’autorisation n’est appliquée qu’à un seul endroit. Le fournisseur indique à votre API qui est l’appelant. Savoir si cet appelant peut voir une ligne donnée est vérifié dans le code applicatif, requête par requête. Oubliez un seul WHERE user_id = ... et vous avez une fuite de données.

Les agents de code accentuent ces trois problèmes. Un agent qui monte une nouvelle application branche l’authentification à la hâte, sur le tenant configuré quel qu’il soit, et vérifie les autorisations là où il y pense.

Une authentification qui vit sur la branche

L’authentification gérée part de la même idée que le reste de Relyt One : ce qui appartient à vos données doit vivre avec vos données.

Le navigateur se connecte sur l’URL d’authentification de la branche, obtient un JWT de courte durée et appelle votre API, qui le vérifie avec JWKS et définit app.user_id pour la sécurité au niveau des lignes dans la base de données où se trouve aussi relyt_auth

Lorsque vous activez l’authentification gérée sur une branche :

  • La branche obtient sa propre URL d’authentification. Vos utilisateurs s’y inscrivent et s’y connectent. C’est une origine distincte de votre application : le formulaire de connexion envoie directement ses données à cette URL, si bien que votre backend ne reçoit jamais de mot de passe et n’en stocke jamais.
  • Les utilisateurs, les sessions et les organisations sont stockés dans votre base de données, dans le schéma relyt_auth de cette branche. Ce sont des lignes que vous pouvez consulter en SQL, à côté des lignes qui leur appartiennent.
  • La branche obtient une JWKS URL qui publie les clés publiques de ses jetons. Votre backend n’a jamais besoin que de la moitié publique.
  • Les paramètres d’authentification appartiennent à la branche. Chaque branche a ses propres méthodes de connexion, origines et clés, qui sont supprimées avec la branche ou le projet. Une branche enfant d’une branche où l’authentification est activée démarre comme une copie : elle hérite des origines de confiance et du paramètre e-mail, et sa base de données contient une copie des utilisateurs finaux de la branche parente, tandis que les sessions et les clés de signature ne sont pas reprises.

Le service repose sur Better Auth : les endpoints de connexion et le modèle de session vous sembleront familiers si vous l’avez déjà utilisé.

Comment une requête est authentifiée

Le flux est le même pour toutes les applications, quel que soit le framework.

  1. Connexion sur l’URL d’authentification. Le navigateur envoie l’e-mail et le mot de passe (ou passe par GitHub ou Google) à l’URL d’authentification de la branche et reçoit en retour un jeton de session.
  2. Échange de la session contre un JWT. Le SDK échange la session contre un JSON Web Token valable 15 minutes, signé en EdDSA. Il met le jeton en cache et le renouvelle avant son expiration.
  3. Appel de votre API avec le JWT. Le navigateur envoie Authorization: Bearer <jwt>. Il ne vérifie jamais le jeton lui-même.
  4. Vérification sur le serveur. Votre API vérifie la signature à l’aide du JWKS de la branche, ainsi que l’émetteur, l’audience et l’expiration, et récupère l’identifiant de l’utilisateur dans sub.
  5. Application par Postgres. Votre API définit l’identifiant de l’utilisateur pour la transaction, et les politiques de sécurité au niveau des lignes décident quelles lignes cet utilisateur peut voir.

En code, les étapes 4 et 5 tiennent en quelques lignes :

import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";

export async function GET(req: Request) {
  let claims;
  try {
    claims = await getClaims(req, { issuer: process.env.RELYT_AUTH_ISSUER! });
  } catch (e) {
    // Impossible de récupérer les clés : 503, pour qu’une coupure réseau ne déconnecte pas tout le monde
    if (e instanceof RelytAuthUnavailableError) return new Response(null, { status: 503 });
    throw e;
  }
  if (!claims) return new Response(null, { status: 401 });

  const rows = await db.transaction(async (tx) => {
    await tx.execute(sql`select set_config('app.user_id', ${claims.sub}, true)`);
    return tx.select().from(notes); // uniquement les notes de cet utilisateur
  });
  return Response.json(rows);
}
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;

CREATE POLICY own_notes ON notes FOR ALL
  USING (owner_id = current_setting('app.user_id', true))
  WITH CHECK (owner_id = current_setting('app.user_id', true));

Le paramètre est local à la transaction : une connexion poolée ne le transmet donc jamais à la requête suivante. Si votre code oublie de le définir, current_setting ne renvoie rien et la politique ne correspond à aucune ligne. Une vérification manquante signifie aucun accès, jamais une fuite.

Ce que contient la préversion

Méthodes de connexion

  • E-mail et mot de passe.
  • GitHub et Google. Vous collez le Client ID et le Client Secret de votre application OAuth dans la console ; le secret est en écriture seule et n’est plus jamais affiché. La connexion via un réseau social entre origines utilise un code de transfert à usage unique avec PKCE, et fonctionne donc sans cookies tiers.

Redirections et origines

L’URL d’authentification ne répond qu’aux navigateurs provenant des origines de confiance que vous listez, comme https://app.example.com ou http://localhost:3000, sans caractères génériques. La connexion via un réseau social ne redirige que vers des URL de rappel enregistrées à l’identique. Les deux listes sont propres à chaque branche : une branche de préversion peut faire confiance à https://pr-128.example.dev sans que la production lui fasse confiance aussi.

Jetons et clés

  • Les JWT durent 15 minutes et sont signés en EdDSA. Le SDK n’accepte que EdDSA : les jetons signés avec tout autre algorithme sont rejetés.
  • Les clés de signature pivotent à la demande. Après une rotation, la nouvelle clé signe le jeton suivant, les anciennes clés restent dans le JWKS pendant 960 secondes (environ 16 minutes) pour que les jetons déjà émis restent vérifiables, et les nœuds edge récupèrent le nouveau jeu de clés en 30 secondes environ. Au plus trois anciennes clés sont conservées pendant cette période de grâce : toute nouvelle rotation pendant celle-ci est donc refusée jusqu’à ce qu’une clé expire. Les vérificateurs doivent récupérer à nouveau le JWKS lorsqu’ils rencontrent un identifiant de clé inconnu ; c’est ce que fait @relyt/auth.
  • Les sessions peuvent être révoquées pour un utilisateur ou pour tout le monde sur la branche. Une session révoquée ne peut plus être actualisée ni échangée contre de nouveaux jetons. Les JWT déjà émis restent valides jusqu’à leur expiration, 15 minutes au maximum : si l’accès doit cesser immédiatement, vérifiez-le aussi dans votre application.

Organisations, rôles et autorisations

Les utilisateurs peuvent créer des organisations, et les propriétaires et administrateurs peuvent inviter des membres via les endpoints d’organisation de l’URL d’authentification (la préversion n’envoie aucun e-mail d’invitation ni de vérification : une personne invitée doit donc se connecter avec un e-mail vérifié, c’est-à-dire aujourd’hui via GitHub ou Google). Chaque membre a un rôle, et vous définissez vos propres ressources et actions dans la console, par exemple une ressource todo avec create, read, update et delete, puis vous les accordez à des rôles. Les rôles intégrés sont owner, admin et member ; vous pouvez ajouter jusqu’à 16 rôles personnalisés et 32 ressources personnalisées.

Lorsqu’un utilisateur a une organisation active, le JWT la contient :

"o": {
  "id": "…",
  "slug": "acme",
  "role": "member",
  "perms": ["todo:create", "todo:read", "todo:update"]
}

Votre API vérifie o.perms pour l’opération demandée, et une politique RLS sur org_id décide quelles lignes d’équipe sont visibles. La liste des autorisations doit tenir dans le jeton : la console limite donc le total à 64 paires resource:action et à 1 KiB ; si elle ne tient pas, perms vaut null et votre API doit refuser plutôt que deviner.

L’exploitation

  • Users (Utilisateurs) affiche les utilisateurs finaux de la branche, si leur e-mail est vérifié, leur rôle, leurs sessions actives et leur dernière activité, avec une recherche dans la liste chargée.
  • Integration code (Code d’intégration) affiche les variables d’environnement et des extraits pour le middleware Next.js, les route handlers, le client navigateur et la connexion via un réseau social, préremplis avec les URL de la branche.
  • Disable auth (Désactiver l’authentification) arrête les connexions sans supprimer les utilisateurs, les paramètres ni les clés. Réactivez-la et tout est là où vous l’aviez laissé.

Un point de vigilance : BYPASSRLS

La sécurité au niveau des lignes ne vous protège que si votre application se connecte avec un rôle auquel elle s’applique. Les utilisateurs de base de données créés depuis la console Relyt One ont BYPASSRLS, et Postgres ignore silencieusement toutes les politiques pour eux. Pour toute application qui s’appuie sur RLS, créez un rôle applicatif dédié en SQL :

CREATE ROLE app_user LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEROLE NOCREATEDB;
\password app_user
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO app_user;

\password est une commande psql qui demande le mot de passe de façon interactive : il n’atterrit donc jamais dans l’historique de votre shell. Vérifiez ensuite le rôle avec SELECT rolbypassrls FROM pg_roles WHERE rolname = 'app_user';, qui doit renvoyer f. La suite de tests de l’application d’exemple contient un cas qui montre ce qui se passe quand on se trompe.

Limites de la préversion

L’authentification gérée est une préversion, et certaines choses sont volontairement limitées ou absentes le temps de comprendre comment elle est utilisée :

  • Seuls les propriétaires et administrateurs de l’organisation peuvent activer l’authentification et modifier ses paramètres.
  • Les branches enfants ne copient pas les identifiants de connexion via un réseau social, et une application OAuth GitHub ne peut enregistrer qu’une seule URL de rappel : utilisez une application OAuth par branche.
  • Aucun e-mail n’est encore envoyé : ni vérification, ni réinitialisation de mot de passe, ni invitation.
  • Les noms, les limites et la disposition de la console peuvent encore changer avant la disponibilité générale.

Essayez-la

  1. Ouvrez une branche dans la console, allez dans Auth (Authentification) et choisissez Enable auth (Activer l’authentification).
  2. Suivez Premiers pas avec l’authentification gérée de Relyt One pour ajouter la connexion, la vérification des jetons et RLS à une application.
  3. Ou clonez l’exemple TODO, qui fait tout cela avec des TODO personnels et d’équipe et une suite de tests.

Dites-nous ce qui ne fonctionne pas. La préversion est le bon moment pour changer les choses.

Essayer l’authentification gérée

ÉtiquettesAuthentificationPostgresSécurité

Rédigé par

Zhan Lu

Votre prochaine base de données n’est qu’à une branche.

Créez un projet gratuit en moins d’une minute. Sans carte bancaire, sans configuration.

Retour au blog