There are quite a few authorization services out there. Auth0, AWS Cognito, and Firebase are some of the most popular, but they lack the ability to fine tune the front end appearance beyond changing some colors and stroke widths. If you want full control over your UI like I did, Ory offers just what you need. With Ory, you get as much control as you want, and you can even dive into the code itself since it’s all open source.

This article will demonstrate how to use one piece of the Ory ecosystem, Oathkeeper, as an authorizer for requests passing through AWS API gateway. This is just one way Oathkeeper can be used, the documentation explains other ways including using it as a reverse proxy, or with other open source services like traefik.

What you’ll need

Deploying Oathkeeper to Lambda would seem simple since it only requires four files, but knowing what those files should contain can be tricky. I’ll show you what worked for me in my deployment and hopefully it will work for you too. Here are the four files you’ll need:

  • A config.yml file for Oathkeeper.
  • A rules.json file containing the list of rules that Oathkeeper will use to decided what to do with incoming requests.
  • The Go executable containing the logic for the lambda function.
  • A JSON web keys file for Oathkeeper to use if you’re using an id_token mutator for any of your rules.

Config File

Your config file will likely be different depending on your setup, but here is what I used:

# config.yml

serve:
  proxy:
    port: 4455 # run the proxy at port 4455
  api:
    port: 4456 # run the api at port 4456

access_rules:
  repositories:
    - file:///var/task/rules.json
  matching_strategy: regexp

log:
  level: debug
  leak_sensitive_values: false

authorizers:
  allow:
    enabled: true
  deny:
    enabled: true

authenticators:
  anonymous:
    enabled: true
    config:
      subject: guest
  cookie_session:
    enabled: true
    config:
      check_session_url: https://slug.projects.oryapis.com/sessions/whoami
      preserve_path: true
      only:
        - ory_session_slughere
      extra_from: "@this"
      subject_from: "identity.id"

errors:
  fallback:
    - json
  handlers:
    json:
      enabled: true
      config:
        verbose: true
    redirect:
      enabled: true
      config:
        to: https://www.ory.sh/docs

mutators:
  noop:
    enabled: true
  id_token:
    enabled: true
    config:
      issuer_url: http://oathkeeper:4455/
      jwks_url: file:///tmp/jwks.json
      ttl: 60s
      claims: |
        {
          "session": {{ .Extra | toJson }}
        }

Rules

You’ll need to have a list of rules defined in a json file for your deployment, and have that file specified in your config.yml file. I believe it’s possible to have your rules hosted remotely instead of including them with your lambda zip file, but in my case I did the latter. I found it easier to write out my rules in a rules.jsonnet file for better maintainability, and then compile it down to a rules.json file.

Json Web Keys

The Oathkeeper CLI provide a command that will create jwks for you. Take this file and put it on aws secrets manager. Name it oathkeeper_jwks (name not critical).

oathkeeper credentials generate --alg RS256 > jwks.json

Golang Executable

This was the trickiest part for me to figure out. In a local environment, it’s easy enough to have a docker compose file that uses Traefik and Oathkeeper running on their own containers.

Docker file for Oathkeeper in development

# Stage 1: Build the rules.json file
FROM alpine:3.14 as builder

# Install jsonnet
RUN apk add --no-cache jsonnet
COPY ./rules.jsonnet ./rules.jsonnet

# Build rules.json from rules.jsonnet
ARG API_VERSION

RUN jsonnet rules.jsonnet --ext-str domain=localhost --ext-str version=$API_VERSION -o /rules.json;

# Copy artifacts to a clean image
# Stage 2: Oathkeeper setup
FROM oryd/oathkeeper:v0.40.3

COPY ./config.dev.yml /etc/config/oathkeeper/config.yml
COPY --from=builder /rules.json /etc/config/oathkeeper/rules.json

Docker compose file for development

# docker-compose.yml
---
oathkeeper:
  build:
    args:
      - API_VERSION=1
    context: ./oathkeeper
  entrypoint: oathkeeper
  command: serve --config /etc/config/oathkeeper/config.yml
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.oathkeeper.rule=Host(`oathkeeper`)"
    - "traefik.http.routers.oathkeeper.entrypoints=web"
    - "traefik.http.routers.oathkeeper.service=oathkeeper"
    - "traefik.http.services.oathkeeper.loadbalancer.server.port=4456"
    - "traefik.http.middlewares.oathkeeper.forwardauth.address=http://oathkeeper:4456/decisions"
    - "traefik.http.middlewares.oathkeeper.forwardauth.authResponseHeaders=X-User,
      Authorization"
  secrets:
    - jwks.json
proxy:
  image: traefik:v2.10
  command:
    - "--api.insecure=true"
    - "--entrypoints.web.address=:80"
    - "--entrypoints.websecure.address=:443"
    - "--entryPoints.websecure.forwardedHeaders.insecure"
    # ^^ needed so that we can check ip address of the ory webhook response in the django middleware
    - "--providers.docker=true"
    - "--providers.docker.exposedbydefault=false"
    - "--providers.file.directory=/traefik"
    - "--providers.file.watch=true"
    - "--log.level=DEBUG"
  ports:
    - 80:80
    - 443:443
    - 8080:8080
  volumes:
    - ./front/certs:/etc/ssl/certs
    - ./traefik:/traefik
    - /var/run/docker.sock:/var/run/docker.sock

As I mentioned, this works well enough in development, but when it comes to production, things are a little different. If you want to use AWS API gateway to control access to your application, then the only way to integrate it with Ory, is to use lambda authorizers. As you see in the docker compose file, you just use oathkeeper serve to run the service. You could probably run Oathkeeper as a background process on a docker container and deploy that to Lambda, but let’s keep things a little leaner.

Instead of running Oathkeeper as a long running process exposed on a port, we’ll examine the Oathkeeper source code and find whatever code we need to place in a go program for the executable which Lambda will run.

Here is the final Go program. Pay attention to the init process. Since we’re not running Oathkeeper from the command line, we’ll have to create the command flags ourself. With the flags, you can start the driver for oathkeeper and initialize the repository, which will go and grab all of the rules from the rules.json file. Finally, in the init process, you’ll set the handler which is scoped globally and can be accessed by the other functions. The last thing I’ll point out is where oathkeeper actually handles the incoming request. Typically, in the actual Oathkeeper source code, ServeHTTP is used as middleware, but we’ll call it directly. The method gets passed a response writer and request with some headers set. These headers are where oathkeeper will look for the information to find a matching rule in your rule.json file.

package main

import (
 "context"
 "encoding/json"
 "fmt"
 "log"
 "net/http"
 "net/http/httptest"
 "os"
 "path/filepath"

 "github.com/aws/aws-lambda-go/events"
 "github.com/aws/aws-lambda-go/lambda"
 "github.com/aws/aws-sdk-go-v2/config"
 "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
 "github.com/aws/aws-sdk-go/aws"
 "github.com/aws/aws-secretsmanager-caching-go/secretcache"
 "github.com/ory/oathkeeper/api"
 "github.com/ory/oathkeeper/driver"
 "github.com/ory/x/logrusx"
 "github.com/spf13/pflag"
)

var (
 version        = "0.4.0"
 build          = "unknown"
 date           = "unknown"
 h              *api.DecisionHandler
 d              driver.Driver
 DecisionPath   = "/decisions"
 oathkeeperHost = "oathkeeper:4456"
)

func generateAuthResponse(effect string, resource string) events.APIGatewayCustomAuthorizerResponse {
 authResponse := events.APIGatewayCustomAuthorizerResponse{
  PrincipalID: "user",
  PolicyDocument: events.APIGatewayCustomAuthorizerPolicy{
   Version: "2012-10-17",
   Statement: []events.IAMPolicyStatement{
    {
     Action: []string{"execute-api:Invoke"},
     Effect: effect,
     Resource: []string{
      "arn:aws:execute-api:*:*:*/*/*",
      resource,
     },
    },
   },
  },
 }

 return authResponse
}

func generateAllow(resource string, decision *http.Response) events.APIGatewayCustomAuthorizerResponse {
 authResponse := generateAuthResponse("Allow", resource)
 context := map[string]interface{}{}

 if decision.Header.Get("Authorization") != "" {
  context["authorization"] = decision.Header.Get("Authorization")
 }
 if decision.Header.Get("X-User") != "" {
  context["x-user"] = decision.Header.Get("X-User")
 }
 authResponse.Context = context

 return authResponse
}

func generateDeny(resource string) events.APIGatewayCustomAuthorizerResponse {
 return generateAuthResponse("Deny", resource)
}

func getCachedJwks() (map[string]interface{}, error) {

 var secretCache, _ = secretcache.New()

 result, err := secretCache.GetSecretString("oathkeeper_jwks")
 if err != nil {
  return nil, err
 }

 var jwks map[string]interface{}
 if err := json.Unmarshal([]byte(result), &jwks); err != nil {
  return nil, err
 }

 return jwks, nil
}

func getStoredJwks() (map[string]interface{}, error) {
 secretName := "oathkeeper_jwks"
 secretRegion := os.Getenv("AWS_REGION")
 if secretRegion == "" {
  secretRegion = "us-west-2"
 }

 config, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(secretRegion))
 if err != nil {
  log.Fatal(err)
 }
 svc := secretsmanager.NewFromConfig(config)
 input := &secretsmanager.GetSecretValueInput{
  SecretId:     aws.String(secretName),
  VersionStage: aws.String("AWSCURRENT"),
 }

 result, err := svc.GetSecretValue(context.TODO(), input)
 if err != nil {
  log.Fatal(err.Error())
 }

 var secretString string = *result.SecretString
 var jwks map[string]interface{}
 if err := json.Unmarshal([]byte(secretString), &jwks); err != nil {
  return nil, err
 }

 return jwks, nil
}

func getJwksData() ([]byte, error) {
 cachedJwks, err := getCachedJwks()
 if err != nil || cachedJwks == nil {
  fmt.Println("Failed to get cached JWKS. Getting from Secrets Manager...")
  storedJwks, err := getStoredJwks()
  if err != nil {
   return nil, err
  }
  return json.Marshal(storedJwks)
 }

 jwksData, err := json.Marshal(cachedJwks)
 if err != nil {
  return nil, err
 }

 return jwksData, nil
}

func setJwks() {
 // Get JWKS from AWS Secrets Manager
 jwksData, err := getJwksData()
 if err != nil {
  fmt.Printf("Failed to get JWKS data: %v\n", err)
  return
 }

 // Write JWKS to file
 jwksFilePath := "/tmp/jwks.json"
 os.MkdirAll(filepath.Dir(jwksFilePath), 0755)

 jwksFile, err := os.Create(jwksFilePath)
 if err != nil {
  fmt.Printf("Failed to create JWKS file: %v\n", err)
  return
 }

 jwksFile.Write(jwksData)
 jwksFile.Close()
}

func init() {
 setJwks()

 configFile := "./config.yml"

 // Initialize Oathkeeper
 okFlags := pflag.NewFlagSet("serve", pflag.ContinueOnError)
 okFlags.StringSlice("config", []string{configFile}, "Path to a configuration file")

 logger := logrusx.New("ORY Oathkeeper", version)

 d = driver.NewDefaultDriver(logger, version, build, date, okFlags)
 d.Registry().Init()
 h = d.Registry().DecisionHandler()
}

func getDecision(event events.APIGatewayProxyRequest) (*http.Response, error) {

 req, err := http.NewRequest(event.RequestContext.HTTPMethod, event.Path, nil)

 req.URL.Path = DecisionPath
 req.Host = oathkeeperHost
 req.Proto = "HTTP/1.1"
 req.Header.Add("X-Forwarded-Method", event.RequestContext.HTTPMethod)
 req.Header.Add("X-Forwarded-Proto", "https")
 req.Header.Add("X-Forwarded-Host", event.RequestContext.DomainName)
 req.Header.Add("X-Forwarded-Uri", event.Path)
 req.Header.Add("X-Forwarded-For", event.RequestContext.Identity.SourceIP)
 req.Header.Add("cookie", event.Headers["cookie"])

 if event.Headers["User-Agent"] != "" {
  req.Header.Add("User-Agent", event.Headers["User-Agent"])
 }

 if err != nil {
  return nil, err
 }

 next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
 rw := httptest.NewRecorder()
 h.ServeHTTP(rw, req, next)

 return rw.Result(), nil
}

func HandleRequest(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayCustomAuthorizerResponse, error) {
 decision, err := getDecision(event)

 if err != nil || decision.StatusCode != 200 {
  return generateDeny(event.Path), err
 } else {
  return generateAllow(event.Path, decision), nil
 }
}

func main() {
 // Start Lambda handler
 lambda.Start(HandleRequest)
}

A few notes on the code. The init function will only be called during cold starts, and during subsequent calls to your lambda function, the decision handler, repository, jwks, will all be ready to go.

Bring It All Together

Your jwks should already be stored on AWS Secrets Manager, so let’s combine the files for deployment to Lambda.

Compile the Go binary

GOOS=linux GOARCH=arm64 go build -tags lambda.norpc -o bootstrap main.go

Zip everything together

zip -r authorizer.zip bootstrap config.yml rules.json

Deployment

(An AWS API gateway should already be set up)

  1. Create an iam policy for reading the jwks in the secrets manager with the following policy:

    {
      "Version": "2012-10-17",
      "Statement": [
          {
              "Sid": "VisualEditor0",
              "Effect": "Allow",
              "Action": [
                  "secretsmanager:GetSecretValue",
                  "secretsmanager:DescribeSecret"
              ],
              "Resource": "secret_arn"
          }
      ]
    }
  2. Create an iam role for Oathkeeper and attach the policy from the previous step. Run the AWS CLI command to create your authorizer:

    aws lambda create-function
    --function-name oathkeeper
    --handler main
    --code authorizer.zip
    --runtime provided.al2023
    --architectures arm64
    --role <role from previous step>
  3. Create the authorizer for AWS API gateway

    Navigate to AWS API gateway and click on the name of your API. On the left side panel, click authorizers. Create your authorizer and select your lambda function from the list. Select request as the lambda event payload. Save your changes.

  4. Attach the authorizer to your resource

    On the left side panel now click resources. For any resource you wish you protect with Oathkeeper, click on it, and select method request. From the dropdown select your authorizer and click save. You should be good to go now!

  5. Map HTTP headers

    In my setup, I have it configured so for most of the requests being verified by Oathkeeper, a jwt is returned in the Authorization header. You’ll see in the response of the lambda function that in the context, we have an authorization parameter. In order to have this set in the header of the request going from AWS API gateway to our backend service, we need to set up some a mapping. Go to the resource of your api gateway, and click on “Integration request”. Click edit and go to “URL request header parameters”. Add Authorization: context.authorization and X-User: context.x-user. Now you should see the proper headers set in the requests to you backend service.

  6. Health check

    Curl an endpoint on your backend to see your response. If you’re getting errors, you can always check the logs to see what was going wrong. For troubleshooting, here are a few good places to start:

    1. DNS is always potential source of error
    2. VPC settings can also mess things up easily
    3. Make sure your handler name is main for the lambda function

Happy Coding

Hope this helped you with deploying Oathkeeper to Lambda. Feel free to reach out to me on X @scmitton if you have any questions or just want to connect! I’m always eager to meet other developers and hear about what they’re building.

Resources

Never miss an article - Subscribe to our newsletter!