Le cœur de la documentation
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
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.
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
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.
Le vocabulaire
Chaque terme correspond à une case du formulaire rempli plus bas. Rien de compliqué — c’est du vocabulaire de contrat.
| Terme | Signification simple | Dans le lab |
|---|---|---|
| JWT | Un « badge » signé : lisible par tous mais infalsifiable. En-tête + informations (claims) + signature. | Le jeton d’accès émis par Auth0, signé RS256. |
| Access token | Le 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 token | Le « renouvellement » : obtenir un nouveau badge sans redemander le mot de passe. | Auth0 le délivre grâce au scope offline_access. |
| Claim | Une 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 / permission | Le droit précis accordé (plus fin qu’un rôle). | urn:oracle:dbtools:ords:mcpserver:all — « accès MCP ». |
| PKCE | Preuve 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. |
403 et OpenCode reste bloqué sur needs_auth.
Le film, pas à pas
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).
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).
L’utilisateur se connecte chez Auth0 (Google ou compte local). Auth0 vérifie le mot de passe / la session.
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.
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é.
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.
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 ».
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.
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.
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.
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.
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.
/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’é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ètre | Doit correspondre à |
|---|---|
client_id | L’application Native Auth0 |
redirect_uri | Les Allowed Callback URLs de l’app (chemin exact /mcp/oauth/callback) |
resource | L’API Identifier Auth0 exactement (sinon « Service not found ») |
scope | La permission API (+ offline_access si on veut le refresh token) |
code_challenge / code_challenge_method=S256 | PKCE — prouve l’identité du client natif |
Côté OpenCode (docs/OPENCODE.md)
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"
}
| Claim | Rôle dans la décision | S’il manque / est faux |
|---|---|---|
aud | ORDS 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. |
roles2 | Filtre les pools (comparé aux mcp.role). | ORDS : « Missing Role Claim » → 401. |
permissions / scope | Autorise MCP en général. | 403 Forbidden ; OpenCode reste en needs_auth malgré « Authorization successful ». |
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'] |
| Message | Signification |
|---|---|
| Navigateur : « Authorization successful » | Auth0 a émis un code d’autorisation ; OpenCode l’a échangé contre des jetons. |
| CLI : « Unexpected status: needs_auth » puis Done | Les 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. |
opencode mcp logout ords-mcp puis opencode mcp auth ords-mcp après un changement.
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
// 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"
}
}
}
}
| Champ | Pourquoi |
|---|---|
url | URL publique du MCP ; doit matcher l’API Identifier Auth0 et l’audience ORDS (resource=). Derrière un proxy : page reverse proxy. |
clientId | Client ID de l’app Native, chargé depuis l’environnement. |
Pas de clientSecret | Native + PKCE (token_endpoint_auth_method=none). |
scope | La permission MCP + refresh ; éviter les scopes superflus. |
timeout | Les outils SQL peuvent être lents au premier appel. |
{env:AUTH0_CLIENT_ID} depuis les variables d’environnement :
set -a && source .env && set +a avant de lancer la commande.