Skip to content

Ory Oathkeeper: Identity and Access Proxy Server

Ory has a great ecosystem of products when it comes to authentication and authorization. Ory Oathkeeper is an stateless Identity and Access Proxy server.

It is capable of acting as a reverse-proxy as well as a decision maker and policy enforcer for other proxy servers.

In today's application development world, if you're operating on HTTP layer, Ory Oathkeeper has a lot to offer to you.

Stick around to find out how.

What is Ory Oathkeeper?

Chances are, your application needs protection from unauthorized access, whether deployed into the internet and exposed publicly, or gated behind private network and only accessible to a certain privileged users.

That is what Ory Oathkeeper is good at, making sure that requests won't make it to the upstream server unless they are explicitly allowed.

It enforces protective measures to ensure unauthorized requests are denied. It does that by sitting at the frontier of your infrastructure, receiving traffics as they come in, inspecting its content, and making decisions based on the rules you've previously defined and instructed it to.

In this blog post, we will explore what Ory Oathkeeper can do, deploy and configure it in a way that will protect our upstream server.

This scenario is quite common, and you've probably come across it or developed a custom solution for your application before.

Hold your breath till the end to find out how to leverage this opensource solution to your advantage so that you won't ever have to reinvent the wheel again.

Why Ory Oathkeeper?

There are numerous reasons why Oathkeeper is a good fit at what it does. Here are some of the highlights you should be aware of:

  • Proxy Server: One of the superpowers of Oathkeeper is its ability to sit at the forefront of your infrastructure and denying unauthorized requests. 🛡
  • Decision Maker: Another mode of running Ory Oathkeeper is to use it as a policy enforcer, making decisions on whether or not a request should be granted access based on the defined rules. 🧐
  • Open Source: Ory Oathkeeper is opensource with a permissive license, meaning you can inspect the source code, contribute to it, and even fork it if you want to. 🏳
  • Stateless: Ory Oathkeeper is stateless, meaning it doesn't store any session data. This is a good thing because it makes it horizontally scalable and easy to deploy in a distributed environment. 🦅
  • Pluggable: Ory products are adhering to plugin architecture; you can use all of them, some of them, or only one of them. This allows a lot of flexibility when migrating from a current solution or integrating with a third party service. This is the key feature that makes the entire suite very appealing to me. 🔌
  • Full Featured: It comes with batteries included, providing experimental support for gRPC middleware1 (if you're into Golang), and also stable support for WebSockets2. 🔋
  • Community: Ory has a great community of developers and users. If you ever get stuck, you can always ask for help in the community Slack channel3. 🤝

In short and more accurately put, Ory Oathkeeper is an Identity and Access Proxy (IAP for short)4. You will see later in this post how that comes into play.

Disclaimer

This blog post is NOT sponsored by Ory(1). I'm just a happy user of their products and I want to share my experience with you.

  1. Though, I definitely wouldn't mind seeing some dollars. 🤑

How Does Ory Oathkeeper Work?

There are two modes of operation for Ory Oathkeeper:

  1. Reverse Proxy Mode: Accepting raw traffics from the client and forwarding it to the upstream server.
  2. Decision Maker Mode: Making decisions whether or not a request should be granted access. The frontier proxy server will query the decision API of Oathkeeper to grant or deny a request5.

Both of these modes rely solely on the API access rules written in human-readable YAML format6.(1) You can pass multiple rules to be applied for multiple upstream servers or backends.

  1. Some will disagree with YAML files being human-readable . Though we are not in the business of picking sides, we are here to provide a technical guide. 🤷

After this rather long introductory, let's get our hands dirty and roll up our system administration sleeves.

Pre-requisites

This guide will be built upon our earlier infrastructure setup. You are more than welcome to stick to your own technology stack, however, since we had to deploy and make sure everything works perfectly, we picked our preferred stack as you see below:

  • Kubernetes: Though you don't have to, this guide is built on top of Kubernetes, heavily relying on the operator pattern and the CRDs used to deploy our infrastructure as well as the Oathkeeper rules.
  • cert-manager: We will need internet access to our cluster with TLS certificate from a trusted CA. That is where cert-manager and Gateway API are lending a generous hand. 🤝 Take a look at cert-manager: All-in-One Kubernetes TLS Certificate Manager if you don't have it set up yet.
  • External Secrets: For fetching the TLS certificates, we need access to our DNS provider (Cloudflare in this case). That's where we need the ESO to provide the API token for the cert-manager. Our earlier guide is a perfect place to set it up if you haven't already: External Secrets Operator: Fetching AWS SSM Parameters into Azure AKS.
  • Ory Kratos: The main and most important part of this guide is the integration of Oathkeeper with Kratos. We will use Kratos to authenticate the users. Ultimately the requests reaching the upstream server will be authenticated and granted access by two of Ory's products. If you need help setting up Kratos, refer to Ory Kratos: Headless Authentication, Identity and User Management
  • FluxCD: This is our technology of choice when it comes to Kubernetes deployments. You are free to pick simpler tools such as Helm CLI. FluxCD is a great tool that has a bit of learning curve. Check out our guide on GitOps Demystified: Introduction to FluxCD for Kubernetes if you need a starting point or GitOps Continuous Deployment: FluxCD Advanced CRDs if you are an advanced user.
  • Jaeger: For the observation of traces between Oathkeeper and Kratos we get to configure both to send their traces to a custom endpoint. That's where Jaeger comes into play.
  • Azure account: Optionally, an Azure VM having system assigned identity attached to it. We have covered this specific case two weeks ago in How to Access AWS From Azure VM Using OpenID Connect. We use this in the last part of this guide to send authenticated requests to Oathkeeper from an Azure VM.

Deploying Ory Oathkeeper

Being an stateless application by nature, Oathkeeper is a perfect fit for Kubernetes Deployment. This allows us to horizontally scale it on demand.

Let's write the server configuration and then deploy it to our Kubernetes cluster using FluxCD.

Oathkeeper Server Configuration

As with every other of Ory products, Oathkeeper relies heavily on its configuration file. This is, as usual, written in YAML format, making it easy to read and maintain as the complexity grows.

The following configuration is fetched in its entirety from the configuration reference7 and customized to fit our need.

An important thing to note is that we are only using the authenticators and authorizers that are being used in this blog post. The rest and the complete reference can be found in the official documentation7.

oathkeeper/oathkeeper-server-config.yml
access_rules:
  matching_strategy: regexp
  repositories:
    - file:///etc/rules/access-rules.json
authenticators:
  cookie_session:
    config:
      check_session_url: http://kratos-public.auth/sessions/whoami
      preserve_path: true
      preserve_query: true
    enabled: true
  jwt:
    config:
      jwks_urls:
        - https://login.windows.net/common/discovery/keys
    enabled: true
  anonymous:
    config:
      subject: guest
    enabled: true
errors:
  handlers:
    redirect:
      config:
        to: https://ory.developer-friendly.blog/login
        when:
          - error:
              - unauthorized
        code: 301
      enabled: true
    json:
      config:
        verbose: true
      enabled: true
    www_authenticate:
      enabled: true
  fallback:
    - json
authorizers:
  allow:
    enabled: true
mutators:
  header:
    config:
      headers:
        x-user-id: "{{ print .Subject }}"
    enabled: true
log:
  format: json
  leak_sensitive_values: true
  level: info
tracing:
  service_name: oathkeeper
  providers:
    jaeger:
      sampling:
        trace_id_ratio: 1.0
        server_url: http://jaeger.monitoring:5778/sampling
      local_agent_address: jaeger.monitoring:6831
  provider: jaeger
serve:
  proxy:
    host: 0.0.0.0
    cors:
      allowed_origins:
        - https://*.developer-friendly.blog
      allow_credentials: true
      enabled: true
    port: 4455
  prometheus:
    host: 0.0.0.0
    port: 9000
  api:
    host: 0.0.0.0
    port: 4456

There are a lot to unpack. We may not be able to cover all, but let's explain some of the highlights.

Allowed Authentication Methods

Oathkeeper accepts in its configuration, the methods allowed for authentication and authorization. If you wish to use OAuth2 authentication, before using it in a Oathkeeper rule, you have to enable it in the configuration.

oathkeeper/oathkeeper-server-config.yml
authenticators:
  cookie_session:
    config:
      check_session_url: http://kratos-public.auth/sessions/whoami
      preserve_path: true
      preserve_query: true
    enabled: true
  jwt:
    config:
      jwks_urls:
        - https://login.windows.net/common/discovery/keys
    enabled: true
  anonymous:
    config:
      subject: guest
    enabled: true

You can customize each method further with specific values. However, we will leave the customization to the Oathkeeper rule later in this blog. The URLs, however, are a required field and must be specified at the configuration level.

There will be more blog posts including Ory Oathkeeper and other authenticators and authorizers in the future. Stay tuned for more. 🤞

Tracing Endpoints

In all of the Ory products, you can specify where to ship your traces to.

That is possible through the same configuration over all the (currently) four products as below:

oathkeeper/oathkeeper-server-config.yml
tracing:
  service_name: oathkeeper
  providers:
    jaeger:
      sampling:
        trace_id_ratio: 1.0
        server_url: http://jaeger.monitoring:5778/sampling
      local_agent_address: jaeger.monitoring:6831
  provider: jaeger

CORS Configuration

If you're accessing the Oathkeeper from the browser, you have to set the allowed origin addresses in the configurations.

Those "allowed" URLs are the hostnames that is in the address bar of a browser. If you specify a wildcard, Oathkeeper will intelligently allow the concrete value coming from the Origin header of the request.

For example, if *.developer-friendly.blog is in the allowed origins and the browser sends the request with Origin: example.developer-friendly.blog, the Oathkeeper will respond with Access-Control-Allow-Origin: example.developer-friendly.blog. Same goes with other subdomains.

The allow_credentials: true is perhaps the second most important part of this configuration. Without it your browser will not forward the cookies to the Oathkeeper server and you will always get a 401 Unauthorized response.

That part that makes it possible is in these configuration lines:

oathkeeper/oathkeeper-server-config.yml
    cors:
      allowed_origins:
        - https://*.developer-friendly.blog
      allow_credentials: true
      enabled: true

Kubernetes Deployment Resources

There are different ways to deploy Oathkeeper8 and it highly depends on your infrastructure more than anything else.

In this blog post, we will refrain from using Docker Compose as that is something publicly available in the corresponding repository9 and example repository10.

Instead, we will share how to deploy Ory Oathkeeper in a Kubernetes cluster using FluxCD. We have in-depth guide on both topics in our archive if you're new to them.

Note

Beware, the following Ory Oathkeeper deployment is using Kustomization.

That requires doing a lot of heavy lifting if you're used to simpler deployment tools such as Helm.

However, the upstream Helm chart seems to be quite inflexible and due to the lack of customizations allowed, we had to resort to Kustomization.

Opting for the Helm installation would require us to do a lot of post-render Kustomization, both ugly and unmaintainable.

As of writing this blog post, with the help of Oathkeeper Maester11, Ory Oathkeeper has native Kubernetes support for rules, i.e., you can create Kubernetes resources to have Oathkeeper rules. 💪

However, this will require proper Kubernetes RBAC as you see below. 👇

oathkeeper/clusterrole.yml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: oathkeeper-maester-role
rules:
  - apiGroups:
      - oathkeeper.ory.sh
    resources:
      - rules
    verbs:
      - "*"
  - apiGroups:
      - ""
    resources:
      - configmaps
    verbs:
      - get
      - list
      - watch
      - create
      - patch
      - update
oathkeeper/serviceaccount-maester.yml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: oathkeeper-maester
oathkeeper/clusterrolebinding.yml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: oathkeeper-maester-role-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: oathkeeper-maester-role
subjects:
  - kind: ServiceAccount
    name: oathkeeper-maester

The following two Deployment resources are the core of our Kustomization stack.

oathkeeper/deployment-oathkeeper-maester.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oathkeeper-maester
spec:
  selector:
    matchLabels:
      app: oathkeeper-maester
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: oathkeeper-maester
    spec:
      automountServiceAccountToken: true
      containers:
        - args:
            - --metrics-addr=0.0.0.0:8080
            - controller
            - --rulesConfigmapName=oathkeeper-rules
            - --rulesConfigmapNamespace=FILLED_BY_KUSTOMIZATION
          command:
            - /manager
          image: oryd/oathkeeper-maester
          name: oathkeeper-maester
          ports:
            - containerPort: 8080
              name: metrics
              protocol: TCP
          securityContext:
            capabilities:
              drop:
                - ALL
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            seccompProfile:
              type: RuntimeDefault
      serviceAccountName: oathkeeper-maester
oathkeeper/deployment-oathkeeper.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oathkeeper
spec:
  selector:
    matchLabels:
      app: oathkeeper
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: oathkeeper
    spec:
      containers:
        - args:
            - serve
            - --config
            - /etc/oathkeeper/config.yml
          command:
            - oathkeeper
          image: oryd/oathkeeper
          livenessProbe:
            failureThreshold: 5
            httpGet:
              httpHeaders:
                - name: Host
                  value: 127.0.0.1
              path: /health/alive
              port: http-api
              scheme: HTTP
            initialDelaySeconds: 1
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 1
          name: oathkeeper
          ports:
            - containerPort: 4456
              name: http-api
              protocol: TCP
            - containerPort: 4455
              name: http-proxy
              protocol: TCP
            - containerPort: 9000
              name: http-metrics
              protocol: TCP
          readinessProbe:
            failureThreshold: 3
            httpGet:
              path: /health/alive
              port: http-api
              scheme: HTTP
            initialDelaySeconds: 1
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 1
          securityContext:
            capabilities:
              drop:
                - ALL
            readOnlyRootFilesystem: true
            runAsNonRoot: true
          volumeMounts:
            - mountPath: /etc/oathkeeper
              name: oathkeeper-config
              readOnly: true
            - mountPath: /etc/rules
              name: oathkeeper-rules
              readOnly: true
      volumes:
        - configMap:
            defaultMode: 0444
            name: oathkeeper-config
          name: oathkeeper-config
        - configMap:
            defaultMode: 0444
            name: oathkeeper-rules
          name: oathkeeper-rules

We won't need to expose Oathkeeper Maester, but we require the Oathkeeper to be accessible to the cluster. Hence the Services below.

oathkeeper/service-oathkeeper-api.yml
apiVersion: v1
kind: Service
metadata:
  name: oathkeeper-api
spec:
  ports:
  - name: http
    port: 80
    protocol: TCP
    targetPort: http-api
  selector:
    app: oathkeeper
  type: ClusterIP
oathkeeper/service-oathkeeper-proxy.yml
apiVersion: v1
kind: Service
metadata:
  name: oathkeeper-proxy
spec:
  ports:
  - name: http
    port: 80
    protocol: TCP
    targetPort: http-proxy
  selector:
    app: oathkeeper
  type: ClusterIP

Now let's put this all together into a Kustomization file.

oathkeeper/kustomization.yml
configMapGenerator:
  - name: oathkeeper-config
    files:
      - config.yml=oathkeeper-server-config.yml

images:
  - name: oryd/oathkeeper
    newTag: v0.40.7
  - name: oryd/oathkeeper-maester
    newTag: v0.1.10-arm64

replacements:
  - source:
      kind: Deployment
      name: oathkeeper
      fieldPath: metadata.namespace
    targets:
      - select:
          kind: Deployment
          name: oathkeeper-maester
        fieldPaths:
          - spec.template.spec.containers.[name=oathkeeper-maester].args.3
        options:
          delimiter: "="
          index: 1
          create: false
      - select:
          kind: ClusterRoleBinding
          name: oathkeeper-maester-role-binding
        fieldPaths:
          - subjects.[kind=ServiceAccount].namespace
        options:
          create: true

resources:
  - https://github.com/ory/oathkeeper-maester//config/crd?timeout=30s&ref=v0.1.10
  - clusterrole.yml
  - clusterrolebinding.yml
  - service-oathkeeper-api.yml
  - service-oathkeeper-metrics.yml
  - service-oathkeeper-proxy.yml
  - serviceaccount-maester.yml
  - deployment-oathkeeper-maester.yml
  - deployment-oathkeeper.yml

Notice the CRD installation in the Kustomization file. Without it, this stack won't be deployed properly.

oathkeeper/kustomization.yml
resources:
  - https://github.com/ory/oathkeeper-maester//config/crd?timeout=30s&ref=v0.1.10

FluxCD Deployment Kustomization

To deploy the Oathkeeper, we need one last YAML file.

oathkeeper/kustomize.yml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: oathkeeper
  namespace: flux-system
spec:
  force: false
  healthChecks:
    - kind: Deployment
      name: oathkeeper
    - kind: Deployment
      name: oathkeeper-maester
  interval: 5m
  path: ./oathkeeper
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  targetNamespace: auth
  timeout: 1m
  wait: true

Now let's deploy this into our cluster.

kubectl apply -f oathkeeper/kustomize.yml

This will take a few moments to pull the images and prepare everything. In the end, you should have the stack ready as you see in the screenshot below.

FluxCD VSCode Extension
FluxCD VSCode Extension

That is the VSCode Extension by the way. It can give a pretty good visual at needed times12.

Is There a Simpler Way?

Of course there is.

There is almost always a way to less complexity, especially those complexities that are accidental!

The following Helm commands do all the things we worked so hard to achieve in the previous sections13.

helm repo add ory https://k8s.ory.sh/helm/charts
helm install oathkeeper ory/oathkeeper \
    --set oathkeeper.managedAccessRules=false \
    --version 0.43.x

Pick whichever you like. The decision between extensive customization and simplicity is yours to make.

Upstream Server Deployment

Whichever method you have employed to deploy Oathkeeper, you should have the server up and running.

At this point we will start using the Oathkeeper by creating rules and testing the authentication of our upstream server.

To make sure the test is realistic and with a concrete example, we will deploy our previous echo server example, an opensource project that echoes back the request it receives14.

To save space for the actual content, we will only provide the Deployment definition.

echo-server/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo-server
  template:
    metadata:
      labels:
        app: echo-server
    spec:
      containers:
        - envFrom:
            - configMapRef:
                name: echo-server
          image: ealen/echo-server
          name: echo-server
          ports:
            - containerPort: 80
              name: http
          resources:
            limits:
              cpu: 100m
              memory: 128Mi
            requests:
              cpu: 50m
              memory: 64Mi
          securityContext:
            capabilities:
              add:
                - NET_BIND_SERVICE
              drop:
                - ALL
            readOnlyRootFilesystem: true
      securityContext:
        runAsGroup: 1000
        runAsUser: 1000
        seccompProfile:
          type: RuntimeDefault

If you're interested to see the entire resources, click below. 👇

Click to expand
echo-server/configs.env
PORT=80
LOGS__IGNORE__PING=false
ENABLE__HOST=true
ENABLE__HTTP=true
ENABLE__REQUEST=true
ENABLE__COOKIES=true
ENABLE__HEADER=true
ENABLE__ENVIRONMENT=false
ENABLE__FILE=false
echo-server/service.yml
apiVersion: v1
kind: Service
metadata:
  name: echo-server
spec:
  ports:
    - name: http
      port: 80
      targetPort: http
  type: ClusterIP
echo-server/kustomization.yml
configMapGenerator:
  - envs:
      - configs.env
    name: echo-server

images:
  - name: ealen/echo-server
    newTag: 0.9.2

replacements:
  - source:
      kind: Deployment
      name: echo-server
      fieldPath: spec.template.metadata.labels
    targets:
      - select:
          kind: Service
          name: echo-server
        fieldPaths:
          - spec.selector
        options:
          create: true
  - source:
      kind: ConfigMap
      name: echo-server
      fieldPath: data.PORT
    targets:
      - select:
          kind: Deployment
          name: echo-server
        fieldPaths:
          - spec.template.spec.containers.[name=echo-server].ports.[name=http].containerPort

resources:
  - deployment.yml
  - service.yml

namespace: default
echo-server/kustomize.yml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: echo-server
  namespace: flux-system
spec:
  force: false
  interval: 5m
  path: ./echo-server
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  targetNamespace: default
  wait: true
kubectl apply -f echo-server/kustomize.yml

Oathkeeper Rules

The final section of this blog post is the main part. Everything we've built so far was a preparation for this moment.

Internet Accessible Endpoint

The first step is to route all the traffic targeting the upstream server to the Oathkeeper proxy endpoint. Based on different types of deployments, you may end up executing this step differently.

But, if you have followed along with the guide so far, we need two pieces to make this come to life:

  1. A DNS record for the host we want to expose.
  2. The Kubernetes HTTPRoute resource that will accept the incoming traffics.

We will skip the first part as that is something we have covered multiple times in this blog and it is tailored to your DNS provider.

The second part is as you see below.

Notice that this HTTPRoute has to be in the same namespace as the Oathkeeper. The reason is that the Gateway will only route the traffics to the same namespace as the HTTPRoute15.

In short, we send the internet traffics to the Oathkeeper, and if all looks OK (the request is authenticated and whatnot), it will forward the request to the upstream server.

Otherwise, the user will get the proper error message from Oathkeeper before even a single byte reaches the upstream server. That is the true power of Oathkeeper.

echo-server-rule/httproute.yml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: echo-server
spec:
  hostnames:
    - echo.developer-friendly.blog
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: developer-friendly-blog
      namespace: cert-manager
      sectionName: https
  rules:
    - backendRefs:
        - kind: Service
          name: oathkeeper-proxy
          port: 80
      filters:
        - responseHeaderModifier:
            set:
              - name: Strict-Transport-Security
                value: max-age=31536000; includeSubDomains; preload
          type: ResponseHeaderModifier
      matches:
        - path:
            type: PathPrefix
            value: /

Let's deploy this as a Kustomization stack.

echo-server-rule/kustomization.yml
resources:
  - httproute.yml
echo-server-rule/kustomize.yml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: echo-server-rule
  namespace: flux-system
spec:
  force: false
  interval: 5m
  path: ./echo-server-rule
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  targetNamespace: auth
  wait: true
kubectl apply -f echo-server-rule/kustomize.yml

Play 1: Anonymous Access

The first rule we will create is to allow anonymous access to the upstream server. This is a common use-case where you want to allow everyone to access the server without any authentication.

echo-server-rule/rule.yml
apiVersion: oathkeeper.ory.sh/v1alpha1
kind: Rule
metadata:
  name: echo-server
spec:
  authenticators:
    - handler: anonymous
  authorizer:
    handler: allow
  errors:
    - handler: json
  match:
    methods:
      - GET
      - POST
      - PUT
      - PATCH
      - DELETE
      - OPTIONS
    url: http<s?>://echo.developer-friendly.blog</?.*>
  mutators:
    - handler: header
  upstream:
    preserveHost: true
    url: http://echo-server.default

The flow of the request is as follows16:

1⃣ Is the request authenticated? Yes, it is anonymous.

echo-server-rule/rule.yml
  authenticators:
    - handler: anonymous

2⃣ Is it authorized? Yes, the rule allows access to everyone.

echo-server-rule/rule.yml
  authorizer:
    handler: allow

3⃣ Do we need to change anything in the request? Yes, add a single x-user-id header (guest for anonymous).

echo-server-rule/rule.yml
  mutators:
    - handler: header

4⃣ What if error happens before reaching upstream? Return the error as JSON.

echo-server-rule/rule.yml
  errors:
    - handler: json

The flow you see above is the most important part of how Ory Oathkeeper works. If you master this flow, you can create any kind of rule you want.

Notice that in our Rule definition, we are specifying the upstream_url as the <service_name>.<namespace>. This is due to the fact that the Oathkeeper stack and our echo-server are in separate namespaces.

Also note that the match.url is using regex format. This is only possible if you have access_rules.matching_strategy: regexp in your Oathkeeper server configuration.

Let's apply this stack:

echo-server-rule/kustomization.yml
---
resources:
  - httproute.yml
  - rule.yml

Let's send an anonymous request to verify it worked.

curl https://echo.developer-friendly.blog

The response is as below.

{
  "host": {
    "hostname": "echo.developer-friendly.blog",
    "ip": "::ffff:20.0.0.195",
    "ips": []
  },
  "http": {
    "baseUrl": "",
    "method": "GET",
    "originalUrl": "/",
    "protocol": "http"
  },
  "request": {
    "body": {},
    "cookies": {},
    "headers": {
      "accept": "*/*",
      "accept-encoding": "gzip",
      "b3": "c2720bd50c24d2254f9e593737409cff-3214b34f361875d0-1",
      "host": "echo.developer-friendly.blog",
      "traceparent": "00-c2720bd50c24d2254f9e593737409cff-3214b34f361875d0-01",
      "uber-trace-id": "c2720bd50c24d2254f9e593737409cff:3214b34f361875d0:0:1",
      "user-agent": "curl/7.81.0",
      "x-b3-sampled": "1",
      "x-b3-spanid": "3214b34f361875d0",
      "x-b3-traceid": "c2720bd50c24d2254f9e593737409cff",
      "x-envoy-expected-rq-timeout-ms": "15000",
      "x-envoy-internal": "true",
      "x-request-id": "97d77949-6ef0-46b0-86d3-59eb32344fe3",
      "x-user-id": "guest"
    },
    "params": {
      "0": "/"
    },
    "query": {}
  }
}

The user ID is coming from the following Oathkeeper server configuration. 👇

oathkeeper/oathkeeper-server-config.yml
  anonymous:
    config:
      subject: guest
    enabled: true

Play 2: Authenticated by Ory Kratos

At this stage, we should be able to use our previously deployed Ory Kratos server.

Let's modify the same rule so that the authenticated users and the identities of Kratos can send their request to this upstream server17.

echo-server-rule/rule.yml
apiVersion: oathkeeper.ory.sh/v1alpha1
kind: Rule
metadata:
  name: echo-server
spec:
  authenticators:
    - config:
        check_session_url: http://kratos-public.auth/sessions/whoami
        extra_from: "@this"
        force_method: GET
        only:
          - ory_kratos_session
        subject_from: identity.id
      handler: cookie_session
    - handler: anonymous
  authorizer:
    handler: allow
  errors:
    - handler: json
  match:
    methods:
      - GET
      - POST
      - PUT
      - PATCH
      - DELETE
      - OPTIONS
    url: http<s?>://echo.developer-friendly.blog</?.*>
  mutators:
    - handler: header
  upstream:
    preserveHost: true
    url: http://echo-server.default

Note that this will only work for browser users of Kratos. For mobile native clients using the API flow of Kratos, you'd want to use the X-Session-Token header instead of the cookie17.

If we authenticate to Kratos first and send an HTTP request to the echo-server, this is what we get.

{
  "host": {
    "hostname": "echo.developer-friendly.blog",
    "ip": "::ffff:20.0.0.67",
    "ips": []
  },
  "http": {
    "baseUrl": "",
    "method": "GET",
    "originalUrl": "/",
    "protocol": "http"
  },
  "request": {
    "body": {},
    "cookies": {
      "csrf_token_1c7f8e9fce29a4477c061c763377284ec29475bad1819ea48f4f48bb479dd449": "0AKt9i0pcgopFl2DbnuNsqxMC1/l0sqwOjZQtupwGhs=",
      "ory_kratos_session": "MTcxNzkyNDUyOHxxcGpkbkhqOW4zTFZaQUZtVFRxd2pQZ3F2UGFxQUlOalAyd18xcUZjVTh6U1pvQ1ZGd2dBMmRtVFBGSnkzY3ZkeDJRRnZvR29KclZ0a2E3RHFxN0NBV2ktMDNzcm1lcldGUndWTFBCSExVM1VKV2tfbzhNMUVmZDdMZ2wwUW1JSmI0UW96MGpGLXc0YU5xc095ZlJ0SzRiVkhKTkdBcUR0ZTZoYVYxd1dHNy1YM01oTDluYURZU09ZRUI1d2lpejFhcGtNenI5VkEtcHRTQkRIQ2xOUGcySWs2R3RvMlpURHZrODVoZVlacVlpSzNBVnNRZDg4MDJnMGRXaVNBbDVNN2pHeVZCMFVVMTZzQ2p2Y1U3RDF8GAjcTd0iTmLRfV2tDWxCbgz9Wau3WGGVI_wSJcBUt54="
    },
    "headers": {
      "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
      "accept-encoding": "gzip, deflate, br, zstd",
      "accept-language": "en-US",
      "b3": "19f74b8e495e129aa2b70f16a840d060-4b191ecca0c43e9f-1",
      "cookie": "csrf_token_1c7f8e9fce29a4477c061c763377284ec29475bad1819ea48f4f48bb479dd449=0AKt9i0pcgopFl2DbnuNsqxMC1/l0sqwOjZQtupwGhs=; ory_kratos_session=MTcxNzkyNDUyOHxxcGpkbkhqOW4zTFZaQUZtVFRxd2pQZ3F2UGFxQUlOalAyd18xcUZjVTh6U1pvQ1ZGd2dBMmRtVFBGSnkzY3ZkeDJRRnZvR29KclZ0a2E3RHFxN0NBV2ktMDNzcm1lcldGUndWTFBCSExVM1VKV2tfbzhNMUVmZDdMZ2wwUW1JSmI0UW96MGpGLXc0YU5xc095ZlJ0SzRiVkhKTkdBcUR0ZTZoYVYxd1dHNy1YM01oTDluYURZU09ZRUI1d2lpejFhcGtNenI5VkEtcHRTQkRIQ2xOUGcySWs2R3RvMlpURHZrODVoZVlacVlpSzNBVnNRZDg4MDJnMGRXaVNBbDVNN2pHeVZCMFVVMTZzQ2p2Y1U3RDF8GAjcTd0iTmLRfV2tDWxCbgz9Wau3WGGVI_wSJcBUt54=",
      "dnt": "1",
      "host": "echo.developer-friendly.blog",
      "priority": "u=4",
      "sec-fetch-dest": "document",
      "sec-fetch-mode": "navigate",
      "sec-fetch-site": "cross-site",
      "sec-gpc": "1",
      "traceparent": "00-19f74b8e495e129aa2b70f16a840d060-4b191ecca0c43e9f-01",
      "uber-trace-id": "19f74b8e495e129aa2b70f16a840d060:4b191ecca0c43e9f:0:1",
      "upgrade-insecure-requests": "1",
      "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0",
      "x-b3-sampled": "1",
      "x-b3-spanid": "4b191ecca0c43e9f",
      "x-b3-traceid": "19f74b8e495e129aa2b70f16a840d060",
      "x-envoy-expected-rq-timeout-ms": "15000",
      "x-envoy-internal": "true",
      "x-request-id": "5b5bedfd-ac4b-4ae3-a87f-952cf2b55453",
      "x-user-id": "90b31539-08ba-45b1-a5fc-5de840f19d7e"
    },
    "params": {
      "0": "/"
    },
    "query": {}
  }
}

The x-user-id in this response is just the same as if we take the session information from the Kratos itself.

Kratos Session Information
Kratos Session Information

Play 3: Azure VM Access

The idea in this scenario is that the virtual machine in the Azure cloud with system assigned identity can send authenticated requests to the echo-server, while Oathkeeper verifies the authenticity of the request using the Azure AD JWKs endpoint.

echo-server-rule/rule.yml
apiVersion: oathkeeper.ory.sh/v1alpha1
kind: Rule
metadata:
  name: echo-server
spec:
  authenticators:
    - config:
        check_session_url: http://kratos-public.auth/sessions/whoami
        extra_from: "@this"
        force_method: GET
        only:
          - ory_kratos_session
        subject_from: identity.id
      handler: cookie_session
    - config:
        jwks_urls:
          - https://login.windows.net/common/discovery/keys
        target_audience:
          - https://management.azure.com
      handler: jwt
    - handler: anonymous
  authorizer:
    handler: allow
  errors:
    - handler: json
  match:
    methods:
      - GET
      - POST
      - PUT
      - PATCH
      - DELETE
      - OPTIONS
    url: http<s?>://echo.developer-friendly.blog</?.*>
  mutators:
    - handler: header
  upstream:
    preserveHost: true
    url: http://echo-server.default

Azure AD JWT Audience

The target_audience you see in this rule will be identical to the aud claim in the JWT token of the Azure VM. To make sure you get it right, you can fetch the access token from within the VM18, decode its content and check the aud claim.

Azure AD JWKs Endpoint

How to get the jwks_url one might ask!? The answer is simple. If you use the same technique to decode the said token, you will see its iss claim. Using that issuer, you can append /.well-known/openid-configuration to get the OpenID Connect configuration of the server, and in the JSON payload response, the JWKs URL will be present.

Using this Oathkeeper rule, if we spin up an Azure VM and enable its system identity, we can get a JWT token19 from Azure AD and send it to Oathkeeper.

# From inside the Azure VM
token=$(curl \
  'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F' \
  -H Metadata:true -s)

# or
token=$(az account get-access-token \
  --resource https://management.azure.com \
  --query "accessToken" -o tsv)

curl https://echo.developer-friendly.blog -H "Authorization $token"

The response will be as you see below. The x-user-id is the subject or the identity ID that the Identity Provider (Azure AD) knows the VM by.

{
  "host": {
    "hostname": "echo.developer-friendly.blog",
    "ip": "::ffff:20.0.0.67",
    "ips": []
  },
  "http": {
    "baseUrl": "",
    "method": "GET",
    "originalUrl": "/",
    "protocol": "http"
  },
  "request": {
    "body": {},
    "cookies": {},
    "headers": {
      "accept": "*/*",
      "accept-encoding": "gzip, deflate",
      "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNGNkZGNmMC1kMjAwLTQ3M2ItYjdkMi1lMjQ1ZTgyOWRiMjkifQ.KSOICXTqr9TF7ltpaKloAOnQZIPXK-wz3mKoHI4zF7k",
      "b3": "a26738ea41facd800a9a99dc2d193fe5-1bc4f24219265111-1",
      "host": "echo.developer-friendly.blog",
      "traceparent": "00-a26738ea41facd800a9a99dc2d193fe5-1bc4f24219265111-01",
      "uber-trace-id": "a26738ea41facd800a9a99dc2d193fe5:1bc4f24219265111:0:1",
      "user-agent": "curl/7.81.0",
      "x-b3-sampled": "1",
      "x-b3-spanid": "1bc4f24219265111",
      "x-b3-traceid": "a26738ea41facd800a9a99dc2d193fe5",
      "x-envoy-expected-rq-timeout-ms": "15000",
      "x-envoy-internal": "true",
      "x-request-id": "ffae2311-da88-4a3c-9b33-0b29a873bc3c",
      "x-user-id": "a4cddcf0-d200-473b-b7d2-e245e829db29"
    },
    "params": {
      "0": "/"
    },
    "query": {}
  }
}

Other Types of Authenticators

As you can imagine, these are just a few examples of the capabilities of Oathkeeper and what it can bring to your table.

There are other types of authenticators, as well as authorizers, that can be used to gate an application behind a proxy server, granting access only to those trusted parties that you have explicitly defined.

As an example, if we remove the anonymous access from our rule, the request will be denied with a 401 Unauthorized status code.

{
  "error": {
    "code": 401,
    "message": "Access credentials are invalid",
    "request": "d1b7eadf-1383-4951-a162-f35717e3ee1b",
    "status": "Unauthorized"
  }
}

That is to say, the order in which Oathkeeper processes the rules is important. It will start from the top, and any authenticator that can handle the authentication process will be the only one ever consulted and the rest of the authenticators are comletely ignored, even if the matched authentication denies the request! Yes, even if any of the subsequent authenticators would have allowed the request. That's something to keep in mind when designing and creating your rules.

From the official documentation20:

authenticators: A list of authentication handlers that authenticate the provided credentials. Authenticators are checked iteratively from index 0 to n and the first authenticator to return a positive result will be the one used. If you want the rule to first check a specific authenticator before "falling back" to others, have that authenticator as the first item in the array.

Observing the Traces

Remember we mentioned how use you can ship your traces with Ory products into a backend? Among different available solutions, Jaeger is one of the easy ones to set up and use.

When we engage both Oathkeeper and Kratos in one HTTP request, this is what we get in the Jaeger UI.

Jaeger UI: Oathkeeper & Kratos
Jaeger UI: Oathkeeper & Kratos

Pretty neat! Wouldn't you say? 🤓

Wrapping Up

All in all, Oathkeeper design and secure implementation will help you sleep tight at night, knowing that your application is safely guarded by a production grade and robust proxy server, consulting the proper authentication server before handing it to the upstream backend.

These days, I use the Oathkeeper even for my admin pages; even the ones not publicly accessible and only exposed to the private network. This helps secure the backend from unauthorized access.

There are other types of examples we can provide here, but with the ones you see here, you should have a good idea on what's possible and what you can do more with Ory Oathkeeper.

Regardless, we will have more examples of this topic in the future for other practical and production use-cases.

Conclusion

Based on my production experience over the years managing different types of applications and backends in various industries, there is the same pattern and approach for a desired authentication layer one might want to have.

It usually includes some sort of cunsultation with the Identity Provider, making sure the identity is coming from a trusted source, and then tightening it further by making an API call to the authorization server, making sure the identity is indeed allowed and granted access to such resource.

The plugin architecture of Ory makes this back and forth quite straightforward. There is little you can't do with the Ory suite and with the right configuration and architectural mindset, you can secure many of the knowingly hard-to-protect applications.

I can't recommend their products highly enough, being a happy customer and whatnot. But, even more so, knowing that it's easy to fall into the trap of thinking that one's security and authentication needs are beyond the common pattern happening around the industry

It's tempting to think that customization and in-house development is in order. That is wrong, in my humble opinion. You will lose countless engineering hours making something not nearly as secure as what is already available as an off-the-shelf and opensource solution.

Before investing many of your engineering efforts building something from scratch, I highly recommend trying Ory's products in the tenth of that time. 🕰

Ory is only one of the many solutions out there, but at the very least, you should have a basic understanding of what is already available to you around the industry before going all in on a custom solution.

Make your decisions wisely, and do the right things before doing things right.

Happy hacking and until next time 🫡, ciao. 🐧 🦀

If you enjoyed this blog post, consider sharing it with these buttons 👇. Please leave a comment for us at the end, we read & love 'em all. ❣

Share on Share on Share on Share on

Comments