Envoy Gateway : OIDC, JWT & Authorization

Protéger une application avec une SecurityPolicy OIDC via Envoy Gateway, et restreindre l'accès à un groupe spécifique avec JWT et authorization.

RV
Rémi Verchère
Platform & Cloud Native
9 min de lecture

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.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: mysecureapp
  namespace: mysecurens
spec:
  parentRefs:
    - kind: Gateway
      name: eg
      namespace: mysecurens
      sectionName: mysecureapp-https
  hostnames:
    - mysecureapp.gravitek.io
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: mysecureapp
          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 :

  # Endpoint interne du filtre OAuth2
  rules:
    - matches:
        - path:
            type: PathPrefix
            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 :

# SecurityPolicy
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: mysecureapp-oidc
  namespace: mysecurens
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: mysecureapp
  oidc:
    provider:
      issuer: https://sso.gravitek.io/realms/myrealm
    clientID: mysecureapp
    clientSecret:
      name: mysecureapp-client-secret   # Secret K8s contenant le client_secret
    redirectURL: https://mysecureapp.gravitek.io/oauth2/callback
    logoutPath: /oauth2/logout
    scopes:
      - openid
      - groups  # On se basera plus tard la-dessus pour l'authz
    cookieNames:
      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.

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 :

# SecurityPolicy
  jwt:
    providers:
      - name: keycloak
        issuer: "https://sso.gravitek.io/realms/myrealm"
        remoteJWKS:
          cacheDuration: 300s
          uri: "https://sso.gravitek.io/realms/myrealm/protocol/openid-connect/certs"
        extractFrom:
          cookies:
            - IdTokenMySecureApp

Ensuite, on configure le bloc authorization où par défaut tout est refusé, et on autorise uniquement les membres du groupe /admins :

  authorization:
    defaultAction: Deny
    rules:
      - name: allow-admin-group
        action: Allow
        principal:
          jwt:
            provider: keycloak
            claims:
              - name: groups
                valueType: StringArray
                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 :

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: mysecureapp-oidc
  namespace: mysecurens
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: mysecureapp
 
  oidc:
    provider:
      issuer: https://sso.gravitek.io/realms/myrealm
    clientID: mysecureapp
    clientSecret:
      name: mysecureapp-client-secret
    redirectURL: https://mysecureapp.gravitek.io/oauth2/callback
    logoutPath: /oauth2/logout
    scopes:
      - openid
      - groups
    cookieNames:
      idToken: "IdTokenMySecureApp"
 
  jwt:
    providers:
      - name: keycloak
        issuer: "https://sso.gravitek.io/realms/myrealm"
        remoteJWKS:
          cacheDuration: 300s
          uri: "https://sso.gravitek.io/realms/myrealm/protocol/openid-connect/certs"
        extractFrom:
          cookies:
            - IdTokenMySecureApp
 
  authorization:
    defaultAction: Deny
    rules:
      - name: allow-admin-group
        action: Allow
        principal:
          jwt:
            provider: keycloak
            claims:
              - name: groups
                valueType: StringArray
                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 😕 :

RBAC: 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) :

  oidc:
    # ...
    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 :

  jwt:
    providers:
      - name: keycloak
        # ...
        claimToHeaders:
          - claim: groups
            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.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysecureapp
  namespace: mysecurens
spec:
  replicas: 1
  selector: 
    matchLabels:
      app: mysecureapp
  template:
    metadata: 
      labels:
        app: mysecureapp
    spec:
      containers:
        - name: echo
          image: mendhak/http-https-echo
          env:
            - name: JWT_HEADER
              value: "Authorization"   # décode l'access token
          ports: 
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: mysecureapp
  namespace: mysecurens
spec:
  selector: 
    app: mysecureapp
  ports: 
    - port: 8080
      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 :

{
  "path": "/",
  "headers": {
    "host": "mysecureapp.gravitek.io",
    "x-forwarded-proto": "https",
    "x-request-id": "02d9c605-3391-48d7-ba70-9b7f95f0c0c2",
    // forwardAccessToken: true → l'access token en Bearer
    "authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI…",
    // posés par le filtre OIDC : access token + id_token
    "cookie": "AccessToken-10c7e844=eyJhbGc…; IdTokenMySecureApp=eyJhbGc…",
    // claimToHeaders : valeur du claim `groups`, encodée en base64
    "x-user-groups": "WyIvYWRtaW5zIl0="
  },
  "method": "GET",
  // JWT_HEADER=Authorization → l'access token décodé par l'echo
  "jwt": {
    "payload": {
      "iss": "https://sso.gravitek.io/realms/myrealm",
      "aud": "account",
      "azp": "mysecureapp",
      "scope": "openid groups",
      "groups": [
        "/admins"
      ]
    }
  }
}

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

© Gravitek. Tous droits réservés.

logo

Gravitek est une Société à taille humaine, guidée par la qualité de service et la construction d'une relation durable avec ses clients.