# WorkOS Connect en production : version pour agents IA

Version condensée, pour les agents IA, de l'article "Intégrer WorkOS Connect avec un client web public : retour d’expérience" : https://blog.qesle.com/workos-connect-en-production/

- Constats d'octobre et de début novembre 2026, faits en intégrant WorkOS Connect à QESLE, un outil web de réservation d'environnements. WorkOS évolue vite : revérifie chaque point avant de l'appliquer et signale à l'humain ce qui a changé.
- La source de chaque constat est entre crochets :
  - [sonde] : mesure sur une page de test jetable qui lit les jetons réels.
  - [essai] : essai sur l'environnement de test WorkOS.
  - [recette] : recette de production, avec deux comptes et une vraie boîte mail.
  - [support] : réponse du support WorkOS, absente de la documentation à cette date.
  - [configuration] : réglage posé sans mesure particulière.
  - [démo] : mise en ligne d'une démo publique, dans un second environnement WorkOS de type Production.

## Périmètre

Ces notes s'appliquent quand toutes ces conditions sont réunies :

- application web avec un client OIDC public, en Authorization Code avec PKCE, sans secret client.
- connexion par WorkOS Connect, ni SDK AuthKit ni session côté serveur.
- paramètre `resource` (RFC 8707) qui désigne l'API, repris dans `aud` [sonde].
- jetons gardés en mémoire seulement, renouvelés par jeton de rafraîchissement.
- API qui vérifie elle-même les JWT RS256 (signature, `iss`, `aud`, dates), sans SDK WorkOS et sans clé secrète WorkOS sur le serveur.

Hors de ce montage, certains points ne s'appliquent pas.

## Notion clé : l'application par défaut

Un environnement WorkOS contient une application AuthKit par défaut (liste *Applications*) et l'application Connect (section *Connect*). Plusieurs réglages qui gouvernent Connect se font sur l'application par défaut. Une seconde application AuthKit créée à côté n'a aucun effet sur Connect, même si elle affiche les mêmes valeurs [sonde].

Les réglages ne suivent pas d'un environnement à l'autre. La production démarre avec les valeurs d'origine : jeton d'accès de 5 minutes, 2 jours d'inactivité, session d'un an au plus [recette]. L'habillage se recopie, chaque réglage se refait. Un troisième environnement, celui de la démo publique, l'a confirmé (section 8) [démo].

| Réglage | Où | Source |
|---|---|---|
| Origines CORS de l'échange du code depuis le navigateur | Application par défaut, onglet *Sessions* | [support], puis [essai] et [recette] |
| Durée du jeton d'accès | Application par défaut, onglet *Sessions* | [sonde] |
| Inactivité et durée maximale de la session AuthKit | Application par défaut, onglet *Sessions* | [support] |
| Durée de l'autorisation de rafraîchissement Connect | Aucun réglage : ni durée maximale ni délai d'inactivité | [support] |
| Initiate login URI (liens d'invitation et de réinitialisation) | Application par défaut, onglet *Redirects* | [recette] |
| Redirect URI de la route de retour de l'Initiate login URI | Application par défaut, onglet *Redirects* | [recette] |
| Client public PKCE et URL de retour de la connexion | *Connect › Applications* ; une application de la liste *Applications* d'AuthKit ne convient pas | [configuration], puis [démo] |
| Ressource de l'API, reprise dans `aud` | *Connect › Configuration › Resource Indicators* | [sonde] |
| Claims ajoutés aux jetons | *Authentication › Features › JWT Template*, un seul modèle par environnement, appliqué aussi aux jetons Connect | [sonde] |
| Inscription fermée et liste d'attente | *Authentication › Features*, tout l'environnement | [recette] |
| Langue de repli des pages AuthKit | *Authentication › Features › Localization*, sinon langue du navigateur | [configuration] |
| Habillage des pages AuthKit | *Branding*, tout l'environnement, personnalisable par application | [configuration] |

Les noms des menus peuvent changer.

## 1. CORS de l'échange du code

- Symptôme : le préflight de l'échange du code contre un jeton réussit, puis la vraie réponse revient sans `Access-Control-Allow-Origin`.
- Cause : Connect lit les origines autorisées de l'application AuthKit par défaut [support]. L'échange direct a fonctionné dès l'origine déclarée [essai].
- À faire : déclarer l'origine du site sur l'application par défaut, onglet *Sessions*.
- À éviter : un relais serveur qui transmet l'échange à WorkOS. Il devient inutile une fois l'origine déclarée.

## 2. Écart d'horloge

- Symptôme : une connexion échoue au premier essai et réussit au second, un renouvellement échoue de temps en temps.
- Cause : le `iat` des jetons WorkOS arrive avec 1 à 2 secondes d'avance sur l'horloge du serveur et PyJWT refuse un `iat` futur [essai].
- À faire : tolérer 60 secondes d'avance sur `iat` et `nbf`, vérifier `exp` sans aucune tolérance.
- Piège : l'option `leeway` de PyJWT s'applique aussi à `exp`. Avec `leeway=60`, un jeton expiré reste accepté une minute. Désactiver les contrôles intégrés de `iat` et `nbf` et les refaire :

```python
import time
import jwt  # PyJWT

TOLERANCE = 60  # secondes, pour iat et nbf seulement

def verifier_jeton(jeton: str, cle_publique) -> dict:
    claims = jwt.decode(
        jeton,
        cle_publique,
        algorithms=["RS256"],
        audience=AUDIENCE_API,
        issuer=EMETTEUR_WORKOS,
        options={
            "require": ["exp", "iat"],
            "verify_iat": False,
            "verify_nbf": False,
        },
    )  # exp reste vérifié, sans tolérance
    limite = time.time() + TOLERANCE
    for nom in ("iat", "nbf"):
        if nom not in claims:
            continue
        valeur = claims[nom]
        if type(valeur) not in (int, float):
            raise jwt.InvalidTokenError(f"{nom} doit être un nombre")
        if valeur > limite:
            raise jwt.ImmatureSignatureError(f"{nom} est dans le futur")
    return claims
```

- Garder le contrôle du type : sans lui, un `iat` mal formé provoque une erreur interne au lieu d'un refus du jeton.

## 3. Trois durées distinctes

| Objet | Rôle | Réglage |
|---|---|---|
| Jeton d'accès | Présenté à l'API | Application par défaut, onglet *Sessions* [sonde] |
| Session AuthKit du navigateur | Reconnexion sans mot de passe | Inactivité et durée maximale, application par défaut, onglet *Sessions* [support] |
| Autorisation de rafraîchissement Connect | Obtenir de nouveaux jetons d'accès | Aucun : ni durée maximale ni délai d'inactivité [support] |

Conséquences :

- un onglet ouvert qui renouvelle ses jetons reste connecté au-delà de la durée maximale de session. Cette durée ne joue qu'au passage par la connexion, après un rechargement ou dans un nouvel onglet.
- la rotation des jetons de rafraîchissement n'y change rien.
- pour couper un accès, agir sur le compte ou sur l'autorisation (section 5).
- mesurer la durée réelle sur un jeton émis (`exp - iat`) : la valeur affichée par le tableau de bord ne suffit pas [sonde].

Choix de QESLE : jeton de 30 minutes, un jour d'inactivité, sept jours de session. Un jeton plus long espace les renouvellements mais prolonge d'autant l'accès d'un compte supprimé.

## 4. Déconnexion

- La découverte OIDC de l'environnement de test n'annonce ni `end_session_endpoint` ni `revocation_endpoint` [essai].
- L'API de déconnexion de session appelée avec le `sid` du jeton répond 200 sans rien fermer. Dans un jeton Connect, `sid` désigne un consentement d'application, pas une session [support].
- Pratique prise en charge [support] : effacer les jetons côté client, puis envoyer `prompt=login` à la connexion suivante pour redemander le mot de passe.
- QESLE n'envoie `prompt=login` qu'après un clic sur "Se déconnecter". Après un simple rechargement, la connexion suivante réutilise la session AuthKit sans demander de mot de passe.
- Limite à annoncer à l'humain : la session AuthKit reste ouverte et l'autorisation Connect n'est pas révoquée. Sur un poste partagé, c'est important. Le retour du formulaire de mot de passe ne prouve aucune révocation.
- Jetons en mémoire seulement : un rechargement perd la connexion, qui se rouvre sans mot de passe tant que la session AuthKit est valide. Ce choix ne protège pas d'un script injecté dans la page : prévoir une CSP stricte, `script-src 'self'`.

## 5. Retrait d'un accès

- Le tableau de bord propose la suppression d'un utilisateur, pas son blocage. Son onglet *Sessions* ne montre pas ses connexions Connect [essai].
- Une API révoque l'autorisation Connect, avec une clé secrète WorkOS [support]. Elle n'empêche pas une nouvelle connexion suivie d'une nouvelle autorisation. QESLE ne l'utilise pas, pour ne pas ajouter de clé secrète.
- Suppression du compte, observée en production [recette] :
  - l'API accepte encore le jeton d'accès déjà émis, jusqu'à son `exp`.
  - WorkOS refuse le renouvellement suivant : le client doit terminer la session, sans reconnexion automatique.
  - réinvitée, la personne obtient un nouveau compte WorkOS, donc un nouveau `sub` : l'application la voit comme une nouvelle personne, sans son ancien historique.
- Procédure de départ complète : supprimer le compte WorkOS, retirer la personne des droits de l'application, accepter le délai jusqu'à l'expiration du jeton en cours.

## 6. Invitations avec inscription fermée

Contexte : inscription fermée et liste d'attente activées (*Authentication › Features*).

- Symptômes [recette] :
  - le lien d'invitation ouvre l'application sans lancer la connexion.
  - une fois la connexion Connect lancée, l'invité voit la liste d'attente.
  - par "Déjà invité ? Connectez-vous ici", WorkOS lui demande un mot de passe qu'il n'a jamais créé. Le compte reste inactif et l'invitation en attente.
- Cause : l'invitation passe par l'application AuthKit par défaut et Connect ne la voit pas.
- Sans effet [recette] :
  - passer `invitation_token`, puis `context`, à l'autorisation Connect.
  - déclarer comme Initiate login URI une route du client qui lance directement la connexion Connect : l'invité arrive toujours sur la liste d'attente.
- Solution [recette], sans code serveur, sur l'application par défaut :
  1. Initiate login URI (onglet *Redirects*) : l'autorisation AuthKit de l'application par défaut, sur une seule ligne, avec le `client_id` de l'application par défaut (pas celui de l'application Connect) et `redirect_uri` encodée :

     ```text
     https://api.workos.com/user_management/authorize?client_id=<client_id de l'application par défaut>&provider=authkit&response_type=code&redirect_uri=https%3A%2F%2F<domaine>%2F<route-de-retour>
     ```

  2. Déclarer `https://<domaine>/<route-de-retour>` dans les Redirect URIs de l'application par défaut, sinon WorkOS affiche une page d'erreur.
  3. Laisser vides User invitation URL et Password reset URL.
  4. Côté client, la route de retour (`/connexion` dans QESLE) n'échange jamais le `code` reçu : elle lance la connexion Connect, qui trouve la session AuthKit ouverte et ne redemande pas le mot de passe.
- Une invitation déjà en attente fonctionne après ce réglage, sans renvoi.
- Les liens de réinitialisation du mot de passe passent par la même Initiate login URI.
- Reste connu : avec l'inscription fermée, un utilisateur déjà inscrit sans session réutilisable voit d'abord la liste d'attente et doit cliquer sur "Déjà invité ? Connectez-vous ici". Personne n'est bloqué.

## 7. Limites de débit et 429

- Limites d'AuthKit hébergé [support] :
  - 10 000 requêtes par minute.
  - 500 écritures par tranche de 10 secondes, échanges de code et renouvellements compris.
  - comptées par domaine AuthKit et par point de présence, pas par application ni par adresse source.
- Le parcours de connexion a probablement une protection plus fine, de règle inconnue : une dizaine d'essais de connexion et d'inscription ont suffi à obtenir `too_many_requests` pendant quelques minutes [essai].
- À faire face à un 429 [support] : suivre `Retry-After` s'il est présent, sinon espacer les essais de plus en plus, avec un peu d'aléa.
- Renouvellement : réessayer un échec passager (réseau, délai dépassé, 429, 5xx) quelques fois avec aléa, sans dépasser l'expiration du jeton. Un refus définitif comme `invalid_grant` termine la session tout de suite. QESLE fait quatre demandes au plus et sa bibliothèque OIDC n'expose pas `Retry-After`.
- Si tu pilotes un navigateur : n'enchaîne pas les essais sur les pages de connexion AuthKit, tu déclencherais cette protection.

## 8. Démo publique et second environnement de production

Contexte : démo publique sur `demo.qesle.com`, trois personnages partagés aux mots de passe affichés, données remises à zéro chaque nuit.

- Isolement : un environnement WorkOS distinct, avec son propre domaine AuthKit, donc son propre émetteur. L'API de production refuse tout jeton de la démo, et un mot de passe publié n'ouvre aucun compte de production.
- Type d'environnement : Production. Un environnement Staging n'est pas destiné au trafic public selon WorkOS (documentation `authkit/environments`).
- Application OAuth : la créer dans *Connect › Applications*. Une application créée dans la liste *Applications* d'AuthKit ne convient pas : seul l'identifiant client de l'application Connect fonctionne avec le parcours Connect [démo].
- Réglages à refaire dans le nouvel environnement : méthode de connexion, application Connect, ressource (*Connect › Configuration*), modèle JWT, origine CORS sur l'application par défaut (onglet *Sessions*), durées de session. Au premier contrôle, les quatre premiers manquaient [démo].
- `login_hint` : absent de la documentation de Connect, mais pris en compte par l'autorisation Connect. L'adresse est préremplie [démo].
- `prompt=login` obligatoire avec `login_hint` quand plusieurs comptes se partagent un navigateur. Sans lui, une session AuthKit encore ouverte reconnecte son compte sans formulaire, quel que soit le `login_hint` [démo].
- Avec oidc-client-ts : passer `login_hint` et `prompt` en arguments de `signinRedirect`, pas dans `extraQueryParams`, qui remplacerait celui des réglages du client au lieu de s'y ajouter.
- Sessions : `GET /user_management/users/{id}/sessions` ne liste pas les sessions ouvertes par le parcours Connect. `POST /user_management/sessions/revoke` sur cette liste ne ferme donc rien [démo]. Une session AuthKit survit jusqu'à sa durée maximale (un jour sur l'environnement de la démo). Pour une démo partagée, remettre le mot de passe et recréer les données à chaque remise à zéro. Aucun moyen de révoquer ces sessions n'est connu à cette date.

## Recette avant ouverture

- Côté serveur : certificats, redirections, en-têtes de sécurité, refus des jetons invalides, CSP limitée au site, à WorkOS et à l'outil de suivi d'erreurs.
- En production, avec deux comptes et deux navigateurs : connexion, invitations, renouvellement des jetons pendant un flux SSE ouvert, déconnexion puis reconnexion, suppression d'un compte suivie du refus de son renouvellement.
- Sur l'environnement de test, avec la sonde : durée réelle des jetons, échanges directs avec WorkOS.
- Invitation complète avant le jour J, avec une vraie boîte mail et l'inscription fermée : envoyer, suivre le lien, créer le mot de passe, arriver dans l'application.

## Si tu aides un humain à intégrer WorkOS Connect

1. Proposer d'abord une page de sonde jetable : connexion, lecture des jetons, renouvellement, rechargement, déconnexion. Elle sert pendant toute l'intégration : changer un réglage, puis lire le jeton suivant, sans chercher en même temps un problème dans l'application.
2. Pour chaque réglage, établir sur quelle application il s'applique avant de le changer.
3. Mesurer les jetons réels plutôt que de croire le tableau de bord.
4. Tester tôt le parcours le plus ordinaire : une invitation envoyée à une vraie boîte mail, avec l'inscription fermée, suivie jusqu'à la création du mot de passe. Une connexion qui fonctionne ne le prouve pas.
5. Traiter la production comme une installation neuve.
6. Écrire la procédure de départ d'un utilisateur dès le début : déconnexion locale, révocation, suppression du compte et expiration des jetons sont quatre choses différentes.
7. Pour une démo publique, proposer un environnement distinct de type Production, jamais Staging ni l'environnement de production.
8. Préciser à l'humain la date de ces constats et ce que tu as revérifié.
