Premiers pas avec l’authentification gérée de Relyt One

Un guide pas à pas pour créer une application Next.js multi-utilisateur sur Relyt One. Activez l’authentification gérée sur une branche, connectez vos utilisateurs, vérifiez leurs JWT avec @relyt/auth et laissez la sécurité au niveau des lignes de Postgres séparer les lignes de chaque utilisateur.

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

Dans ce guide, nous construisons une petite API de notes multi-utilisateur sur Relyt One. Les utilisateurs s’inscrivent et se connectent sur l’URL d’authentification gérée de la branche, l’application vérifie leurs jetons avec @relyt/auth, et la sécurité au niveau des lignes (row-level security) de Postgres garantit que chaque utilisateur ne lit et n’écrit jamais que ses propres notes. Au passage, nous abordons les points qui posent habituellement problème : origines de confiance, émetteurs qui ne correspondent pas, rôles BYPASSRLS et connexions poolées.

Il vous faut un projet Relyt One, Node.js 20 ou version ultérieure, psql et une trentaine de minutes. Tout ce qui suit fonctionne aussi avec l’exemple TODO open source, qui applique le même modèle avec davantage de fonctionnalités (connexion via un réseau social, équipes et rôles, et une suite de tests). Si vous voulez comprendre pourquoi l’authentification gérée fonctionne ainsi, lisez d’abord L’authentification gérée de Relyt One, désormais en préversion.

Ce que nous construisons

Le navigateur se connecte sur l’URL d’authentification et obtient un JWT, l’API le vérifie avec JWKS et définit app.user_id, et RLS filtre la table notes

  • Une table notes avec un propriétaire par ligne, protégée par la sécurité au niveau des lignes.
  • Une route Next.js /api/notes qui liste et crée les notes de l’appelant. Elle n’accepte que Authorization: Bearer <jwt>.
  • Un utilitaire côté navigateur qui connecte un utilisateur sur l’URL d’authentification et appelle l’API avec un JWT récent.

Deux règles tiennent toute la conception, et chaque étape ci-dessous en découle :

  1. L’identifiant de l’utilisateur ne provient que d’un JWT vérifié. Jamais d’un en-tête, d’une chaîne de requête ou du corps de la requête.
  2. La base de données le vérifie aussi. L’API filtre par utilisateur, et RLS filtre une seconde fois : un WHERE oublié ne peut donc pas laisser fuiter les lignes de quelqu’un d’autre.

Étape 1 : activer l’authentification gérée sur une branche

Dans la console, ouvrez votre projet et choisissez la branche que votre application utilisera. Pendant le développement, une branche enfant de main est un bon choix : ses paramètres d’authentification, ses sessions et ses clés de signature lui sont propres, si bien que vos expériences ne touchent jamais les connexions de production. (Si vous créez la branche enfant après avoir activé l’authentification sur main, elle démarre avec une copie des origines de confiance et des utilisateurs finaux de main : considérez donc qu’elle contient de vraies données d’utilisateurs.)

Allez dans l’onglet Auth (Authentification) et choisissez Enable auth (Activer l’authentification). La première configuration réveille l’instance de calcul de la branche et crée les tables d’authentification ; cela prend environ une minute, alors gardez la page ouverte. Seuls les propriétaires et administrateurs de l’organisation peuvent effectuer cette opération.

Lorsque le statut passe à Ready (Prêt), la vue d’ensemble affiche deux URL :

  • Auth URL (URL d’authentification) : l’endroit où vos utilisateurs se connectent, et la valeur que votre backend attend comme émetteur des jetons.
  • JWKS URL : les clés publiques que votre backend utilise pour vérifier la signature des jetons.

Copiez les deux. Les utilisateurs, les sessions et les organisations sont désormais stockés dans le schéma relyt_auth de la base de données de cette branche.

Étape 2 : activer une méthode de connexion

Dans Sign-in methods (Méthodes de connexion), vérifiez que Email + password (E-mail et mot de passe) est activé (c’est le cas par défaut) et enregistrez. C’est tout ce dont ce guide a besoin.

GitHub et Google fonctionnent de la même façon une fois que vous avez une application OAuth : copiez l’URL de rappel affichée par la console (elle pointe vers l’URL d’authentification, par exemple <URL d’authentification>/api/auth/callback/github) dans les paramètres du fournisseur, puis collez le Client ID et le Client Secret dans la console et choisissez Save & enable (Enregistrer et activer). Le secret est en écriture seule. Une application OAuth GitHub ne peut enregistrer qu’une seule URL de rappel : utilisez une application OAuth par branche.

Étape 3 : faire confiance à l’origine de votre application

L’URL d’authentification est une origine différente de celle de votre application : le navigateur envoie donc une requête CORS chaque fois que votre application connecte quelqu’un. Le service d’authentification ne répond qu’aux origines que vous listez.

Dans Redirects & origins (Redirections et origines), ajoutez l’origine exacte de votre application à Trusted origins (Origines de confiance) :

  • http://localhost:3000 pendant le développement ;
  • https://app.example.com (ou l’adresse où vous déployez) par la suite.

Pas de chemin, pas de barre oblique finale, pas de caractères génériques. Si vous ajoutez plus tard la connexion via un réseau social, enregistrez aussi la page qui termine la connexion, par exemple http://localhost:3000/auth/callback, dans Callback URLs (URL de rappel). Les URL de rappel doivent correspondre à l’identique, et l’origine de chacune doit aussi être une origine de confiance.

Étape 4 : créer la table, la politique et un rôle applicatif

Connectez-vous à la branche en tant que propriétaire de la base de données (relytdb_owner sur la base relytdb, avec le mot de passe défini lors de la création du projet) et créez la table :

CREATE TABLE notes (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    text NOT NULL,
  body       text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notes_user_id_idx ON notes (user_id, created_at DESC);

user_id contient le claim sub du JWT de l’utilisateur, qui est un identifiant textuel.

Activez maintenant la sécurité au niveau des lignes et ajoutez une politique qui compare le propriétaire de chaque ligne à un paramètre que l’application définira pour chaque transaction :

ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;

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

Le rôle de chaque partie :

  • ENABLE active RLS ; FORCE l’applique aussi au propriétaire de la table, sauf si celui-ci a BYPASSRLS. Sur Relyt One, c’est le cas de relytdb_owner, qui voit donc toujours toutes les lignes (point suivant).
  • USING filtre les lignes qu’une requête peut voir, mettre à jour ou supprimer.
  • WITH CHECK rejette les insertions et mises à jour qui créeraient une ligne appartenant à quelqu’un d’autre.
  • current_setting('app.user_id', true) renvoie le paramètre, ou rien s’il n’a jamais été défini (le true signifie « ne pas lever d’erreur »). Lorsqu’il est absent, aucune ligne ne correspond : un code qui oublie de le définir voit une table vide au lieu des notes de tout le monde.

Se connecter avec un rôle soumis à RLS

C’est l’étape que l’on saute, et elle désactive silencieusement tout ce qui précède.

Les superutilisateurs et les rôles dotés de BYPASSRLS ignorent toutes les politiques. Les utilisateurs de base de données créés depuis la console Relyt One ont BYPASSRLS, tout comme relytdb_owner, le rôle propriétaire avec lequel démarre chaque projet. Utilisez le propriétaire pour créer les tables, jamais pour servir les requêtes. Si votre application se connecte avec l’un de ces rôles, la politique ne s’exécute jamais, et seul votre code applicatif sépare les utilisateurs.

Créez plutôt un rôle dédié à l’application, en SQL :

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

\password est une commande psql qui demande le mot de passe de façon interactive : il ne se retrouve donc jamais dans l’historique de votre shell ni dans les journaux du serveur. Vérifiez le résultat :

SELECT rolname, rolbypassrls, rolsuper FROM pg_roles WHERE rolname = 'notes_app';
--  rolname   | rolbypassrls | rolsuper
-- -----------+--------------+----------
--  notes_app | f            | f

Les deux colonnes doivent valoir f. Un rôle créé de cette façon se connecte aussi bien par la chaîne de connexion directe que par la chaîne poolée ; nous avons vérifié l’ensemble de cette étape sur une vraie branche, y compris le fait que la politique tient derrière le pooler.

Étape 5 : configurer l’application

Créez une application Next.js 15 et installez le SDK et le pilote Postgres (@relyt/auth/next prend en charge Next.js 14.2.35+ et 15.5.28+, mais pas encore la 16 : fixez donc la version) :

npx create-next-app@15 notes --ts --app
cd notes
npm install @relyt/auth pg
npm install -D @types/pg

Ajoutez les paramètres dans .env.local :

# L’Auth URL de l’étape 1 : une origine seule, sans barre oblique finale ni chemin.
RELYT_AUTH_ISSUER=https://<votre URL d’authentification>
RELYT_AUTH_JWKS_URL=https://<votre JWKS URL>

# La même URL d’authentification, pour le navigateur.
NEXT_PUBLIC_RELYT_AUTH_URL=https://<votre URL d’authentification>

# Se connecter en tant que notes_app, le rôle de l’étape 4, jamais avec un utilisateur créé depuis la console.
DATABASE_URL=postgresql://notes_app:<mot-de-passe>@<hôte>/relytdb?sslmode=require

L’émetteur doit être exactement l’origine de l’URL d’authentification, car les jetons portent cette valeur à la fois dans iss et dans aud, et le SDK la compare caractère par caractère. sslmode=require compte aussi : les connexions à Relyt One utilisent TLS, et node-postgres ne l’active pas si vous ne le demandez pas.

Étape 6 : un seul accès à la base de données

Placez tout accès à la base de données derrière une fonction unique qui ouvre une transaction, définit app.user_id à partir de l’identifiant d’utilisateur vérifié, et seulement ensuite exécute vos requêtes.

// lib/db.ts
import { Pool, type PoolClient } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

export async function withUser<T>(userId: string, fn: (db: PoolClient) => Promise<T>): Promise<T> {
  if (!userId) throw new Error("withUser: empty user id");
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // `true` = local à cette transaction : une connexion poolée ne le transmet jamais à la requête suivante.
    await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
    const result = await fn(client);
    await client.query("COMMIT");
    client.release();
    return result;
  } catch (e) {
    await client.query("ROLLBACK").catch(() => undefined);
    client.release(true); // abandonner une connexion peut-être cassée au lieu de la rendre au pool
    throw e;
  }
}

Le troisième argument de set_config compte plus qu’il n’y paraît. Sans lui, le paramètre dure toute la session, et avec un pool de connexions, la requête suivante qui emprunterait cette connexion s’exécuterait en tant qu’utilisateur précédent. Avec true, il disparaît au COMMIT ou au ROLLBACK.

Il en va de même derrière le pooling par transaction de PgBouncer, car le paramètre ne survit jamais à la transaction.

Étape 7 : vérifier le jeton à chaque requête

getClaims de @relyt/auth/next lit le jeton bearer, vérifie sa signature EdDSA à l’aide du JWKS, vérifie l’émetteur, l’audience et l’expiration, puis renvoie les claims, ou null si le jeton est absent ou non valide.

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

export type Caller = { ok: true; userId: string } | { ok: false; response: Response };

export async function requireUser(req: Request): Promise<Caller> {
  try {
    const claims = await getClaims(
      req,
      { issuer: process.env.RELYT_AUTH_ISSUER!, jwksUrl: process.env.RELYT_AUTH_JWKS_URL },
      { cookie: false }, // les routes d’API n’acceptent que les jetons bearer
    );
    if (!claims?.sub) {
      return { ok: false, response: Response.json({ error: "unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } }) };
    }
    return { ok: true, userId: claims.sub };
  } catch (e) {
    if (e instanceof RelytAuthUnavailableError) {
      // Impossible de récupérer les clés. Ne pas répondre 401 : cela déconnecterait tout le monde lors d’une coupure réseau.
      return { ok: false, response: Response.json({ error: "auth_unavailable" }, { status: 503, headers: { "retry-after": "2" } }) };
    }
    throw e;
  }
}

N’accepter que les jetons bearer (cookie: false) met l’API à l’abri du CSRF : un navigateur n’ajoute jamais de lui-même un en-tête Authorization à une requête intersite.

Étape 8 : l’API de notes

// app/api/notes/route.ts
import { requireUser } from "@/lib/auth";
import { withUser } from "@/lib/db";

export async function GET(req: Request) {
  const caller = await requireUser(req);
  if (!caller.ok) return caller.response;

  const notes = await withUser(caller.userId, async (db) => {
    const { rows } = await db.query(
      "SELECT id, body, created_at FROM notes WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100",
      [caller.userId],
    );
    return rows;
  });
  return Response.json(notes);
}

export async function POST(req: Request) {
  const caller = await requireUser(req);
  if (!caller.ok) return caller.response;

  const input = await req.json().catch(() => null);
  const body = typeof input?.body === "string" ? input.body.trim() : "";
  if (body.length < 1 || body.length > 2000) return Response.json({ error: "invalid_body" }, { status: 400 });

  const note = await withUser(caller.userId, async (db) => {
    const { rows } = await db.query(
      "INSERT INTO notes (user_id, body) VALUES ($1, $2) RETURNING id, body, created_at",
      [caller.userId, body],
    );
    return rows[0];
  });
  return Response.json(note, { status: 201 });
}

Remarquez que la requête contient toujours WHERE user_id = $1, même si RLS filtrerait de toute façon. Garder les deux est délibéré : la requête est rapide et explicite, et la politique est le filet de sécurité pour le jour où quelqu’un en écrira une qui ne l’est pas.

Remarquez aussi ce que le handler ne fait pas : il ne lit jamais d’identifiant d’utilisateur dans le corps ni dans la chaîne de requête. Le user_id qu’il écrit est celui du jeton.

Étape 9 : se connecter depuis le navigateur

Le navigateur parle à deux endroits : l’URL d’authentification, pour se connecter, et votre API, avec un JWT.

// lib/auth-client.ts
"use client";
import { createAuthClient } from "@relyt/auth";

const authUrl = process.env.NEXT_PUBLIC_RELYT_AUTH_URL!;
const KEY = "notes.session-token";

export const authClient = createAuthClient({
  authUrl,
  sessionToken: () => localStorage.getItem(KEY),
  onSessionToken: (t) => localStorage.setItem(KEY, t),
});

export async function emailAuth(mode: "sign-in" | "sign-up", email: string, password: string) {
  const body = mode === "sign-up" ? { email, password, name: email.split("@")[0] } : { email, password };
  const res = await fetch(`${authUrl}/api/auth/${mode}/email`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
    credentials: "omit",
  });
  if (!res.ok) throw new Error(`${mode} failed (HTTP ${res.status})`);
  const token = res.headers.get("set-auth-token");
  if (!token) throw new Error("No session token returned. Is this origin a trusted origin?");
  localStorage.setItem(KEY, token);
}

export async function api(path: string, init: RequestInit = {}) {
  const jwt = await authClient.getToken(); // en cache ; renouvelé avant son expiration
  const headers = new Headers(init.headers);
  headers.set("authorization", `Bearer ${jwt}`);
  return fetch(path, { ...init, headers });
}

export async function signOut() {
  const token = localStorage.getItem(KEY);
  if (token) {
    // Terminer la session sur l’URL d’authentification, pas seulement dans cet onglet
    await fetch(`${authUrl}/api/auth/sign-out`, { method: "POST", headers: { authorization: `Bearer ${token}` }, credentials: "omit" }).catch(() => undefined);
  }
  localStorage.removeItem(KEY);
  authClient.clear();
}

Comment les éléments s’articulent :

  • emailAuth envoie une requête POST à l’URL d’authentification. La réponse contient un jeton de session dans l’en-tête set-auth-token. Ce jeton est la session de l’utilisateur : quiconque le détient peut obtenir des JWT et appeler les endpoints de l’URL d’authentification au nom de cet utilisateur jusqu’à la fin de la session ; traitez-le donc comme un mot de passe.
  • authClient.getToken() échange le jeton de session contre un JWT (15 minutes, EdDSA) et le met en cache, en le renouvelant environ deux minutes avant son expiration.
  • api() envoie le JWT à votre API. Le navigateur ne vérifie jamais les jetons ; c’est le travail du serveur.

Conserver le jeton de session dans localStorage permet de garder ce guide court, mais tout ce qui y est stocké peut être lu par n’importe quel script exécuté sur votre page : une faille de cross-site scripting l’exposerait. Tenez les scripts tiers à l’écart des pages qui gèrent la connexion et utilisez une Content Security Policy stricte. L’exemple TODO montre une configuration plus robuste qui place aussi le JWT dans un cookie HttpOnly pour la navigation entre les pages.

Une page minimale pour essayer :

// app/page.tsx
"use client";
import { useState } from "react";
import { api, emailAuth } from "@/lib/auth-client";

export default function Home() {
  const [notes, setNotes] = useState<{ id: string; body: string }[]>([]);
  const load = async () => setNotes(await (await api("/api/notes")).json());

  return (
    <main>
      <form
        onSubmit={async (e) => {
          e.preventDefault();
          const f = new FormData(e.currentTarget);
          await emailAuth("sign-up", String(f.get("email")), String(f.get("password")));
          await load();
        }}
      >
        <input name="email" type="email" required />
        <input name="password" type="password" required />
        <button>Sign up</button>
      </form>
      <button onClick={async () => { await api("/api/notes", { method: "POST", body: JSON.stringify({ body: "hello" }) }); await load(); }}>
        Add note
      </button>
      <ul>{notes.map((n) => <li key={n.id}>{n.body}</li>)}</ul>
    </main>
  );
}

Lancez npm run dev, inscrivez deux utilisateurs dans deux profils de navigateur et ajoutez une note avec chacun. Chaque utilisateur ne voit que les siennes.

Étape 10 : prouver l’isolation depuis le terminal

Le constater dans un navigateur, c’est bien. Le voir refuser l’accès quand quelque chose manque, c’est mieux. Obtenez un JWT pour un utilisateur avec curl :

AUTH=https://<votre URL d’authentification>
ORIGIN=http://localhost:3000

SESSION=$(curl -s -D - -o /dev/null "$AUTH/api/auth/sign-in/email" \
  -H "origin: $ORIGIN" -H "content-type: application/json" \
  -d '{"email":"alice@example.com","password":"<password>"}' \
  | awk 'tolower($1)=="set-auth-token:" {print $2}' | tr -d '\r')

JWT=$(curl -s "$AUTH/api/auth/token" -H "authorization: Bearer $SESSION" | sed 's/.*"token":"\([^"]*\)".*/\1/')

curl -s http://localhost:3000/api/notes -H "authorization: Bearer $JWT"

Essayez ensuite ce qui doit échouer :

# Sans jeton : 401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes

# Un jeton altéré : 401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes -H "authorization: Bearer ${JWT}x"

Et vérifiez directement la part du contrat qui revient à la base de données. Connecté en tant que notes_app, sans définir app.user_id, la table est vide, quel que soit le nombre de notes existantes :

SELECT count(*) FROM notes;            -- 0
BEGIN;
SELECT set_config('app.user_id', '<sub d’Alice>', true);
SELECT count(*) FROM notes;            -- uniquement les notes d’Alice
COMMIT;

Si la première requête renvoie toutes les notes, votre application est connectée avec un rôle qui contourne RLS. Revenez à l’étape 4.

Aller plus loin

Connexion via un réseau social

startSocialSignIn envoie l’utilisateur vers GitHub ou Google via l’URL d’authentification, et completeSocialSignIn termine la connexion sur votre page de rappel à l’aide d’un code de transfert à usage unique et de PKCE, ce qui fonctionne sans cookies tiers. Le panneau Integration code (Code d’intégration) de la console contient les deux extraits, préremplis avec les URL de votre branche. Le README de l’exemple TODO liste les règles applicables à la page de rappel (elle doit exécuter completeSocialSignIn avant toute autre chose, et la connexion doit commencer et se terminer sur la même origine).

Équipes, rôles et autorisations

Lorsqu’un utilisateur a une organisation active, son JWT contient un claim o :

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

Définissez les ressources et les actions dans Roles & permissions (Rôles et autorisations) dans la console, vérifiez o.perms dans votre API avant chaque opération, et ajoutez une seconde politique RLS fondée sur une colonne org_id (définie par un autre set_config dans withUser) pour les lignes partagées. L’exemple TODO implémente exactement cela, avec toutes les décisions d’autorisation dans un seul module.

Dépannage

SymptômeCause probable
La connexion dans le navigateur échoue avec une erreur réseauL’origine de l’application ne figure pas dans Trusted origins (Origines de confiance), ou elle comporte un chemin ou une barre oblique finale
Chaque appel d’API renvoie 500, et le journal du serveur montre une erreur config de @relyt/authRELYT_AUTH_ISSUER n’est pas une origine seule : elle a une barre oblique finale ou un chemin, ou utilise http pour un hôte autre que localhost
Chaque appel d’API renvoie 401 avec un jeton qui semble valideRELYT_AUTH_ISSUER est une origine différente de celle qui a émis le jeton, par exemple l’URL d’authentification d’une autre branche
L’API renvoie 503Le serveur n’a pas pu récupérer le JWKS. Vérifiez l’accès réseau sortant du serveur vers la JWKS URL
La connexion à la base de données échoue ou reste bloquéeIl manque ?sslmode=require dans la chaîne de connexion
Les utilisateurs voient les lignes des autres lorsqu’une requête oublie son filtreL’application se connecte avec un rôle doté de BYPASSRLS (c’est le cas des utilisateurs créés depuis la console et de relytdb_owner). Utilisez le rôle de l’étape 4
Les requêtes ne renvoient aucune ligneapp.user_id n’a pas été défini dans la même transaction que la requête, ou la requête s’est exécutée en dehors de withUser
Une requête voit, de façon intermittente, les lignes d’un autre utilisateurset_config a été appelé avec false (pour toute la session) sur une connexion poolée. Utilisez true

Avant la mise en production

  • L’application se connecte avec un rôle NOBYPASSRLS dédié (pas relytdb_owner), et rolbypassrls vaut f.
  • Chaque table contenant des données d’utilisateurs a RLS activée et forcée, avec USING et WITH CHECK.
  • Chaque route handler appelle requireUser, même derrière un middleware.
  • Les identifiants d’utilisateur ne proviennent que de claims.sub.
  • La déconnexion appelle /api/auth/sign-out sur l’URL d’authentification, et aucun script tiers ne s’exécute sur les pages qui détiennent le jeton de session.
  • Une panne du JWKS renvoie 503, pas 401.
  • Les origines de production figurent dans Trusted origins (Origines de confiance), et les URL de rappel de production sont enregistrées à l’identique.
  • Si vous mettez vous-même le JWKS en cache, le cache dure moins de 960 secondes et vous le récupérez à nouveau lorsqu’un jeton porte un identifiant de clé inconnu, pour que les rotations de clés fonctionnent. (@relyt/auth fait déjà les deux.)
  • Vous savez que révoquer les sessions d’un utilisateur dans la console laisse les JWT déjà émis valides jusqu’à 15 minutes, et votre application gère les cas où cela compte.

Prochaines étapes

Créer un projet gratuit

ÉtiquettesAuthentificationPostgresSécuriténextjs

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