Authentification native pour les agents
fremforge accepte trois modèles d’authentification OAuth2 / OIDC pour les appelants non humains (agents, scripts, assistants d’IA, runners CI, intégrations de fournisseurs). Tous les trois reposent sur la même liste d’autorisation de portées par tenant et la même chaîne d’audit que la connexion des utilisateurs : pas de chemin parallèle, pas de magasin de confiance séparé.
Le prérequis commun à tout flux impliquant l’IdP du client : enregistrez votre IdP comme source d’authentification OIDC sous Administration de l’organisation → SSO → Sources d’authentification, vérifiez le domaine, et configurez des listes d’autorisation de portées par application sur la page de modification de la source d’authentification, sous Exchangeable scopes. Le même enregistrement alimente la fédération de connexion des personnes : un client qui utilise déjà le SSO OIDC dispose donc déjà des fondations.
Note d’implémentation : les points de terminaison
/_app/api/v1/auth/token-exchangeet/_app/api/v1/oauth/tokensont servis par le plan de contrôle de fremforge ; ce ne sont pas des points de terminaison natifs de Forgejo. Les auteurs de SDK doivent ciblerhttps://frem.sh/_app/api/v1/*exactement comme documenté.
Les trois flux en un coup d’œil
| Flux | Spécification | Rôle de l’IdP du client | Idéal pour |
|---|---|---|---|
| 1. JWT bearer direct | — (informel, « Bearer JWT ») | Émet le JWT ; fremforge le vérifie | Les identités de charge de travail (Entra workload identity, compte de service Okta, compte de service Google) qui émettent déjà des JWT liés au tenant |
| 2. Échange de jetons (RFC 8693) | RFC 8693 | Émet le subject_token (+ un actor_token facultatif pour l’OBO) | Les workflows CI, les agents planifiés, les flux on-behalf-of (OBO) où l’audit doit nommer la personne et l’agent qui agit pour elle |
3. client_credentials (RFC 6749 §4.4) | RFC 6749 | Aucun : fremforge émet à la fois le client_id et le client_secret | Les applications qui n’ont pas d’IdP du tout, ou lorsque le client préfère que fremforge serve d’IdP |
Les trois flux produisent un jeton d’accès ffp_ de courte durée (≤ 1 heure), lié à un tenant et à un ensemble de portées. Le RBAC, la limitation de débit, l’émission d’audit et les contrôles d’isolation des tenants sont identiques à ceux d’un PAT créé par une personne.
Flux 1 : JWT de l’IdP du client utilisé directement comme bearer
L’agent présente directement le JWT émis par son IdP sous la forme Authorization: Bearer <jwt> sur n’importe quel point de terminaison /api/v1/*. fremforge vérifie le JWT par rapport à votre source d’authentification enregistrée, applique l’intersection des portées issue de l’autorisation d’application de la source, et renvoie la réponse : aucun aller-retour supplémentaire, aucun identifiant stocké.
Point de terminaison : n’importe quelle route frem.sh/_app/api/v1/*.
Configuration (une seule fois, par source d’authentification) :
- Administration de l’organisation → SSO → enregistrez une source OIDC pour votre IdP (ou réutilisez une source existante utilisée pour la connexion des personnes).
- Configurez des listes d’autorisation de portées par application sous Edit auth source → App grants. La liste d’autorisation associe une application de l’IdP (reconnue par la claim
azpouaud[0]du JWT) à un ensemble maximal de portées. - Administration de l’organisation → Accès programmatique → Gérer le bearer JWT et l’échange → activez Autoriser le JWT bearer direct sur la source.
- (IdP multi-tenants uniquement) Saisissez une assertion de claim qui lie la source à votre tenant :
{"tid": "<your-Entra-tenant-guid>"}pour Entra/common/,{"hd": "yourdomain.com"}pour Google Workspace, etc. Le formulaire d’administration refuse d’activer le bearer direct sur une URL d’émetteur multi-tenants sans assertions de claim.
Exigences sur le jeton :
isscorrespond à l’URL de la source enregistrée (normalisée sans tenir compte de la casse, barre oblique finale tolérée).audcorrespond soit au Remplacement de l’audience configuré sur la source, soit à l’audience canonique par tenanthttps://frem.sh/<your-tenant-slug>.expdans le futur,iatpas déraisonnablement dans le passé (jose applique une tolérance de décalage d’horloge de 30 secondes).- Toutes les
claim_assertionsconfigurées sur la source correspondent au contenu vérifié. - La clé de signature est résolue via la découverte OIDC + JWKS (en cache 10 minutes, contrôlable par le client via la rotation de clés standard de l’IdP).
Exemple (Entra workload identity) :
# 1. Acquire a JWT from your IdP (managed identity, federated identity,
# or workload-identity SDK) with aud=https://frem.sh/<your-slug>
ACCESS_TOKEN=$(az account get-access-token \
--resource https://frem.sh/acme \
--query accessToken -o tsv)
# 2. Call any fremforge API directly
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://frem.sh/_app/api/v1/orgs/acme/reposPropriétés de sécurité :
- Le JWT est l’identifiant. Aucune ligne de PAT n’est créée dans la base de données de fremforge. Révocation = laisser
expexpirer, OU retirer la source de l’IdP / désactiver le bearer direct (la requête suivante est rejetée). - Une liaison entre tenants est impossible par construction : une audience par tenant lie le JWT à un seul slug, et la table auth_sources impose une contrainte UNIQUE au niveau de la base de données qui empêche deux tenants d’enregistrer le même émetteur pour le bearer direct.
- Plafond de portées issu de l’autorisation par application de la source. Les portées qui engagent de l’argent ou la configuration (
billing:write,sso:write,policy:write,orgs:write) exigent des entrées explicites dans la liste d’autorisation par application ; elles ne sont JAMAIS accordées par une liste d’autorisation NULL.
Flux 2 : échange de jetons RFC 8693 (avec OBO)
L’agent présente un JWT émis par l’IdP comme subject_token et l’échange contre un PAT fremforge de courte durée (ffp_). Le paramètre actor_token permet la délégation on-behalf-of : l’agent agit pour une personne nommée. fremforge n’a pas d’identités d’agents : la personne est l’acteur de l’audit, et l’agent est un détail de chaque ligne d’audit.
Point de terminaison : POST frem.sh/_app/api/v1/auth/token-exchange.
Exemple (échange simple) :
curl -X POST https://frem.sh/_app/api/v1/auth/token-exchange \
-H "Content-Type: application/json" \
-d '{
"subject_token": "<customer-idp-jwt>",
"tenant_slug": "acme",
"scopes": ["orgs:read", "findings:write"],
"name": "ci-build-bot"
}'Exemple (OBO : l’agent agit pour le compte d’Alice) :
curl -X POST https://frem.sh/_app/api/v1/auth/token-exchange \
-H "Content-Type: application/json" \
-d '{
"subject_token": "<alice-idp-jwt>",
"actor_token": "<agent-idp-jwt>",
"tenant_slug": "acme",
"scopes": ["findings:read", "audit:read"]
}'Ne demandez que des portées que l’échange de jetons peut accorder : l’ensemble échangeable (orgs:read, orgs:write, audit:read, exports:read, exports:write, findings:read, findings:write, components:read, deployments:report, runners:read, runners:write, sboms:write, seats:read, seats:write, billing:read, billing:write, sso:read, sso:write, policy:read, policy:write), restreint par l’autorisation d’application de votre source d’authentification. Une portée hors de l’un ou de l’autre est écartée, et une requête dont il ne reste rien est refusée avec no-scopes-grantable. L’accès aux dépôts et aux tickets ne fait pas partie de l’ensemble de portées de l’API fremforge et ne peut donc pas être échangé.
Chaque événement d’audit écrit par le PAT créé nomme Alice comme actor, sous la forme oidc:idp:<alice-sub> (le sub vérifié du subject token). L’agent est un détail de la ligne : fields.agent vaut oidc:agent:<agent-sub> (le sub vérifié de l’actor token), et fields.via_token est l’identifiant du PAT créé. L’échange lui-même est enregistré sous api.token.exchange.obo, lui aussi avec Alice comme acteur et l’agent dans fields.agent. Les deux valeurs proviennent uniquement des JWT vérifiés ; rien dans le corps de la requête ne peut nommer ni changer la personne.
Sans actor_token, un échange depuis l’IdP du client nomme toujours la personne comme acteur, avec via_token et sans agent. Un échange OIDC de runner n’a pas de personne (un job CI n’en est pas une), si bien que ses événements gardent actor=token:<id>.
Les requêtes de conformité du type « chaque action que mon agent Anthropic a effectuée pour le compte d’Alice au dernier trimestre » filtrent le journal d’audit sur Alice et lisent fields.agent. Le filtre d’acteur porte sur l’acteur, la colonne on_behalf_of et fields.agent : filtrer sur oidc:agent:<agent-sub> trouve donc tout ce que cet agent a fait, pour n’importe quelle personne. Dans le journal d’audit de l’administration, le filtre porte sur une sous-chaîne et ces lignes portent une puce via un agent ; GET /orgs/{org}/audit?actor= fait une correspondance exacte. Les événements écrits avant le 2026-10-23 nommaient l’agent comme acteur et Alice dans on_behalf_of. La chaîne de hachage les couvre : ils gardent donc cette forme, s’affichent comme pour le compte d’Alice, et les mêmes filtres les trouvent.
Différence avec le flux 1 : l’échange de jetons crée un PAT stocké (qui peut donc porter un sous-ensemble explicite de portées et un nom personnalisé). Le flux 1 est éphémère. Choisissez l’échange lorsque l’agent effectuera de nombreux appels au cours d’une même exécution de workflow ; choisissez le bearer direct lorsque chaque appel est indépendant.
Flux 3 : client_credentials (fremforge comme IdP)
Pour les applications sans IdP, ou lorsque le client préfère un unique jeu d’identifiants géré dans fremforge. L’administrateur du tenant crée une paire (client_id, client_secret), l’application s’authentifie avec Authorization: Basic (ou un formulaire POST) et reçoit un PAT ffp_ de courte durée.
Configuration :
- Administration de l’organisation → Accès programmatique → Gérer le bearer JWT et l’échange → Créer un nouveau client (dans la section
client_credentials). - Choisissez un nom, un plafond de portées et une liste d’autorisation d’audiences facultative.
- Copiez immédiatement le
client_secret: il n’est affiché qu’une fois et ne l’est plus jamais ensuite.
Point de terminaison : POST frem.sh/_app/api/v1/oauth/token.
Exemple :
curl -X POST https://frem.sh/_app/api/v1/oauth/token \
-u "ffc_<client-id>:ffs_<client-secret>" \
-d "grant_type=client_credentials" \
-d "scope=findings:write runners:read"Réponse :
{
"access_token": "ffp_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "findings:write runners:read",
"audience": "https://frem.sh/acme"
}Rotation : à tout moment, cliquez sur le bouton Renouveler dans l’interface d’administration. Le secret actuel reste valide à côté du nouveau pendant 24 heures, pour que vous puissiez basculer votre configuration sans interruption. Au-delà de cette fenêtre de 24 heures, seul le nouveau secret permet de s’authentifier.
Révocation : cliquer sur Révoquer invalide le client ET révoque en cascade chaque jeton ffp_ qu’il a créé, en une seule transaction ; c’est sûr même si le pod de l’opérateur est tué en pleine révocation.
Ce que les trois flux ont en commun
| Propriété | Comment elle est appliquée |
|---|---|
| Isolation des tenants | Chaque jeton créé a un tenant_id ; requireTenantBinding rejette en 404 les appels entre tenants. Une contrainte UNIQUE au niveau de la base de données empêche qu’un même émetteur soit revendiqué par deux tenants pour le bearer direct. |
| Plafond de portées | Les trois font l’intersection avec un ensemble de portées échangeables contrôlé par l’opérateur ET la liste d’autorisation du client par IdP / par client. Les portées qui engagent de l’argent ou la configuration (billing:write, sso:write, policy:write, orgs:write) exigent des entrées explicites dans la liste d’autorisation ; elles ne sont jamais accordées par défaut. |
| Durée de vie | Tous les jetons créés sont plafonnés à 1 heure. Les clients qui veulent des identifiants de plus longue durée passent par la création dans l’interface d’administration (Personal Access Tokens). |
| Chaîne d’audit | Chaque création émet un audit_event avec actor, l’action et les portées. Pour un échange depuis l’IdP du client, l’acteur est la personne, et l’agent (pour l’OBO) est fields.agent ; chaque événement ultérieur écrit par le jeton porte la personne comme acteur, plus agent et via_token. Le hachage de la chaîne couvre fields_json, y compris les deux. |
| Limitation de débit | Les points de terminaison de jetons sont limités par IP. /auth/token-exchange et /oauth/token partagent un plafond de 30/min pour borner le coût CPU de la vérification des JWT face à des entrées factices. |
| Accessibilité des JWKS | Toutes les récupérations sortantes de JWKS et de documents de découverte passent par une passerelle de sortie restreinte : SSRF, IP privées et métadonnées cloud bloquées au niveau réseau. JWKS en cache 10 min. |
| Résidence dans l’UE | Le pipeline n’atteint jamais un point de terminaison Sigstore / Auth0 / Okta hébergé aux États-Unis : la vérification se fait entièrement dans l’empreinte T Cloud Public eu-de de fremforge. Le trafic sortant vers l’IdP du client passe uniquement par la récupération via le proxy anti-SSRF. |
Résumé du modèle de menaces
| Menace | Atténuation |
|---|---|
Un client enregistre une URL Entra multi-tenants /common/ sans claim_assertion tid | Le formulaire d’administration refuse ; la fonction isMultiTenantIssuerUrl() signale Entra (v1 + v2), Google Workspace, les clouds Microsoft Gov / Chine |
Deux tenants partagent une URL iss d’IdP | Index UNIQUE partiel sur (LOWER(RTRIM(iss,'/'))) WHERE bearer direct activé : le second enregistrement est rejeté au niveau de la base de données |
| JWT de l’IdP du client rejoué contre le mauvais tenant | Audience par tenant https://frem.sh/<slug> : un jeton émis pour aud=https://frem.sh/acme ne peut littéralement pas satisfaire la vérification d’audience du tenant initech |
client_secret divulgué | L’administrateur du tenant lance Révoquer → la transaction en cascade supprime le client ET chaque PAT qu’il a créé ; la rotation prévoit une fenêtre de 24 h à double secret pour un remplacement sans interruption |
| Claims usurpées dans l’actor_token OBO | jose vérifie la signature + iss + aud par rapport à la même source que le subject ; sub immuable, email/name jamais considérés comme fiables |
| Cache JWKS périmé après une rotation de clés de l’IdP | TTL strict de 10 min : borné sur le plan opérationnel |
| Nom d’hôte interne au réseau dans le document de découverte de l’IdP (DNS rebind / SSRF) | La passerelle de sortie restreinte bloque la récupération ; une garde https:// dans le code ajoute une défense en profondeur |
Référence rapide
| Chemin | Méthode | Authentification | Durée de vie | Corps |
|---|---|---|---|---|
/_app/api/v1/auth/token-exchange | POST | Le JWT EST l’authentification | 1h | subject_token (+ actor_token facultatif), tenant_slug, scopes, name |
/_app/api/v1/oauth/token | POST | Basic OU corps de formulaire | 1h | grant_type=client_credentials, client_id, client_secret, scope, audience |
/api/v1/* (tout) | tout | Authorization: Bearer <jwt or ffp_*> | selon exp / selon la ligne du PAT | — |
Voir aussi
- Configuration du SSO (OIDC) : enregistrez votre IdP à la fois pour la connexion des personnes et pour l’authentification des agents
- OIDC des workflows pour l’authentification cloud : la direction sortante (VM du runner → politiques de confiance AWS/GCP/Azure)
- Journal d’audit et chaîne : vérifiez ce que chaque agent a fait, en filtrant sur la personne ou sur l’agent
- Jetons d’API (PAT) : jetons de longue durée créés par des personnes pour la même surface
/api/v1/*; créez-les sous Paramètres utilisateur → Jetons d’API, et voir Authentication policy pour la durée de vie maximale et la politique de rotation