authenticationkeycloakopenid-connectoauth2-proxy

Need help understanding keycloak, reverse proxies, and middleware in a cluster


I need help understanding how things work in an authentication/authorization flow. I am referencing the specific technologies I'm currently using but am less looking for technology specific help (although that would be appreciated if able) and moreso looking for a general understanding.

I have a docker compose setup (also using kuberentes cluster in production, so either reference works). In that setup, I have Traefik as my reverse proxy. I have Keycloak as my identity provider. I want to set up middleware such that an ingress route can be protected by Keycloak. I decide to choose oauth2-proxy as that middleware. I attempt to setup oauth2-proxy. Here is some example code:

name: example
services:
  traefik:
    image: traefik:v2.10
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--entryPoints.web.address=:80"
      - "--entryPoints.websecure.address=:443"
      - "--providers.docker.exposedByDefault=false"
    ports:
      - "80:80"
      - "443:443"
      - "8082:8080" # Traefik dashboard
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - keycloak
      - oauth2-proxy

  keycloak:
    container_name: keycloak
    image: quay.io/keycloak/keycloak:26.0.2
    environment:
      KC_HTTP_RELATIVE_PATH: "/auth"
      KC_BOOTSTRAP_ADMIN_USERNAME: "admin"
      KC_BOOTSTRAP_ADMIN_PASSWORD: "admin"
    command: "start-dev"
    labels:
      - "traefik.enable=true"
      # keycloak will be available at localhost/auth
      - "traefik.http.routers.keycloak.rule=PathPrefix(`/auth`)"
      - "traefik.http.services.keycloak.loadbalancer.server.port=8080"

  oauth2-proxy:
    image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.0
    environment:
      - OAUTH2_PROXY_PROVIDER=keycloak-oidc
      - OAUTH2_PROXY_CLIENT_ID=${CLIENT}
      - OAUTH2_PROXY_CLIENT_SECRET=${CLIENT_SECRET}
      - OAUTH2_PROXY_EMAIL_DOMAINS=*
      - OAUTH2_PROXY_UPSTREAMS=static://202
      - OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
      - OAUTH2_PROXY_REDIRECT_URL=http://localhost/oauth2/callback
      - OAUTH2_PROXY_OIDC_ISSUER_URL=http://keycloak:8080/auth/realms/${REALM}
      - OAUTH2_PROXY_WHITELIST_DOMAIN=localhost
      - OAUTH2_PROXY_REVERSE_PROXY=true
      - OAUTH2_PROXY_OIDC_EXTRA_AUDIENCES=account,${CLIENT}
      - OAUTH2_PROXY_COOKIE_SECRET="12345678123456"
      - OAUTH2_PROXY_COOKIE_EXPIRE=5m
      - OAUTH2_PROXY_CODE_CHALLENGE_METHOD=S256
      - OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
    depends_on:
      - keycloak
    labels:
      - "traefik.enable=true"
      - "traefik.http.middlewares.oauth2-proxy.forwardauth.address=http://oauth2-proxy:4180"
      - "traefik.http.middlewares.oauth2-proxy.forwardauth.authResponseHeaders=Authorization"
      - "traefik.http.routers.oauth2-proxy.rule=PathPrefix(`/oauth2`)"
      - "traefik.http.services.oauth2-proxy.loadbalancer.server.port=4180"

  myapp:
    ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.myapp.rule=PathPrefix(`/`)"
      - "traefik.http.routers.myapp.entrypoints=web,websecure"
      - "traefik.http.services.myapp.loadbalancer.server.port=8080"
      - "traefik.http.routers.myapp.middlewares=oauth2-proxy@docker"

The first issue I run into is the issuer URL, and I've run into this problem trying other middlewares

- OAUTH2_PROXY_OIDC_ISSUER_URL=http://keycloak:8080/auth/realms/${REALM}

If I use it with keycloak:8080, oauth2-proxy connects to it no problem. But, when my user goes to access myapp at localhost/, they are redirected to a login with the url keycloak:8080, which of course they don't have access to.

Okay, so then if I make the issuer URL into localhost/auth/..., then oauth2-proxy fails to initialize because it now does not have access to that URL.

My first question is, how am I supposed to be resolving this issue when server/backend accesses the identity provider at a different URL than the user? If I used a 3rd party IDP, this would be no problem, but of course that's not the case. I feel like there's some crucial misunderstanding I'm having here. Is there a different way this is supposed to be setup, regardless of the middleware I use?

Continuing, I can eventually get it working by essentially forcing oauth2-proxy to use the correct URLS, here is a working setup for oauth2-proxy. I'm also adding some header passthrough, which relates to my next question.

    environment:
      - OAUTH2_PROXY_PROVIDER=keycloak-oidc
      - OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true
      # Client Side URL
      - OAUTH2_PROXY_LOGIN_URL=http://localhost/auth/realms/${REALM}/protocol/openid-connect/auth
      # Server Side URLs
      - OAUTH2_PROXY_REDEEM_URL=http://keycloak:8080/auth/realms/${REALM}/protocol/openid-connect/token
      - OAUTH2_PROXY_PROFILE_URL=http://keycloak:8080/auth/realms/${REALM}/protocol/openid-connect/userinfo
      - OAUTH2_PROXY_VALIDATE_URL=http://keycloak:8080/auth/realms/${REALM}/protocol/openid-connect/userinfo
      - OAUTH2_PROXY_OIDC_JWKS_URL=http://keycloak:8080/auth/realms/${REALM}/protocol/openid-connect/certs
      - OAUTH2_PROXY_CLIENT_ID=${CLIENT}
      - OAUTH2_PROXY_CLIENT_SECRET=${CLIENT_SECRET}
      - OAUTH2_PROXY_EMAIL_DOMAINS=*
      - OAUTH2_PROXY_UPSTREAMS=static://202
      - OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
      - OAUTH2_PROXY_REDIRECT_URL=http://localhost/oauth2/callback
      - OAUTH2_PROXY_OIDC_ISSUER_URL=http://localhost/auth/realms/${REALM}
      - OAUTH2_PROXY_WHITELIST_DOMAIN=localhost
      - OAUTH2_PROXY_REVERSE_PROXY=true
      - OAUTH2_PROXY_OIDC_EXTRA_AUDIENCES=account,${CLIENT}
      - OAUTH2_PROXY_COOKIE_SECRET="12345678123456"
      - OAUTH2_PROXY_COOKIE_EXPIRE=5m
      - OAUTH2_PROXY_CODE_CHALLENGE_METHOD=S256
      - OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
      # Headers
      - OAUTH2_PROXY_SET_AUTHORIZATION_HEADER=true
      - OAUTH2_PROXY_SET_XAUTHREQUEST=true
      - OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
      - OAUTH2_PROXY_PASS_USER_HEADERS=true

Great, now its in a working state, although there is LOTS of hardcoding URLs and no more use of the /.well-known endpoint. I don't love it. If I want to add additional middlewares (that, lets say, only allow certain groups/roles to access), I have to have another huge setup to make that work, just to have 1-2 lines different/added. Not very DRY- I'm leaning towards that I'm doing something wrong here.

Let's say I want to access the auth headers (this may be a oauth2-proxy specific question), even though I have anything I could find related to headers set to true, I cannot find any such headers when I go to my browser dev console > networks and check the headers.

Next question, how do I access (and even use) such headers? Are they somewhere I'm not looking? Are they encoded in the cookie? Let's say myapp is a simple frontend web application, but I want it to display the signed in user's email and/or username. How am I supposed to be using those? Diving a bit deeper, how trustworthy are those headers? For example, if I wanted to use the Authorization header for logic in myapp2, can I trust the values after validating the JWT?


Solution

  • Let's go through your questions 1 by 1:

    Lots of hardcoding URLs

    What's complicating things is that you're running on a single hostname (localhost) and using path prefixes to configure all the routes.

    If you ran your keycloak on a different domain, such as keycloak.franco.test then you'd be able to set the issuer url to that and be done.

    If you really want everything to run on a single domain, then we need to find some url we can assign to issuer_url that both the user and oauth proxy have access to.

    We can do this with DNS. Let's simulate this using /etc/hosts. We'll add an entry there:

    127.0.0.1 franco.test
    

    We're free to use .test because it's reserved for this purpose.

    Now you'll be able to access your app on franco.test in your browser like you would localhost. But oauth-proxy doesn't have access to it. So we do some DNS, we add franco.test to be an alias to the keycloak server on a specific docker network:

      keycloak:
        ...
        networks:
          mynetwork:
            aliases:
              - franco.test
    
      oauth2-proxy:
        ...
        networks:
          - mynetwork
    
    networks:
      mynetwork:
    

    Note you'll have to add all your containers to this network. You can also create multiple networks, in that case you'll have to tell traefik on which network to find the container using the label traefik.docker.network=mynetwork.

    Now your browser and oauth-proxy will be able to access keycloak with the same url and you can omit all those urls from your docker compose.

    Accessing the auth headers

    The auth headers are only present in requests between traefik and your app.

    This is the flow: Browser -> Traefik -> OAuth2 Proxy -> App

    What happens is that traefik receives the request, the forward auth you configured checks if there's an oauth session, and if so adds the auth headers. Then your app receives the auth headers and can do things based on that.

    After the request reaches your app, a response is sent back from your app and doesn't go through the oauth proxy. That response doesn't contain the auth headers, and you'll never see them in the browser dev console. Which is a good thing.

    So if you want to see the auth headers, you can log them from your app. Or if it's possible, configure traefik/oauth2-proxy to log the requests.

    Display the users email

    You could add a /api/me route. And in that route you grab whatever is in the auth headers and return the values you want. Like the email.

    You can also send it over cookies or just include them in your page if you're doing a traditional server rendered app. The point is that you have to do something in the app to make them available in the browser.

    The responsibility of the OAuth2 proxy is to deliver the auth headers to your app. What you do with them is up to you.

    Trustworthiness of the headers

    They are as trustworthy as your network security. In your app, you're trusting the oauth proxy to send you correct headers. But it doesn't have to actually be the oauth proxy that's sending the headers. It could be a malicious user who has access to your network.

    To illustrate, you can get the IP of your app container, and send any header you want from your local machine. Your app will accept it just fine.

    So this comes down to network security.

    You should limit the amount of services/containers that can access the ip of your app. Ideally just the oauth2 proxy would be able to send it requests.

    And you should configure the trusted proxy ip address in your app. And only accept requests from that ip address.