Figuring out Ory Oathkeeper

thumbnail

I must admin—I struggled understanding Oathkeeper. Looking back, I think the reason was, I compared it one for one to things like Traefik or Envoy. Turns out, Oathkeeper does not necessarily intend replacing a reverse proxy, although many people probably use it as such.

I was glossing over it for a long time and recently decided to come back to it. While reading the Oathkeeper Docs[1] Introduction section, the part about using Oathkeeper as a decision API for Envoy, Ambassador and Nginx started working.

Digging through issues and pull requests[2], I found some recent commits mentioning Traefik ForwardAuth support. Little bit more digging and I found this little gem:

GET /decisions HTTP/1.1
Accept: application/json

with the following description:

This endpoint mirrors the proxy capability of ORY Oathkeeper’s proxy functionality but instead of forwarding the request to the upstream server, returns 200 (request should be allowed), 401 (unauthorized), or 403 (forbidden) status codes.

It’s in the clear, in the REST API documentation! Yes, that’s it.

§what is Oathkeeper

Oathkeeper authorizes HTTP requests by matching a request to a rule from a set of defined rules, applying some guarding logic and allowing or denying a request. Oathkeeper has two modes of work:

  • a proxy: if a request is allowed, it is forwarded to the upstream
  • an arbiter validating a request and returning HTTP success status or an error

In both cases a request originating from the HTTP client must, at least, touch Oathkeeper. In the proxy mode, it flows completely through it.

Oathkeeper applies a maximum of four internal steps to each request:

  • first, it finds if there is any rule matching the request URL and eventually a HTTP method, the matches can be either regexp or glob
  • if there are no matching rules, the request is denied
  • otherwise, the request passes through a pipeline of maximum three types of, let’s call them filters, for a lack of better word:
    • authenticators: responsible for validating credentials
    • authorizers: permissions the subject, basically: is the user behind the request allowed to execute this request
    • mutators: transforms input credentials into upstream credentials

§rules

The rules are always defined as references to JSON or YAML files in the main Oathkeeper YAML configuration. For example /etc/config/ok/oathkeeper.yaml (furhter always referred to as global configuration):

1
2
3
4
access_rules:
    repositories:
        - file:///etc/config/ok/rules.json
        - https://example.com/ok.yaml

More than one repository can be defined, rules can be loaded from Azure Blob Storage, Google Storage, S3, HTTPS, local files and more.

Sadly, there is no support for ETCD or Consul.

An example might look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[
    {
        "id": "some-id",
        "version": "v0.36.0-beta.4",
        "upstream": {
            "url": "http://my-backend-service",
            "preserve_host": true,
            "strip_path": "/api/v1"
        },
        "match": {
            "url": "http://my-app/some-route/<.*>",
            "methods": ["GET", "POST"]
        },
        "authenticators": [{ "handler": "noop" }],
        "authorizer": { "handler": "allow" },
        "mutators": [{ "handler": "noop" }],
        "errors": [{ "handler": "json" }]
    }
]

Rules have unique IDs, can be optionally tagged with a supported Oathekeeper version and are matched according to the match section of the rule.

In the case above, any GET or POST request to any URL starting with http://my-app/some-route/ would be matched and authenticators, the authorizer and mutators would be applied. In this case, the request would be automatically allowed because there is only one noop authenticator. This rule appears to be used as a proxy rule because the upstream is defined.

The decision to allow or deny the request is made based on the evaluation of authenticators and optionally the authorizer. Mutators allow amending the credentials for the authenticator. For example, it could read a bearer token out of a cookie and place it in an Authorization header instead.

In order to use an authenticator, authorizer or a mutator in a rule, the respective item must be enabled in the main Oathkeeper YAML configuration.

If the respective item type requires additional configuration, the minimum configuration must be specified in the global configuration but can be overridden in individual rules.

§authenticators

An authenticator inspects the HTTP request and returns a boolean decision based on the implementation logic. At the time of writing, there are following authenticators available:

  • noop: bypass authentication, authorization and mutation, forward or allow the request further downstream outright
  • unauthorized: outright reject the request
  • anonymous: if there is no Authorization header, set the subject to anonymous (subject can be configured)
  • cookie_session: forwards the request headers, path and method to a session store, basically: validate session based on headers (cookies, these are headers after all…)
  • bearer_token: similar to cookie_session but allows configuring the source of the token: cookie, header or query parameter
  • oauth2_client_credentials: uses the Authorization: Basic to perform OAuth 2.0 credentials grant to check if the credentials are valid, with a bit of reverse proxy trickery, if could potentially facilitate Hydra with credentials grant
  • oauth2_introspection: uses the token introspection endpoint to validate the token and required scopes
  • jwt: requires an Authorization: Bearer and assumes a JWT token, validates the signature of the token

Every authenticator type has its dedicated configuration parameters. The configuration can be specified in the rule. As mentioned earlier, to be able to use an authenticator in the rule, the authenticator must be enabled globally.

For example, to use noop:

1
2
3
authenticators:
    noop:
        enabled: true

§authorizers

An authorizer ensures that the subject (the entity behind the request) has sufficient permissions to issue the request. There are currently five different authorizers with one of them being a Keto 0.5 specific legacy authorizer:

  • allow: permits outright
  • deny: denies outright
  • remote: this one issues a POST request to the configured remote authorization endpoint and sends the original request body, if the remote returns 200 OK, the request is allowed, if the endpoint returns 403 Forbidden, the request is denied
  • remote_json: this one issues a POST request to the configured remote authorization endpoint and sends configured JSON payload in the POST body, if the remote returns 200 OK, the request is allowed, if the endpoint returns 403 Forbidden, the request is denied
  • keto_engine_acp_ory: is the Keto 0.5 specific authorizer, unless you are already using Keto 0.5, you’ll never use this one

As with authenticators, the authorizers have to be explicitly enabled in the global configuration.

For example:

1
2
3
authorizers:
    noop:
        enabled: true

The remote_json authorizer can be used to authorize the request with Keto 0.6 Zanzibar fanciness:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
authorizer:
    handler: remote_json
    config:
        remote: http://keto:4466/check
        payload: |
            {
                "namespace": "default-namespace",
                "subject": "{{ print .Subject }}",
                "object": "reports",
                "relation": "edit"
            }            

The example shows that it is possible to use templating to populate the JSON payload from an AuthenticationSession object, where the respective golang code is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type AuthenticationSession struct {
	Subject      string                 `json:"subject"`
	Extra        map[string]interface{} `json:"extra"`
	Header       http.Header            `json:"header"`
	MatchContext MatchContext           `json:"match_context"`
}

type MatchContext struct {
	RegexpCaptureGroups []string    `json:"regexp_capture_groups"`
	URL                 *url.URL    `json:"url"`
	Method              string      `json:"method"`
	Header              http.Header `json:"header"`
}

Frankly, this bit lacks proper documentation because it is totally not clear where is this constructed, based on what data, how exactly is the subject extracted and when the rest of the data is available.

At least in the context of a JWT bearer token, the subject appears to be the JWT token subject.

§mutators

Mutators transform an incoming credential into an outgoing credential. Following mutators are available:

  • noop: no mutation, forward headers as they came in
  • id_token: converts the subject into a signed ID Token, the back end service can verify this token using the public key of Oathkeeper JWKS
  • header: allows constructing additional headers from the HTTP request
  • cookie: similar to header but constructs named cookies
  • hydrator: allows fetching additional data from an external API and populates the mysterious AuthenticationSession object mentioned earlier, hmmm… maybe this can somehow be used to dynamically populate object and relation in the remote_json authorizer?

§error handlers

Optionally, as the last step of a rule, a method of handling the authorization error can be specified using the errors rule section. Following handlers are available:

  • json: returns the error as a JSON payload with application/json content type
  • redirect: redirect the request to the location using HTTP 301 or 302 status, configurable
  • www_authenticate: responds with HTTP 401 status and the WWW-Authenticate header

The default handler is json. The order of the default handlers can be changed in the global configuration, for example:

1
2
3
4
errors:
    fallback:
        - redirect
        - json

As with any other object type, the error handler type has to be explicitly enabled in the global config, for example:

1
2
3
4
errors:
    handlers:
        json:
            enabled: true

Error handlers can be conditionally matched using when clauses:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
errors:
    redirect:
        enabled: true
        config:
            to: https://bad.robot
            when:
                - request:
                    header:
                        accept:
                            - application/json

§two modes of operation

I have briefly mentioned that Oathkeeper has two modes of operation: the proxy and the pure decision API.

§the proxy

The flow goes roughly like this:

Oathkeeper proxy mode

The request is validated via Oathkeeper. When found okay, it is proxied upstream. It’s possible to instruct Oathkeeper to keep the original request host and strip a path prefix. These request go via the proxy server. The proxy server is configured in the global configuration, for example:

1
2
3
4
serve:
    proxy:
        host: 0.0.0.0
        port: 4455

The proxy server does not define any custom routes, it serves as a catch all router, as you’d expect from, well, a proxy.

§the decision API

This is the interesting one. In the simplest case, it works like this:

Oathkeeper decision API

In this mode, Oathkeeper never sends the requests to upstream. In fact, a rule used with the decision mode does not need to define the upstream section. This would work perfectly fine:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[
    {
        "id": "some-decision-id",
        "match": {
            "url": "http://my-app/some-route/<.*>",
            "methods": ["GET", "POST"]
        },
        "authenticators": [{ "handler": "noop" }],
        "authorizer": { "handler": "allow" },
        "mutators": [{ "handler": "noop" }],
        "errors": [{ "handler": "json" }]
    }
]

Assuming that the client wants to authorize a GET /some-route/abc request, it would send a GET /decisions/some-route/abc request to Oathkeeper in API mode. Oathkeeper would then look up the /some-route/abc rule for GET method and run its regular pipeline on it.

If the request was authorized, a HTTP 200 OK would be returned, otherwise the result would be HTTP 403 Forbidden.

It’s exactly the same as proxy mode but there is no proxying going on.

The API server is configured in the global configuration:

1
2
3
4
serve:
    api:
        host: 0.0.0.0
        port: 4456

and would normally be placed behind a reverse proxy. For example, the following Nginx configuration could take advantage of this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
http {
    #...
    server {
        #...
        location /private/ {
            auth_request /auth;
            auth_request_set $auth_status $upstream_status;
        }
        location = /auth {
            internal;
            proxy_pass http://oathkeeper:4456/decisions$request_uri;
            #...
        }
    }
}

§summary

This was a quick, 10 minute-like, introduction into Oathkeeper. A brain dump of sorts.

I’m not sure if I’d ever use it as a pure proxy but the decision API looks very neat. Putting it behind Traefik would be an awesome feat but first investigations imply that a custom ForwardAuth plugin would be required.

Traefik would be an awesome choice because of automatic ACME and the ability to use a single ForwardAuth to serve multiple sites via single Oathkeeper.

The reason why a custom ForwardAuth might be required, is that Traefik forwards the original request data in headers. The custom ForwardAuth would have to issue the Oathkeeper request constructed from those headers.

Well, it’s not so complicated after all…