Dans la continuité de l'article sur la Gateway API, une des ressources Envoy Gateway avec laquelle j'ai joué est la SecurityPolicy. Depuis 1 seule ressource YAML, on peut brancher du vrai OIDC sur n'importe quelle HTTPRoute — sans déployer un oauth2-proxy à côté. Voici un exemple d'authentification avec Keycloak, et comment ajouter une notion d'authorization via filtrage par groupe.
Pour l'exemple, on va imaginer une app mysecureapp dans le namespace mysecurens, accessible sur mysecureapp.gravitek.io, et un Keycloak sur sso.gravitek.io.
💡 J'ai profité de cet article pour tester ma démo sur l'environnement Clever Cloud avec un cluster Kubernetes CKE et un add-on Keycloak. Je ne détaille pas le setup ici, peut-être dans un autre article 😉.
⚠️ Je n'explique pas non plus comment configurer Keycloak, ni comment exposer l'app via Gateway API, on suppose que vous avez déjà à disposition ces éléments.
Contexte : exposer l'app via Gateway API
La route applicative /
La HTTPRoute de base est classique : on route tout le trafic de mysecureapp.gravitek.io vers le service applicatif.
1apiVersion: gateway.networking.k8s.io/v1
2kind: HTTPRoute
3metadata:
4 name: mysecureapp
5 namespace: mysecurens
6spec:
7 parentRefs:
8 - kind: Gateway
9 name: eg
10 namespace: mysecurens
11 sectionName: mysecureapp-https
12 hostnames:
13 - mysecureapp.gravitek.io
14 rules:
15 - matches:
16 - path:
17 type: PathPrefix
18 value: /
19 backendRefs:
20 - name: mysecureapp
21 port: 8080
La route /oauth2 pour le callback OIDC
Pour que l'authentification OIDC fonctionne, la redirectURL et le logoutPath de la SecurityPolicy (qu'on verra juste après) doivent matcher une règle de la HTTPRoute. C'est mentionné dans la doc Envoy Gateway, en particulier la section "OIDC Authentication for a HTTPRoute" → "Create a SecurityPolicy". C'est aussi valable pour une policy attachée à une Gateway.
Ici, la route / ci-dessus couvre déjà /oauth2/callback — donc en théorie une règle dédiée n'est pas obligatoire. Mais dès qu'on remplace ce / par des préfixes spécifiques (ex: /myapp, /metrics), aucun ne couvre /oauth2 et la règle devient indispensable. Pour ne pas se mélanger les pinceaux, autant la déclarer explicitement dès le départ :
1 # Endpoint interne du filtre OAuth2
2 rules:
3 - matches:
4 - path:
5 type: PathPrefix
6 value: /oauth2
Pas besoin de backendRefs ici : ce path est entièrement géré en interne par le filtre OIDC d'Envoy (callback, échange de token, logout). Il faut juste que la HTTPRoute accepte de le router.
OIDC avec SecurityPolicy
Avant, avec ingress-nginx, ce type d'app tournait avec un oauth2-proxy déployé à côté, avec ses propres Ingress, sa conf, et son Deployment à maintenir. Avec Envoy Gateway, la SecurityPolicy fait exactement le même travail, sans déploiement supplémentaire :
1# SecurityPolicy
2apiVersion: gateway.envoyproxy.io/v1alpha1
3kind: SecurityPolicy
4metadata:
5 name: mysecureapp-oidc
6 namespace: mysecurens
7spec:
8 targetRefs:
9 - group: gateway.networking.k8s.io
10 kind: HTTPRoute
11 name: mysecureapp
12 oidc:
13 provider:
14 issuer: https://sso.gravitek.io/realms/myrealm
15 clientID: mysecureapp
16 clientSecret:
17 name: mysecureapp-client-secret # Secret K8s contenant le client_secret
18 redirectURL: https://mysecureapp.gravitek.io/oauth2/callback
19 logoutPath: /oauth2/logout
20 scopes:
21 - openid
22 - groups # On se basera plus tard la-dessus pour l'authz
23 cookieNames:
24 idToken: "IdTokenMySecureApp"
Le targetRefs pointe sur la HTTPRoute. Envoy intercepte toutes les requêtes, lance le flow OAuth2/OIDC si l'utilisateur n'est pas authentifié, et pose le cookie IdTokenMySecureApp avec l'id_token JWT une fois connecté.
➡️ Résultat : toute personne avec un compte valide dans le realm peut accéder à l'app. C'est déjà pas mal... mais on peut mieux faire !
💡 Pourquoi fixer
cookieNames.idToken? Ce champ est optionnel : sans lui, Envoy nomme le cookieIdTokenmais y ajoute un suffixe de hash dérivé du domaine (ex:IdToken-5671b67c), donc imprévisible. Or le filtre JWT relira ce cookie plus tard viaextractFrom.cookieset a besoin d'un nom stable. On l'épingle donc explicitement.
Authn ≠ Authz
L'OIDC, c'est de l'authentification : il vérifie qui vous êtes. Il ne dit rien sur ce que vous avez le droit de faire. Avec la SecurityPolicy ci-dessus, n'importe quel compte du realm peut se connecter 🙈.
Il faut alors de l'autorisation : restreindre ici l'accès aux membres d'un groupe spécifique. Envoy Gateway résout ça avec deux blocs supplémentaires dans la même SecurityPolicy :
jwt: pour extraire et valider le token depuis le cookie posé par le filtre OIDCauthorization: pour définir les règles d'accès à partir des claims du token
L'ordre des filtres générés par Envoy Gateway est oauth2 (OIDC) → jwt → authorization, le filtre OIDC s'exécute en premier. Une requête non authentifiée est donc interceptée et redirigée vers Keycloak avant que le filtre JWT ne soit évalué : ce dernier ne voit que des requêtes déjà authentifiées, qui portent l'id_token dans le cookie.
Filtrage par groupe avec JWT et authorization
Pour pouvoir vérifier les groupes d'un utilisateur, on va d'abord configurer le filtre JWT pour lire le cookie IdTokenMySecureApp et en extraire le claim groups :
1# SecurityPolicy
2 jwt:
3 providers:
4 - name: keycloak
5 issuer: "https://sso.gravitek.io/realms/myrealm"
6 remoteJWKS:
7 cacheDuration: 300s
8 uri: "https://sso.gravitek.io/realms/myrealm/protocol/openid-connect/certs"
9 extractFrom:
10 cookies:
11 - IdTokenMySecureApp
Ensuite, on configure le bloc authorization où par défaut tout est refusé, et on autorise uniquement les membres du groupe /admins :
1 authorization:
2 defaultAction: Deny
3 rules:
4 - name: allow-admin-group
5 action: Allow
6 principal:
7 jwt:
8 provider: keycloak
9 claims:
10 - name: groups
11 valueType: StringArray
12 values: ["/admins"]
Le claim groups est un tableau dans Keycloak, d'où le valueType: StringArray. Envoy vérifie que /admins est présent dans ce tableau — si oui, la requête passe. Sinon, 403.
Groupes Keycloak
Pour que le claim groups reflète l'appartenance aux groupes, il faut un mapper de type Group Membership sur le client scope.

Avec l'option Full group path activée sur le mapper Group Membership, le claim vaut /admins (avec le slash initial) ; désactivée, il vaut admins. Envoy fait une comparaison exacte : values: ["/admins"] ne matchera jamais un claim admins, et inversement. Un simple / de différence = 403. Choisissez une convention et gardez la même des deux côtés (le mapper Keycloak et le bloc authorization).

SecurityPolicy complète
En assemblant le tout, un seul objet qui fait l'authn et l'authz :
1apiVersion: gateway.envoyproxy.io/v1alpha1
2kind: SecurityPolicy
3metadata:
4 name: mysecureapp-oidc
5 namespace: mysecurens
6spec:
7 targetRefs:
8 - group: gateway.networking.k8s.io
9 kind: HTTPRoute
10 name: mysecureapp
11
12 oidc:
13 provider:
14 issuer: https://sso.gravitek.io/realms/myrealm
15 clientID: mysecureapp
16 clientSecret:
17 name: mysecureapp-client-secret
18 redirectURL: https://mysecureapp.gravitek.io/oauth2/callback
19 logoutPath: /oauth2/logout
20 scopes:
21 - openid
22 - groups
23 cookieNames:
24 idToken: "IdTokenMySecureApp"
25
26 jwt:
27 providers:
28 - name: keycloak
29 issuer: "https://sso.gravitek.io/realms/myrealm"
30 remoteJWKS:
31 cacheDuration: 300s
32 uri: "https://sso.gravitek.io/realms/myrealm/protocol/openid-connect/certs"
33 extractFrom:
34 cookies:
35 - IdTokenMySecureApp
36
37 authorization:
38 defaultAction: Deny
39 rules:
40 - name: allow-admin-group
41 action: Allow
42 principal:
43 jwt:
44 provider: keycloak
45 claims:
46 - name: groups
47 valueType: StringArray
48 values: ["/admins"]
Le diagramme ci-dessous résume le flux complet :
Et donc, après tout ce paramétrage, vous aurez un beau message si vous n'avez pas les droits nécessaires 😕 :
1RBAC: access denied
Sinon, si vous êtes dans le bon groupe, vous pourrez accéder à votre application 🥳 !
Allons plus loin : passer l'identité à l'application
Tout ce qu'on a vu jusqu'ici se passe dans la gateway : la décision authn/authz est prise par Envoy, et le backend mysecureapp ne voit jamais le token ni les groupes. C'est suffisant pour protéger l'accès, mais parfois l'app a elle-même besoin de savoir qui est connecté (afficher le nom de l'utilisateur, adapter l'UI selon les groupes, appeler une autre API en son nom...).
Deux champs optionnels permettent de transmettre cette info au backend.
forwardAccessToken — l'access token vers le backend
Dans le bloc oidc, forwardAccessToken: true demande à Envoy de transmettre l'access token OIDC au backend (dans le header Authorization) :
1 oidc:
2 # ...
3 forwardAccessToken: true
claimToHeaders — un claim dans un header
Dans le bloc jwt, claimToHeaders injecte la valeur d'un claim du token dans un header HTTP transmis au backend :
1 jwt:
2 providers:
3 - name: keycloak
4 # ...
5 claimToHeaders:
6 - claim: groups
7 header: x-user-groups
Le nom du header (x-user-groups) est ici libre : ce n'est ni un standard ni une convention Keycloak. L'app lira alors les groupes dans ce header sans avoir à décoder le JWT elle-même.
Valider le flux avec un backend "echo"
Pour vérifier concrètement ce qu'Envoy injecte (cookie, Authorization, headers custom), on peut s'appuyer sur l'application mendhak/http-https-echo. Avec la variable JWT_HEADER, il décode le JWT d'un header donné et l'ajoute, claims lisibles, dans sa réponse JSON.
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4 name: mysecureapp
5 namespace: mysecurens
6spec:
7 replicas: 1
8 selector:
9 matchLabels:
10 app: mysecureapp
11 template:
12 metadata:
13 labels:
14 app: mysecureapp
15 spec:
16 containers:
17 - name: echo
18 image: mendhak/http-https-echo
19 env:
20 - name: JWT_HEADER
21 value: "Authorization" # décode l'access token
22 ports:
23 - containerPort: 8080
24---
25apiVersion: v1
26kind: Service
27metadata:
28 name: mysecureapp
29 namespace: mysecurens
30spec:
31 selector:
32 app: mysecureapp
33 ports:
34 - port: 8080
35 targetPort: 8080
- L'id_token est dans le cookie
IdTokenMySecureApp→ l'echo l'affiche brut, non décodé (à passer dans jwt.io à la main, ou viajwt decode). - L'access token n'arrive dans
Authorization: Bearer …que siforwardAccessToken: true→ làJWT_HEADER=Authorizationle décode automatiquement. C'est le cas d'usage idéal pour validerforwardAccessToken.
Une fois authentifié en tant que membre du groupe autorisé — et avec les deux options activées (forwardAccessToken: true, claimToHeaders) — l'echo renvoie ceci :
1{
2 "path": "/",
3 "headers": {
4 "host": "mysecureapp.gravitek.io",
5 "x-forwarded-proto": "https",
6 "x-request-id": "02d9c605-3391-48d7-ba70-9b7f95f0c0c2",
7 // forwardAccessToken: true → l'access token en Bearer
8 "authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI…",
9 // posés par le filtre OIDC : access token + id_token
10 "cookie": "AccessToken-10c7e844=eyJhbGc…; IdTokenMySecureApp=eyJhbGc…",
11 // claimToHeaders : valeur du claim `groups`, encodée en base64
12 "x-user-groups": "WyIvYWRtaW5zIl0="
13 },
14 "method": "GET",
15 // JWT_HEADER=Authorization → l'access token décodé par l'echo
16 "jwt": {
17 "payload": {
18 "iss": "https://sso.gravitek.io/realms/myrealm",
19 "aud": "account",
20 "azp": "mysecureapp",
21 "scope": "openid groups",
22 "groups": [
23 "/admins"
24 ]
25 }
26 }
27}
Les trois mécanismes vus plus haut sont visibles d'un coup d'œil :
authorization: Bearer …→ c'estforwardAccessToken: truequi l'a ajouté ; sans lui, ce header est absent.x-user-groups: WyIvYWRtaW5zIl0=→ c'estclaimToHeaders; la valeur est le base64 du tableau JSON du claim (echo WyIvYWRtaW5zIl0= | base64 -d→["/admins"]). Envoy n'envoie pas la valeur brute mais la représentation JSON encodée.jwt.payload.groups→ l'echo a décodé l'access token (grâce àJWT_HEADER=Authorization) ; on y retrouvegroups, c'est exactement le claim sur lequel la règleauthorizationa statué pour laisser passer la requête.
Le cookie IdTokenMySecureApp, lui, contient l'id_token : c'est lui que le filtre JWT relit (via extractFrom.cookies) pour appliquer l'authorization, indépendamment de ce qui est transmis au backend.
Conclusion
Maintenant, avec une seule SecurityPolicy, on peut gérer l'authn OIDC, l'extraction JWT, l'authorization par claim, colocalisé avec la HTTPRoute. Plus de déploiement oauth2-proxy, plus de conf éparpillée. Plus simple, plus clair, j'aime bien !
Une fois le pattern en place, protéger une nouvelle app par groupe devient simple = un bloc oidc, un bloc jwt, une règle authorization, et go ! 🎉
Rendez-vous bientôt pour un prochain article autour d'Envoy Gateway et les Gateway API !
Ressources
- Envoy Gateway - OIDC : gateway.envoyproxy.io/docs/tasks/security/oidc — inclut le warning sur le matching
redirectURL/HTTPRoute - Envoy Gateway - JWT Authentication : gateway.envoyproxy.io/docs/tasks/security/jwt-authentication
- Envoy Gateway - JWT Claim-Based Authorization : gateway.envoyproxy.io/docs/tasks/security/jwt-claim-authorization — exemples avec
StringArrayet filtrage par claim
