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 cookie IdToken mais 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 via extractFrom.cookies et 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 OIDC
  • authorization : 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) → jwtauthorization, 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.

Ordre des filtres Envoy : OIDC → JWT → Authorization

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.

Group Membership mapper

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).

Full group path

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 :

Flux d'authentification OIDC + JWT + Authorization

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 via jwt decode).
  • L'access token n'arrive dans Authorization: Bearer … que si forwardAccessToken: true → là JWT_HEADER=Authorization le décode automatiquement. C'est le cas d'usage idéal pour valider forwardAccessToken.

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'est forwardAccessToken: true qui l'a ajouté ; sans lui, ce header est absent.
  • x-user-groups: WyIvYWRtaW5zIl0= → c'est claimToHeaders ; 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 retrouve groups, c'est exactement le claim sur lequel la règle authorization a 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