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 /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)
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" } ] }
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>
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.
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!
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.
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:
- DNS is always potential source of error
- VPC settings can also mess things up easily
- 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