1 - Writing Apps That Use Dex

Once you have dex up and running, the next step is to write applications that use dex to drive authentication. Apps that interact with dex generally fall into one of two categories:

  1. Apps that request OpenID Connect ID tokens to authenticate users.
    • Used for authenticating an end user.
    • Must be web based.
  2. Apps that consume ID tokens from other apps.
    • Needs to verify that a client is acting on behalf of a user.

The first category of apps are standard OAuth2 clients. Users show up at a website, and the application wants to authenticate those end users by pulling claims out of the ID token.

The second category of apps consume ID tokens as credentials. This lets another service handle OAuth2 flows, then use the ID token retrieved from dex to act on the end user’s behalf with the app. An example of an app that falls into this category is the Kubernetes API server.

Requesting an ID token from dex

Apps that directly use dex to authenticate a user use OAuth2 code flows to request a token response. The exact steps taken are:

  • User visits client app.
  • Client app redirects user to dex with an OAuth2 request.
  • Dex determines user’s identity.
  • Dex redirects user to client with a code.
  • Client exchanges code with dex for an id_token.

Dex flow

The dex repo contains a small example app as a working, self contained app that performs this flow.

The rest of this section explores the code sections which help explain how to implement this logic in your own app.

Configuring your app

The example app uses the following Go packages to perform the code flow:

First, client details should be present in the dex configuration. For example, we could register an app with dex with the following section:

staticClients:
- id: example-app
  secret: example-app-secret
  name: 'Example App'
  # Where the app will be running.
  redirectURIs:
  - 'http://127.0.0.1:5555/callback'

In this case, the Go code would be configured as:

// Initialize a provider by specifying dex's issuer URL.
provider, err := oidc.NewProvider(ctx, "https://dex-issuer-url.com")
if err != nil {
    // handle error
}

// Configure the OAuth2 config with the client values.
oauth2Config := oauth2.Config{
    // client_id and client_secret of the client.
    ClientID:     "example-app",
    ClientSecret: "example-app-secret",

    // The redirectURL.
    RedirectURL: "http://127.0.0.1:5555/callback",

    // Discovery returns the OAuth2 endpoints.
    Endpoint: provider.Endpoint(),

    // "openid" is a required scope for OpenID Connect flows.
    //
    // Other scopes, such as "groups" can be requested.
    Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
}

// Create an ID token parser.
idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: "example-app"})

The HTTP server should then redirect unauthenticated users to dex to initialize the OAuth2 flow.

// handleRedirect is used to start an OAuth2 flow with the dex server.
func handleRedirect(w http.ResponseWriter, r *http.Request) {
    state := newState()
    http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound)
}

After dex verifies the user’s identity it redirects the user back to the client app with a code that can be exchanged for an ID token. The ID token can then be parsed by the verifier created above. This immediately

func handleOAuth2Callback(w http.ResponseWriter, r *http.Request) {
    state := r.URL.Query().Get("state")

    // Verify state.

    oauth2Token, err := oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
    if err != nil {
        // handle error
    }

    // Extract the ID Token from OAuth2 token.
    rawIDToken, ok := oauth2Token.Extra("id_token").(string)
    if !ok {
        // handle missing token
    }

    // Parse and verify ID Token payload.
    idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
    if err != nil {
        // handle error
    }

    // Extract custom claims.
    var claims struct {
        Email    string   `json:"email"`
        Verified bool     `json:"email_verified"`
        Groups   []string `json:"groups"`
    }
    if err := idToken.Claims(&claims); err != nil {
        // handle error
    }
}

State tokens

The state parameter is an arbitrary string that dex will always return with the callback. It plays a security role, preventing certain kinds of OAuth2 attacks. Specifically it can be used by clients to ensure:

  • The user who started the flow is the one who finished it, by linking the user’s session with the state token. For example, by setting the state as an HTTP cookie, then comparing it when the user returns to the app.
  • The request hasn’t been replayed. This could be accomplished by associating some nonce in the state.

A more thorough discussion of these kinds of best practices can be found in the “OAuth 2.0 Threat Model and Security Considerations” RFC.

Consuming ID tokens

Apps can also choose to consume ID tokens, letting other trusted clients handle the web flows for login. Clients pass along the ID tokens they receive from dex, usually as a bearer token, letting them act as the user to the backend service.

Dex backend flow

To accept ID tokens as user credentials, an app would construct an OpenID Connect verifier similarly to the above example. The verifier validates the ID token’s signature, ensures it hasn’t expired, etc. An important part of this code is that the verifier only trusts the example app’s client. This ensures the example app is the one who’s using the ID token, and not another, untrusted client.

// Initialize a provider by specifying dex's issuer URL.
provider, err := oidc.NewProvider(ctx, "https://dex-issuer-url.com")
if err != nil {
    // handle error
}
// Create an ID token parser, but only trust ID tokens issued to "example-app"
idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: "example-app"})

The verifier can then be used to pull user info out of tokens:

type user struct {
    email  string
    groups []string
}

// authorize verifies a bearer token and pulls user information form the claims.
func authorize(ctx context.Context, bearerToken string) (*user, error) {
    idToken, err := idTokenVerifier.Verify(ctx, bearerToken)
    if err != nil {
        return nil, fmt.Errorf("could not verify bearer token: %v", err)
    }
    // Extract custom claims.
    var claims struct {
        Email    string   `json:"email"`
        Verified bool     `json:"email_verified"`
        Groups   []string `json:"groups"`
    }
    if err := idToken.Claims(&claims); err != nil {
        return nil, fmt.Errorf("failed to parse claims: %v", err)
    }
    if !claims.Verified {
        return nil, fmt.Errorf("email (%q) in returned claims was not verified", claims.Email)
    }
    return &user{claims.Email, claims.Groups}, nil
}

2 - Kubernetes Authentication Through Dex

Overview

This document covers setting up the Kubernetes OpenID Connect token authenticator plugin with dex. It also contains a worked example showing how the Dex server can be deployed within Kubernetes.

Token responses from OpenID Connect providers include a signed JWT called an ID Token. ID Tokens contain names, emails, unique identifiers, and in dex’s case, a set of groups that can be used to identify the user. OpenID Connect providers, like dex, publish public keys; the Kubernetes API server understands how to use these to verify ID Tokens.

The authentication flow looks like:

  1. OAuth2 client logs a user in through dex.
  2. That client uses the returned ID Token as a bearer token when talking to the Kubernetes API.
  3. Kubernetes uses dex’s public keys to verify the ID Token.
  4. A claim designated as the username (and optionally group information) will be associated with that request.

Username and group information can be combined with Kubernetes authorization plugins, such as role based access control (RBAC), to enforce policy.

Configuring the OpenID Connect plugin

Configuring the API server to use the OpenID Connect authentication plugin requires:

  • Deploying an API server with specific flags.
  • Dex is running on HTTPS.
    • Custom CA files must be accessible by the API server.
  • Dex is accessible to both your browser and the Kubernetes API server.

Use the following flags to point your API server(s) at dex. dex.example.com should be replaced by whatever DNS name or IP address dex is running under.

--oidc-issuer-url=https://dex.example.com:32000
--oidc-client-id=example-app
--oidc-ca-file=/etc/ssl/certs/openid-ca.pem
--oidc-username-claim=email
--oidc-groups-claim=groups

Additional notes:

  • The API server configured with OpenID Connect flags doesn’t require dex to be available upfront.
    • Other authenticators, such as client certs, can still be used.
    • Dex doesn’t need to be running when you start your API server.
  • Kubernetes only trusts ID Tokens issued to a single client.
  • If a claim other than “email” is used for username, for example “sub”, it will be prefixed by "(value of --oidc-issuer-url)#". This is to namespace user controlled claims which may be used for privilege escalation.
  • The /etc/ssl/certs/openid-ca.pem used here is the CA from the generated TLS assets, and is assumed to be present on the cluster nodes.

Deploying dex on Kubernetes

The dex repo contains scripts for running dex on a Kubernetes cluster with authentication through GitHub. The dex service is exposed using a node port on port 32000. This likely requires a custom /etc/hosts entry pointed at one of the cluster’s workers.

Because dex uses CRDs to store state, no external database is needed. For more details see the storage documentation.

There are many different ways to spin up a Kubernetes development cluster, each with different host requirements and support for API server reconfiguration. At this time, this guide does not have copy-pastable examples, but can recommend the following methods for spinning up a cluster:

To run dex on Kubernetes perform the following steps:

  1. Generate TLS assets for dex.
  2. Spin up a Kubernetes cluster with the appropriate flags and CA volume mount.
  3. Create secrets for TLS and for your GitHub OAuth2 client credentials.
  4. Deploy dex.

Generate TLS assets

Running Dex with HTTPS enabled requires a valid SSL certificate, and the API server needs to trust the certificate of the signing CA using the --oidc-ca-file flag.

For our example use case, the TLS assets can be created using the following command:

$ cd examples/k8s
$ ./gencert.sh

This will generate several files under the ssl directory, the important ones being cert.pem ,key.pem and ca.pem. The generated SSL certificate is for ‘dex.example.com’, although you could change this by editing gencert.sh if required.

Configure the API server

Ensure the CA certificate is available to the API server

The CA file which was used to sign the SSL certificates for Dex needs to be copied to a location where the API server can read it, and the API server configured to look for it with the flag --oidc-ca-file.

There are several options here but if you run your API server as a container probably the easiest method is to use a hostPath volume to mount the CA file directly from the host.

The example pod manifest below assumes that you copied the CA file into /etc/ssl/certs. Adjust as necessary:

spec:
  containers:

    [...]

    volumeMounts:
    - mountPath: /etc/ssl/certs
      name: etc-ssl-certs
      readOnly: true

    [...]

  volumes:
   - name: ca-certs
     hostPath:
       path: /etc/ssl/certs
       type: DirectoryOrCreate

Depending on your installation you may also find that certain folders are already mounted in this way and that you can simply copy the CA file into an existing folder for the same effect.

Configure API server flags

Configure the API server as in Configuring the OpenID Connect Plugin above.

Note that the ca.pem from above has been renamed to openid-ca.pem in this example - this is just to separate it from any other CA certificates that may be in use.

Create cluster secrets

Once the cluster is up and correctly configured, use kubectl to add the serving certs as secrets.

$ kubectl -n dex create secret tls dex.example.com.tls --cert=ssl/cert.pem --key=ssl/key.pem

Then create a secret for the GitHub OAuth2 client.

$ kubectl -n dex create secret \
    generic github-client \
    --from-literal=client-id=$GITHUB_CLIENT_ID \
    --from-literal=client-secret=$GITHUB_CLIENT_SECRET

Deploy the Dex server

Create the dex deployment, configmap, and node port service. This will also create RBAC bindings allowing the Dex pod access to manage Custom Resource Definitions within Kubernetes.

$ kubectl create -f dex.yaml

Logging into the cluster

The example-app can be used to log into the cluster and get an ID Token. To build the app, run the following commands:

cd examples/example-app
go install .

To build the example-app requires at least a 1.7 version of Go.

$ example-app --issuer https://dex.example.com:32000 --issuer-root-ca examples/k8s/ssl/ca.pem

Please note that the example-app will listen at http://127.0.0.1:5555 and can be changed with the --listen flag.

Once the example app is running, open a browser and go to http://127.0.0.1:5555

A page appears with fields such as scope and client-id. For the most basic case these are not required, so leave the form blank. Click login.

On the next page, choose the GitHub option and grant access to dex to view your profile.

The default redirect uri is http://127.0.0.1:5555/callback and can be changed with the --redirect-uri flag and should correspond with your configmap.

Please note the redirect uri is different from the one you filled when creating GitHub OAuth2 client credentials. When you login, GitHub first redirects to dex (https://dex.example.com:32000/callback), then dex redirects to the redirect uri of example-app.

The printed “ID Token” can then be used as a bearer token to authenticate against the API server.

$ token='(id token)'
$ curl -H "Authorization: Bearer $token" -k https://( API server host ):443/api/v1/nodes

In the kubeconfig file ~/.kube/config, the format is:

users:
- name: (USERNAME)
  user:
    token: (ID-TOKEN)

3 - Machine Authentication to Dex

Overview

Most Dex connectors redirect users to the upstream identity provider as part of the authentication flow. While this works for human users, it is much harder for machines and automated processes (e.g., CI pipelines) to complete this interactive flow. This is where OAuth2 Token Exchange comes in: it allows clients to exchange an access or ID token they already have (obtained from their environment, though custom CLI commands, etc.) for a token issued by dex.

This works like GCP Workload Identity Federation and AWS Web Identity Federation, allowing processes running in trusted execution environments that issue OIDC tokens, such as Gtihub Actions, Buildkite, CircleCI, GCP, and others, to exchange them for a dex issued token to access protected resources.

The authentication flow looks like this:

  1. Client independently obtains an access / id token from the upstream IDP.
  2. Client exchanges the upstream token for a dex access / id token via the token exchange flow.
  3. Use token to access dex protected resources.
  4. Repeat these steps when the token expires.

Configuring dex

Currently, only the OIDC Connector supports token exchanges. For this flow, clientID, clientSecret, and redirectURI aren’t required. getUserInfo is required if you want to exchange from access tokens to dex issued tokens.

As the user performing the token exchange will need the client secret, we configure the client as a public client. If you need to allow humans and machines to authenticate, consider creating a dedicated public client for token exchange and using cross-client trust.

issuer: https://dex.example.com
storage:
    type: sqlite3
    config:
        file: dex.db
web:
    http: 0.0.0.0:8001

outh2:
  grantTypes:
    # ensure grantTypes includes the token-exchange grant (default)
    - "urn:ietf:params:oauth:grant-type:token-exchange"

connectors:
  - name: My Upstream
    type: oidc
    id: my-upstream
    config:
      # The client submitted subject token will be verified against the issuer given here.
      issuer: https://token.example.com
      # Additional scopes in token response, supported list at:
      # https://dexidp.io/docs/custom-scopes-claims-clients/#scopes
      scopes:
        - groups
        - federated:id
      # mapping of fields from the submitted token
      userNameKey: sub
      # Access tokens are generally considered opaque.
      # We check their validity by calling the user info endpoint if it's supported.
      # getUserInfo: true

staticClients:
  # dex issued tokens are bound to clients.
  # For the token exchange flow, the client id and secret pair must be submitted as the username:password
  # via Basic Authentication.
  - name: My App
    id: my-app
    secret: my-secret
    # We set public to indicate we don't intend to keep the client secret actually secret.
    # https://dexidp.io/docs/configuration/custom-scopes-claims-clients/#public-clients
    public: true

Performing a token exchange

To exchange an upstream IDP token for a dex issued token, perform an application/x-www-form-urlencoded POST request to dex’s /token endpoint following RFC 8693 Section 2.1. Additionally, dex requires the connector to be specified with the connector_id parameter and a client id/secret to be included as the username/password via Basic Authentication.

$ export UPSTREAM_TOKEN=$(# get a token from the upstream IDP)

$ curl https://dex.example.com/token \
  --user my-app:my-secret \
  --data-urlencode connector_id=my-upstream \
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  --data-urlencode scope="openid groups federated:id" \
  --data-urlencode requested_token_type=urn:ietf:params:oauth:token-type:access_token \
  --data-urlencode subject_token=$UPSTREAM_TOKEN \
  --data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token

Below is an example of a successful response. Note that regardless of the requested_token_type, the token will always be in the access_token field, with the type indicated by the issued_token_type field. See RFC 8693 Section 2.2.1 for details.

{
  "access_token":"eyJhbGciOi....aU5oA",
  "issued_token_type":"urn:ietf:params:oauth:token-type:access_token",
  "token_type":"bearer",
  "expires_in":86399
}

Full example with GitHub Actions

Here is an example of running dex as a service during a Github Actions workflow and getting an access token from it, exchanged from a Github Actions OIDC token.

Dex config:

issuer: http://127.0.0.1:5556/
storage:
  type: sqlite3
  config:
    file: dex.db
web:
  http: 0.0.0.0:8080
connectors:
- type: oidc
  id: github-actions
  name: github-actions
  config:
    issuer: https://token.actions.githubusercontent.com
    scopes:
      - openid
      - groups
    userNameKey: sub
staticClients:
  - name: My app
    id: my-app
    secret: my-secret
    public: true

Github actions workflow. Replace the service image with one that has the config included.

name: workflow1

on: [push]

permissions:
  id-token: write # This is required for requesting the JWT

jobs:
  job:
    runs-on: ubuntu-latest
    services:
      dex:
        # replace with an image that has the config above
        image: ghcr.io/dexidp/dex:latest
        ports:
          - 80:8080
    steps:
      # Actions have access to two special environment variables ACTIONS_CACHE_URL and ACTIONS_RUNTIME_TOKEN.
      # Inline step scripts in workflows do not see these variables.
      - uses: actions/github-script@v6
        id: script
        timeout-minutes: 10
        with:
          debug: true
          script: |
            const token = process.env['ACTIONS_RUNTIME_TOKEN']
            const runtimeUrl = process.env['ACTIONS_ID_TOKEN_REQUEST_URL']
            core.setOutput('TOKEN', token.trim())
            core.setOutput('IDTOKENURL', runtimeUrl.trim())            
      - run: |
          # get an token from github
          GH_TOKEN_RESPONSE=$(curl \
            "${{steps.script.outputs.IDTOKENURL}}" \
            -H "Authorization: bearer  ${{steps.script.outputs.TOKEN}}" \
            -H "Accept: application/json; api-version=2.0" \
            -H "Content-Type: application/json" \
            -d "{}" \
          )
          GH_TOKEN=$(jq -r .value <<< $GH_TOKEN_RESPONSE)

          # exchange it for a dex token
          DEX_TOKEN_RESPONSE=$(curl \
              http://127.0.0.1/token \
              --user my-app:my-secret \
              --data-urlencode "connector_id=github-actions" \
              --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
              --data-urlencode "scope=openid groups federated:id" \
              --data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
              --data-urlencode "subject_token=$GH_TOKEN" \
              --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token")
          DEX_TOKEN=$(jq -r .access_token <<< $DEX_TOKEN_RESPONSE)

          # use $DEX_TOKEN          

        id: idtoken

4 - Customizing Dex Templates

Using your own templates

Dex supports using your own templates and passing arbitrary data to them to help customize your installation.

Steps:

  1. Copy contents of the web directory over to a new directory.
  2. Customize the templates as needed, be sure to retain all the existing variables so Dex continues working correctly. (Use the following syntax to render values from frontend.extra config: {{ "your_key" | extra }})
  3. Set the frontend.dir value to your own web directory (Alternatively, you can set the DEX_FRONTEND_DIR environment variable).
  4. Add your custom data to the Dex configuration frontend.extra. (optional)
  5. Change the issuer by setting the frontend.issuer config in order to modify the Dex title and the Log in to <<dex>> tag. (optional)
  6. Create a custom theme for your templates in the themes directory. (optional)

Here is an example configuration:

frontend:
  dir: /path/to/custom/web
  issuer: my-dex
  extra:
    tos_footer_link: "https://example.com/terms"
    client_logo_url: "../theme/client-logo.png"
    foo: "bar"

To test your templates simply run Dex with a valid configuration and go through a login flow.

Customize the official container image

Dex is primarily distributed as a container image. The above guide explains how to customize the templates for any Dex instance.

You can combine that with a custom Dockerfile to ease the deployment of those custom templates:

FROM ghcr.io/dexidp/dex:latest

ENV DEX_FRONTEND_DIR=/srv/dex/web

COPY --chown=root:root web /srv/dex/web

Using the snippet above, you can avoid setting the frontend.dir config.

5 - Integration kubelogin and Active Directory

Overview

kubelogin is helper tool for kubernetes and oidc integration. It makes easy to login Open ID Provider. This document describes how dex work with kubelogin and Active Directory.

examples/config-ad-kubelogin.yaml is sample configuration to integrate Active Directory and kubelogin.

Precondition

  1. Active Directory You should have Active Directory or LDAP has Active Directory compatible schema such as samba ad. You may have user objects and group objects in AD. Please ensure TLS is enabled.

  2. Install kubelogin Download kubelogin from https://github.com/int128/kubelogin/releases. Install it to your terminal.

Getting started

Generate certificate and private key

Create OpenSSL conf req.conf as follow:

[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name

[req_distinguished_name]

[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = dex.example.com

Please replace dex.example.com to your favorite hostname. Generate certificate and private key by following command.

$ openssl req -new -x509 -sha256 -days 3650 -newkey rsa:4096 -extensions v3_req -out openid-ca.pem -keyout openid-key.pem -config req.cnf -subj "/CN=kube-ca" -nodes
$ ls openid*
openid-ca.pem openid-key.pem

Modify dex config

Modify following host, bindDN and bindPW in examples/config-ad-kubelogin.yaml.

connectors:
- type: ldap
  name: OpenLDAP
  id: ldap
  config:
    host: ldap.example.com:636

    # No TLS for this setup.
    insecureNoSSL: false
    insecureSkipVerify: true

    # This would normally be a read-only user.
    bindDN: cn=Administrator,cn=users,dc=example,dc=com
    bindPW: admin0!

Run dex

$ bin/dex serve examples/config-ad-kubelogin.yaml

Configure kubernetes with oidc

Copy openid-ca.pem to /etc/ssl/certs/openid-ca.pem on master node.

Use the following flags to point your API server(s) at dex. dex.example.com should be replaced by whatever DNS name or IP address dex is running under.

--oidc-issuer-url=https://dex.example.com:32000/dex
--oidc-client-id=kubernetes
--oidc-ca-file=/etc/ssl/certs/openid-ca.pem
--oidc-username-claim=email
--oidc-groups-claim=groups

Then restart API server(s).

See https://kubernetes.io/docs/reference/access-authn-authz/authentication/ for more detail.

Set up kubeconfig

Add a new user to the kubeconfig for dex authentication:

$ kubectl config set-credentials oidc \
    --exec-api-version=client.authentication.k8s.io/v1beta1 \
    --exec-command=kubectl \
    --exec-arg=oidc-login \
    --exec-arg=get-token \
    --exec-arg=--oidc-issuer-url=https://dex.example.com:32000/dex \
    --exec-arg=--oidc-client-id=kubernetes \
    --exec-arg=--oidc-client-secret=ZXhhbXBsZS1hcHAtc2VjcmV0 \
    --exec-arg=--oidc-extra-scope=profile \
    --exec-arg=--oidc-extra-scope=email \
    --exec-arg=--oidc-extra-scope=groups \
    --exec-arg=--certificate-authority-data=$(base64 -w 0 openid-ca.pem)

Please confirm --oidc-issuer-url, --oidc-client-id, --oidc-client-secret and --certificate-authority-data are same as values in config-ad-kubelogin.yaml.

Run the following command:

$ kubectl --user=oidc cluster-info

It launches the browser and navigates it to http://localhost:8000. Please log in with your AD account (eg. test@example.com) and password. After login and grant, you can access the cluster.

You can switch the current context to dex authentication.

$ kubectl config set-context --current --user=oidc