Le cœur de la documentation

OAuth 2.0 + PKCE, expliqué comme si on ne l’avait jamais vu

Tout ce qui se passe derrière un simple opencode mcp auth ords-mcp — raconté avec des mots simples, puis illustré étape par étape et dans la config du lab.

Le problème de départ

Pourquoi OAuth existe ?

On veut qu’un agent IA (OpenCode) puisse exécuter des requêtes SQL sur Oracle au nom d’un utilisateur. Le problème : on ne veut ni donner le mot de passe Auth0 de l’utilisateur à l’agent, ni donner le mot de passe Oracle à l’agent (et encore moins le graver dans un fichier de config).

OAuth résout ça avec un contrat à trois : l’utilisateur prouve son identité une fois chez un tiers de confiance (Auth0) qui délivre alors un jeton ; l’agent présente ce jeton au serveur de données (ORDS), qui n’a plus qu’à vérifier la signature et les droits — sans jamais voir de mot de passe. C’est comme un badge d’entrée tamponné par la sécurité centrale : les portes n’ont pas besoin de votre passeport, elles vérifient le badge.

✅ Résultat concret dans le lab OpenCode affiche ords-mcp connected (OAuth). Chaque appel à la base passe par un JWT valide ~1 h, renouvelé automatiquement grâce au refresh token. Jamais de HR_MCP_RO/password dans OpenCode.

Le casting

Les 4 acteurs (les connaître = comprendre OAuth)

Dans toute discussion OAuth, se demander « qui joue quel rôle ? » répond à 80 % des questions. Les noms anglais entre parenthèses sont ceux des specs — ils reviennent partout.

🧑
resource ownerL’utilisateurLe propriétaire des données. Il se connecte à Auth0 (Google ou compte local).
🤖
clientOpenCodeL’application qui veut accéder aux données au nom de l’utilisateur.
🚪
serveur d’autorisationAuth0Vérifie l’identité, connaît les rôles, et signe les jetons d’accès.
🏦
serveur de ressourcesORDS + OracleDétient les données. N’accepte que les jetons valides signés par Auth0.
💡 La règle d’or Le client (OpenCode) ne voit jamais le mot de passe de l’utilisateur sur Auth0, et le serveur de ressources (ORDS) ne voit jamais le mot de passe Oracle. Les deux se font confiance par l’intermédiaire d’un objet que seul Auth0 sait fabriquer : le jeton d’accès.

Le vocabulaire

Les mots à connaître avant la séquence

Chaque terme correspond à une case du formulaire rempli plus bas. Rien de compliqué — c’est du vocabulaire de contrat.

TermeSignification simpleDans le lab
JWTUn « badge » signé : lisible par tous mais infalsifiable. En-tête + informations (claims) + signature.Le jeton d’accès émis par Auth0, signé RS256.
Access tokenLe badge d’entrée. Mis dans chaque requête pour prouver « qui je suis et mes droits ». Il expire (souvent 1 h).Envoyé à ORDS en en-tête Authorization: Bearer ….
Refresh tokenLe « renouvellement » : obtenir un nouveau badge sans redemander le mot de passe.Auth0 le délivre grâce au scope offline_access.
ClaimUne ligne d’information à l’intérieur du jeton.sub, aud, roles2, permissions…
Audience (aud)« À qui s’adresse ce badge ? » — évite qu’un jeton pris pour un service serve ailleurs.Doit valoir exactement http://127.0.0.1:8080/mcp (l’API Identifier Auth0).
Issuer (iss)« Qui a signé ? »https://<tenant>.auth0.com/.
Scope / permissionLe droit précis accordé (plus fin qu’un rôle).urn:oracle:dbtools:ords:mcpserver:all — « accès MCP ».
PKCEPreuve que la requête vient bien de l’appli qui a démarré la connexion — indispensable pour les applis « natives » sans secret.OpenCode est une app Native (pas de client secret), donc PKCE obligatoire.
Resource indicator (RFC 8707)Le client précise quelle API il veut joindre, via le paramètre resource=.OpenCode envoie resource=http://127.0.0.1:8080/mcp.
✅ Analogie — le vestiaire Auth0 est le vestiaire d’un théâtre. Vous montrez votre carte d’identité (login), le vestiaire vous donne un jeton numéroté (badge JWT) mentionnant vos droits (rôle). L’agent OpenCode garde ce jeton et le montre aux portes ORDS au lieu de redonner votre carte d’identité. Les portes lisent la signature et le rôle — pas besoin de connaître toute votre identité.
⚠️ L’erreur classique des débutants « Rôle » ≠ « permission ». Le rôle dit quelle base (POOL.FIN), la permission dit si on a le droit d’utiliser MCP en général. Il faut les deux, sinon ORDS répond 403 et OpenCode reste bloqué sur needs_auth.

Le film, pas à pas

La séquence OAuth 2.0 + PKCE en 10 étapes

C’est ce qui se passe quand on lance opencode mcp auth ords-mcp. Deux types d’échanges alternent : des redirections navigateur (l’utilisateur voit Auth0) et des échanges serveur→serveur en arrière-plan (OpenCode parle directement à Auth0 puis à ORDS).

  1. OpenCode (client)

    Prépare l’URL d’autorisation et ouvre le navigateur. Elle contient : l’identité du client (client_id), où renvoyer l’utilisateur (redirect_uri sur le port local 19876), ce qu’on demande (scope = permission MCP + offline_access), et surtout resource=http://127.0.0.1:8080/mcp qui dit « je veux un jeton pour cette API précise ». OpenCode génère aussi un code_challenge (le « C » de PKCE).

  2. Navigateur (via l’utilisateur) → Auth0

    L’utilisateur se connecte chez Auth0 (Google ou compte local). Auth0 vérifie le mot de passe / la session.

  3. Auth0 (serveur d’autorisation)

    Une Action post-login s’exécute : elle copie les rôles de l’utilisateur (POOL.HR, POOL.FIN…) dans une claim nommée roles2. Pourquoi pas roles ? Auth0 réserve ce nom et n’y mettra jamais une liste personnalisée.

  4. Auth0 → navigateur

    Auth0 redirige le navigateur vers http://127.0.0.1:19876/mcp/oauth/callback avec un code d’autorisation à usage unique en paramètre. Pas de jeton ici — juste un « billet » prouvant que l’utilisateur s’est bien connecté.

  5. OpenCode (en arrière-plan) → Auth0

    Le petit serveur local d’OpenCode attrape le code, puis appelle l’endpoint /oauth/token d’Auth0 en présentant : le code + le code_verifier PKCE (preuve que c’est bien la même appli qui a démarré) + la même redirect_uri. Comme l’appli est Native, aucun client secret n’est requis.

  6. Auth0 → OpenCode

    Auth0 répond avec deux jetons : l’access token (JWT signé RS256, ~1 h de validité, contient roles2, permissions, aud…) et le refresh token (grâce à offline_access). OpenCode les range dans ~/.local/share/opencode/mcp-auth.json. La page affiche « Authorization successful ».

  7. OpenCode → ORDS (chaque appel MCP)

    Dès qu’il faut exécuter un outil SQL, OpenCode appelle http://127.0.0.1:8080/mcp avec Authorization: Bearer <access token>. ORDS est un serveur MCP streamé : il reçoit des appels d’outils, exécute et renvoie les résultats en flux.

  8. ORDS (serveur de ressources) — porte 1

    Contrôle global : ORDS valide la signature du jeton via la clé publique Auth0 (JWKS), vérifie iss (Auth0), aud (doit être /mcp), et que la permission …mcpserver:all est présente (dans permissions et/ou scope). Sinon : 401 ou 403.

  9. ORDS → bon pool Oracle — porte 2

    Contrôle par pool : ORDS lit la claim roles2 et la compare aux pools déclarés. Pool mcp-hr exige POOL.HR, pool mcp-fin exige POOL.FIN. L’utilisateur ne voit que les pools correspondant à ses rôles — les autres renvoient 403.

  10. ORDS → Oracle

    En interne, ORDS se connecte à la base avec un utilisateur technique least-privilege (HR_MCP_RO, FIN_MCP_RO — lecture seule). Le SQL demandé est exécuté et le résultat remonte à OpenCode, donc à l’utilisateur.

💡 La subtilité à retenir (les 2 portes) OAuth n’ouvre pas « la base » d’un coup. Il y a deux contrôles successifs : 1) une porte globale (le jeton est-il valide et porte-t-il la permission MCP ?) puis 2) une porte par pool (le rôle roles2 correspond-il à ce pool ?). C’est ce deuxième filtrage qui fait que deux utilisateurs, avec le même OpenCode, voient des bases différentes.
⚠️ Et quand le jeton expire ? Sans rien demander à l’utilisateur, OpenCode prend le refresh token, appelle /oauth/token, et Auth0 rend un nouvel access token. C’est pour ça qu’on demande offline_access au tout début — sinon l’agent serait coupé au bout d’une heure.

Détail technique

L’URL d’authorize construite par OpenCode

À l’étape 1, OpenCode construit une URL équivalente à celle-ci :

https://<AUTH0_DOMAIN>/authorize
  ?response_type=code
  &client_id=<NATIVE_CLIENT_ID>
  &code_challenge=…
  &code_challenge_method=S256
  &redirect_uri=http://127.0.0.1:19876/mcp/oauth/callback
  &scope=urn:oracle:dbtools:ords:mcpserver:all offline_access
  &resource=http://127.0.0.1:8080/mcp
ParamètreDoit correspondre à
client_idL’application Native Auth0
redirect_uriLes Allowed Callback URLs de l’app (chemin exact /mcp/oauth/callback)
resourceL’API Identifier Auth0 exactement (sinon « Service not found »)
scopeLa permission API (+ offline_access si on veut le refresh token)
code_challenge / code_challenge_method=S256PKCE — prouve l’identité du client natif

Côté OpenCode (docs/OPENCODE.md)

Le jeton décodé, et comment le vérifier

Une fois connecté, on peut lire son jeton. Payload d’un utilisateur qui n’a que le rôle POOL.FIN avec la permission MCP :

{
  "iss": "https://dev-xxxx.us.auth0.com/",     // signé par Auth0 (trailing slash !)
  "aud": "http://127.0.0.1:8080/mcp",        // destiné à ORDS /mcp, exactement
  "sub": "google-oauth2|1023…",            // qui est l'utilisateur
  "roles2": ["POOL.FIN"],                  // rôle → quel(s) pool(s)
  "permissions": ["urn:oracle:dbtools:ords:mcpserver:all"],
  "scope": "urn:oracle:dbtools:ords:mcpserver:all offline_access"
}
ClaimRôle dans la décisionS’il manque / est faux
audORDS vérifie que c’est bien lui le destinataire.401, ou Auth0 « Service not found » dès le login si l’API Identifier ne correspond pas.
roles2Filtre les pools (comparé aux mcp.role).ORDS : « Missing Role Claim » → 401.
permissions / scopeAutorise MCP en général.403 Forbidden ; OpenCode reste en needs_auth malgré « Authorization successful ».

Inspecter le token depuis le terminal

python3 - <<'PY'
import json
from pathlib import Path
from jose import jwt  # pip install python-jose si besoin

path = Path.home() / ".local/share/opencode" / "mcp-auth.json"
data = json.loads(path.read_text())
at = data["ords-mcp"]["tokens"]["accessToken"]
c = jwt.get_unverified_claims(at)
print("sub        ", c.get("sub"))
print("aud        ", c.get("aud"))
print("roles2     ", c.get("roles2"))
print("permissions", c.get("permissions"))
print("scope      ", c.get("scope"))
PY
Jetons « bons » (lab qui marche)
aud → http://127.0.0.1:8080/mcp · roles2 → ['POOL.FIN'] · permissions → ['urn:oracle:dbtools:ords:mcpserver:all']

Ce que signifient les messages

MessageSignification
Navigateur : « Authorization successful »Auth0 a émis un code d’autorisation ; OpenCode l’a échangé contre des jetons.
CLI : « Unexpected status: needs_auth » puis DoneLes jetons existent mais ORDS les refuse encore (typiquement un 403 — permission ou rôle absent).
opencode mcp list → connected (OAuth)Jetons OK et ORDS les accepte. C’est le seul vrai critère de succès.
⚠️ Les JWT sont immuables Changer un rôle, une permission ou l’Action post-login dans Auth0 n’affecte pas un jeton déjà émis. Toujours refaire opencode mcp logout ords-mcp puis opencode mcp auth ords-mcp après un changement.

Fallback manuel (dev uniquement)

Si OAuth est bloqué, on peut coller un jeton valide en config :

// opencode.json
"oauth": false,
"headers": { "Authorization": "Bearer {env:ORDS_MCP_TOKEN}" }
# export ORDS_MCP_TOKEN='…' puis opencode

Préférer le vrai OAuth pour le lab.

Config de référence

La config OpenCode du lab

// opencode.json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "ords-mcp": {
      "type": "remote",
      "url": "http://127.0.0.1:8080/mcp",
      "enabled": true,
      "timeout": 120000,
      "oauth": {
        "clientId": "{env:AUTH0_CLIENT_ID}",
        "scope": "urn:oracle:dbtools:ords:mcpserver:all offline_access"
      }
    }
  }
}
ChampPourquoi
urlURL publique du MCP ; doit matcher l’API Identifier Auth0 et l’audience ORDS (resource=). Derrière un proxy : page reverse proxy.
clientIdClient ID de l’app Native, chargé depuis l’environnement.
Pas de clientSecretNative + PKCE (token_endpoint_auth_method=none).
scopeLa permission MCP + refresh ; éviter les scopes superflus.
timeoutLes outils SQL peuvent être lents au premier appel.
✅ Bien charger l’environnement OpenCode lit {env:AUTH0_CLIENT_ID} depuis les variables d’environnement : set -a && source .env && set +a avant de lancer la commande.