Featured image thumbnail for post 'Self-hosting SSO with Traefik (Part 2): OAuth2 Proxy '

Self-hosting SSO with Traefik (Part 2): OAuth2 Proxy

How to use Docker and Traefik to get started with reverse proxy authentication for services that don't natively support OAuth.

Joey Miller • Posted July 06, 2023




This guide is the second part in a multi-part series of guides:

Why do we need Reverse Proxy Auth?

In the first part of this guide, we covered setting up Keycloak. This gives us single sign-on (SSO) for services that can be configured to authenticate with Keycloak/OAuth2/SAML, etc. For services that don't support this, we need to additionally set up reverse proxy authentication.

An example of a service that may require this is a single-user service such as Pihole. In this case, we would disable the Pihole login page and rely on having the reverse proxy (Traefik) prevent unauthenticated users from accessing the service.

We will be configuring OAuth2 Proxy with Keycloak to accomplish this.

Why OAuth2 Proxy?

When using Traefik, it is common to use the service thomseddon/traefik-forward-auth instead of OAuth2 Proxy. Because of its simplicity, I strongly considered using it, but there were a couple of drawbacks.

thomseddon/traefik-forward-auth drawbacks:

For the reasons outlined above, OAuth2 Proxy was the service I decided to proceed with.

How OAuth2-Proxy works

When a user attempts to access a service, Traefik can be configured to call an endpoint to check if the user is authenticated. We will configure our routes with the Traefik ForwardAuth middleware to accomplish this. In this scenario that endpoint is provided by OAuth2 Proxy.

Note: Traefik ForwardAuth is a generic first-party middleware that is unrelated to the thomseddon/traefik-forward-auth service previously mentioned.

The reverse proxy will query OAuth2 Proxy to check if a user is authenticated. OAuth2 Proxy in turn validates the user authentication via an OAuth2 request with our auth provider (Keycloak).
Diagram of reverse proxy authentication flow.

Assumptions

I will be assuming you have followed the Traefik setup from the first part of this guide, and already have Keycloak working.

This will mean that your Traefik infrastructure is structured as follows:

  • Docker configuration located at docker-compose.yml
  • Traefik static configuration located at conf/traefik/traefik.yml
  • Traefik dynamic configuration directory located at conf/traefik/conf.d

I will be assuming you have set up SSL and are enforcing HTTPS for each proxy host. Otherwise, additional setup may be required - such as setting the environment variable OAUTH2_PROXY_COOKIE_SECURE=false for OAuth2 Proxy.

Setting up OAuth2-Proxy

First, we need to create a client in Keycloak. This will be used to allow OAuth2 Proxy to validate user authentication with Keycloak.

  1. Go to the Keycloak Administration Console

  2. Create a new client by going to Clients > Create client.

    • Leave Client type as OpenID Connect
    • Set Client ID to oauth2-proxy
    • Set Client authentication to On
    • Set Authentication flow to only Standard flow
    • Click Save
  3. From the Clients > oauth2-proxy > Credentials page, copy the Client secret (we will be using this below)

  4. From the Clients > oauth2-proxy > Settings page:

    • Set Valid redirect URIs to https://auth.example.com/oauth2/callback

    • Set Front-channel logout URL to https://auth.example.com/oauth2/sign_out

      • This makes sure that OAuth2-proxy single sign-out works. OAuth2-proxy will log itself out when a logout request is sent to our realm.

Add the following to your docker-compose.yml (in addition to the keycloak and traefik services we already added earlier on):

    oauth2proxy:
        # internal: oauth2proxy on port 4180
        image: quay.io/oauth2-proxy/oauth2-proxy:latest
        environment:
            OAUTH2_PROXY_HTTP_ADDRESS: '0.0.0.0:4180'
            OAUTH2_PROXY_COOKIE_SECRET: '< COOKIE SECRET >'
            OAUTH2_PROXY_COOKIE_DOMAINS: '.example.com' # Required so cookie can be read on all subdomains.
            OAUTH2_PROXY_WHITELIST_DOMAINS: '.example.com' # Required to allow redirection back to original requested target.
            # Configure to use Keycloak
            OAUTH2_PROXY_PROVIDER: 'oidc'
            OAUTH2_PROXY_CLIENT_ID: 'oauth2-proxy'
            OAUTH2_PROXY_CLIENT_SECRET: '< CLIENT SECRET >'
            OAUTH2_PROXY_EMAIL_DOMAINS: '*'
            OAUTH2_PROXY_OIDC_ISSUER_URL: 'https://auth.example.com/realms/master'
            OAUTH2_PROXY_REDIRECT_URL: 'https://auth.example.com/oauth2/callback'
            #
            OAUTH2_PROXY_COOKIE_CSRF_PER_REQUEST: true
            OAUTH2_PROXY_COOKIE_CSRF_EXPIRE: '5m'
            OAUTH2_PROXY_CUSTOM_TEMPLATES_DIR: "/templates"
            OAUTH2_PROXY_REVERSE_PROXY: true
        volumes:
            - ./conf/oauth2-proxy/templates:/templates:ro
        labels:
            - "traefik.enable=true"
            - "traefik.http.routers.route-authproxy.rule=(Host(`auth.example.com`) && PathPrefix(`/oauth2/`)) || (PathPrefix(`/oauth2/`))"
            - "traefik.http.services.route-authproxy.loadbalancer.server.port=4180"
        depends_on:
            - keycloak
        restart: unless-stopped

Make sure to:

  • Set OAUTH2_PROXY_CLIENT_SECRET to the Client secret value you copied from the Keycloak Administration Console
  • Set OAUTH2_PROXY_COOKIE_SECRET to a strong cookie secret you generated. See the OAuth2 Proxy docs for further instructions to help accomplish this.

Note: By default, OAuth2 Proxy requires that all users have their email field set and verified. This can be done for each user in the Keycloak Users page for the realm. If you would like to remove this requirement from OAuth2 Proxy, make sure you set the environment variables OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true and OAUTH2_PROXY_OIDC_EMAIL_CLAIM=sub.

Modifying the OAuth2 Proxy sign_in page

Currently, a Javascript hack is needed for correct sign_in page redirect behaviour.

In both cases, ensure OAuth2 Proxy is left configured with skip-provider-button as false.

Option A: Manual redirect

This solution works from a login button for example, but not if you want any unauthorized endpoint to start OIDC flow.

Outcome: When a user is unauthenticated, the user will be redirected to the OAuth2 Proxy sign-in page. The user will have to click the "Sign in with OpenID Connect" button to be taken to the Keycloak sign-in page.

A screenshot showing the OAuth2 Proxy sign_in page.
The OAuth2 Proxy sign_in page.

To accomplish this:

  1. Make a copy of the default OAuth2 Proxy sign_in page at conf/oauth2-proxy/templates/sign_in.html
  2. Add the following Javascript lines directly below the opening <script> tag:
(function() {
var inputs = document.getElementsByName('rd');
for (var i = 0; i < inputs.length; i++)
    inputs[i].value = window.location;
})();

Option B: Automatic redirect to Keycloak

Outcome: When a user is unauthenticated, the user will be automatically redirected to the Keycloak sign-in page.

To accomplish this, save the following at conf/oauth2-proxy/templates/sign_in.html:

{{define "sign_in.html"}}
<!DOCTYPE html>
<html lang="en" charset="utf-8">
  <head>
    <meta charset="utf-8">
    <title>Redirecting...</title>
    <script>
    window.location = "{{.ProxyPrefix}}/start?rd=" + encodeURI(window.location)
    </script>
  </head>
</html>
{{end}}

Configuring OAuth2 Proxy with Traefik

Then, let's configure Traefik. We will be hosting OAuth2 Proxy at /oauth2 at example.com and all subdomains (including auth.example.com). Replace example.com with your domain name.

Create the following Traefik dynamic config file in conf/traefik/conf.d. This will provide the middlewares that will be used on routes to ensure users are authenticated before being served pages.

conf/traefik/conf.d/traefik_dynamic_auth.yml

http:
  middlewares:
    oauth:
      chain:
        middlewares:
          - oauth-signin
          - oauth-verify

    oauth-verify:
        forwardAuth:
            address: "http://oauth2proxy:4180/oauth2/auth"
    oauth-signin:
        errors:
            service: route-authproxy@docker
            status: "401"
            query: "/oauth2/sign_in"

Configuring a service for reverse proxy auth

Now that we have configured OAuth2 Proxy we are ready to use it to provide authentication to our services.

To configure a service to use reverse proxy authentication, we need need to make some changes to the Docker labels for the service (which is using port 8080):

        labels:
            - "traefik.enable=true"
            - "traefik.http.routers.route-recipes.rule=Host(`whoami.example.com`)"
            - "traefik.http.services.route-recipes.loadbalancer.server.port=8080"
            - "traefik.http.routers.route-recipes.middlewares=oauth@file"

The traefik.http.routers.route-recipes.middlewares label tells Traefik to use the oauth middleware we defined earlier to check authentication via OAuth2 Proxy when accessing whoami.example.com. If we are not authenticated, we will be redirected to the login page. Make sure to have a unique router/service name (in this case route-recipes) for each service.

Configuring HTTP Header auth (optional)

Some multi-user services support expect the reverse proxy to pass the authenticated username/email in an HTTP header.

In addition to completing the above steps, add the following to the environment: section of oauth2proxy in your docker-compose.yml:

            OAUTH2_PROXY_SET_XAUTHREQUEST: true

Then, let's configure Traefik to pass the service a header to inform it of the logged-in user.

Add the following to the http.middlewares.oauth-verify.forwardAuth (underneath address:) in conf/traefik/conf.d/traefik_dynamic_auth.yml

            authResponseHeaders: "X-Auth-Request-Preferred-Username"
Dealing with services that expect a different header

Some services expect to provided a different header than X-Auth-Request-Preferred-Username. In this case, we can implement a middleware to rename this header before passing it onto the service.

Let's use the plugin tomMoulard/htransformation. Add the following lines to your static Traefik configuration at conf/traefik/traefik.yml:

experimental:
  plugins:
    htransformation:
      moduleName: github.com/tomMoulard/htransformation
      version: v0.2.7

Then lets add another block to middlewares: in conf/traefik/conf.d/traefik_dynamic_auth.yml (where REMOTE-USER is the HTTP header that the service is looking for to determine the logged-in user):

    transform-authheader:
      plugin:
        htransformation:
          Rules:
            - Header: X-Auth-Request-Preferred-Username
              Name: Header transformation
              Type: Rename
              Value: REMOTE-USER

Then we simply append this middleware to the service.

For example, you would update the Traefik route-recipes service from:

            - "traefik.http.routers.route-recipes.middlewares=oauth@file"

To:

            - "traefik.http.routers.route-recipes.middlewares=oauth@file,transform-authheader@file"

Restricting users from services

In the first part of this series, we covered some approaches to prevent user(s) from accessing an entire client. Since a single OAuth2 Proxy instance/client can provide authentication for many services - we need a different approach to restrict users on a per-service basis.

OAuth2 Proxy can support restricting members by role or group.

Keycloak already provides the necessary information (client scope) to OAuth2 Proxy for restricting users by role, but some additional configuration is needed for groups:

  1. Go to Client scopes > Create client scope and create a new Client Scope with the name groups.
  2. In the created Client Scope, go to the Mappers tab and click Configure a new mapper. Click Group Membership give it a name and set Token Claim Name to groups and add it. Tick Add to access token and Add to userinfo.
  3. Go to Clients > oauth2-proxy and in the Client scopes tab click Add client scope and add groups as Default.

Globally enforcing roles or groups

Globally enforcing a Realm role required by all users that attempt to authenticate through OAuth2 Proxy can be done by setting the environment variable OAUTH2_PROXY_ALLOWED_ROLES or OAUTH2_PROXY_ALLOWED GROUPS respectively.

For example. To require all users to be part of the superadmin realm role, add the following to the environment section of the superadmin service in your Docker compose file:

            OAUTH2_PROXY_ALLOWED_ROLES: 'superadmin'

Enforcing groups for specific service(s)

OAuth2 Proxy supports enforcing groups on a per-service basis by adding a query parameter to the /oauth2/auth location we set up earlier when "Configuring a service for reverse proxy auth".

This means we need to update or duplicate our oauth and oauth-verify middlewares in conf/traefik/conf.d/traefik_dynamic_auth.yml.

See the below example, we create new middlewares in conf/traefik/conf.d/traefik_dynamic_auth.yml that include the allowed_groups query parameter:

    oauth-superadmin:
      chain:
        middlewares:
          - oauth-signin
          - oauth-verify-superadmin

    oauth-verify-superadmin:
        forwardAuth:
            address: "http://oauth2proxy:4180/oauth2/auth?allowed_groups=superadmin"

Note: The oauth-signin middleware we created earlier on this guide is still used and should be left unchanged.

We would then update the Traefik route-recipes service to use the new middleware:

            - "traefik.http.routers.route-recipes.middlewares=oauth-superadmin@file"

What next?

We've now successfully set up an SSO implementation that will work with the majority of our services.

Some multi-user services such as Jellyfin that don't have OAuth or Header Auth support can still cause us headaches. In this case, I recommend adding LDAP to your implementation.

See: Self-hosting SSO (Part 3): Keycloak + LDAP


If you found this post helpful, please share it around:


Comments