Skip to content

Commit

Permalink
Move JWT auth into middleware (#22)
Browse files Browse the repository at this point in the history
* Move JWT auth into middleware

* add a healthcheck

* cleanup lock file on restart
  • Loading branch information
joecorall authored Jan 25, 2025
1 parent 54e5540 commit 6135caf
Show file tree
Hide file tree
Showing 7 changed files with 407 additions and 313 deletions.
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
FROM golang:1.23-bookworm@sha256:2e838582004fab0931693a3a84743ceccfbfeeafa8187e87291a1afea457ff7a

ENV PORT=8080

WORKDIR /app

SHELL ["/bin/bash", "-o", "pipefail", "-c"]
Expand Down Expand Up @@ -32,4 +34,6 @@ RUN go mod download && \
go build -o /app/rollout && \
go clean -cache -modcache

HEALTHCHECK CMD curl -s http://localhost:${PORT}/healthcheck | grep -q ok

ENTRYPOINT [ "/app/rollout"]
10 changes: 6 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
module github.com/lehigh-university-libraries/rollout

go 1.22.0
go 1.23.4

require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/lestrrat-go/jwx v1.2.30
github.com/gorilla/mux v1.8.1
github.com/lestrrat-go/jwx/v2 v2.1.3
github.com/stretchr/testify v1.10.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
17 changes: 10 additions & 7 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,33 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx v1.2.30 h1:VKIFrmjYn0z2J51iLPadqoHIVLzvWNa1kCsTqNDHYPA=
github.com/lestrrat-go/jwx v1.2.30/go.mod h1:vMxrwFhunGZ3qddmfmEm2+uced8MSI6QFWGTKygjSzQ=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
169 changes: 169 additions & 0 deletions lib/handler/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package handler

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"

"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)

type RolloutPayload struct {
DockerImage string `json:"docker-image" env:"DOCKER_IMAGE"`
DockerTag string `json:"docker-tag" env:"DOCKER_TAG"`
GitRepo string `json:"git-repo" env:"GIT_REPO"`
GitBranch string `json:"git-branch" env:"GIT_BRANCH"`
Arg1 string `json:"rollout-arg1" env:"ROLLOUT_ARG1"`
Arg2 string `json:"rollout-arg2" env:"ROLLOUT_ARG2"`
Arg3 string `json:"rollout-arg3" env:"ROLLOUT_ARG3"`
}

type Handler struct{}

func NewHandler() *Handler {
return &Handler{}
}

// LoggingMiddleware logs incoming HTTP requests
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
statusWriter := &statusRecorder{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(statusWriter, r)
duration := time.Since(start)
slog.Info("Incoming request",
"method", r.Method,
"path", r.URL.Path,
"status", statusWriter.statusCode,
"duration", duration,
"client_ip", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
})
}

type statusRecorder struct {
http.ResponseWriter
statusCode int
}

func (rec *statusRecorder) WriteHeader(code int) {
rec.statusCode = code
rec.ResponseWriter.WriteHeader(code)
}

func HealthCheck(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("ok"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
slog.Error("Unable to write for healthcheck", "err", err)
}
}

// JWTAuthMiddleware validates a JWT token and adds claims to the context
func JWTAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a := r.Header.Get("Authorization")
if a == "" || !strings.HasPrefix(strings.ToLower(a), "bearer ") {
http.Error(w, "Missing Authorization header", http.StatusBadRequest)
return
}

tokenString := a[7:]
err := verifyJWT(tokenString)
if err != nil {
slog.Error("JWT verification failed", "err", err)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

next.ServeHTTP(w, r)
})
}

func verifyJWT(tokenString string) error {
keySet, err := fetchJWKS()
if err != nil {
return fmt.Errorf("unable to fetch JWKS: %v", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

token, err := jwt.Parse([]byte(tokenString),
jwt.WithKeySet(keySet),
jwt.WithVerify(true),
jwt.WithContext(ctx),
)
if err != nil {
return fmt.Errorf("unable to parse token: %v", err)
}

if err := validateClaims(token); err != nil {
return fmt.Errorf("unable to validate claims: %v", err)
}

return nil
}

// validateClaims checks if the claims match the expected values
func validateClaims(token jwt.Token) error {
ccStr := os.Getenv("CUSTOM_CLAIMS")
expectedClaims := make(map[string]string)
if ccStr != "" {
err := json.Unmarshal([]byte(ccStr), &expectedClaims)
if err != nil {
return fmt.Errorf("error decoding custom claims: %v", err)
}
}
expectedClaims["aud"] = os.Getenv("JWT_AUD")

for key, expectedValue := range expectedClaims {
value, ok := token.Get(key)
if !ok {
return fmt.Errorf("missing claim: %s", key)
}

switch v := value.(type) {
case string:
if !strings.EqualFold(v, expectedValue) {
return fmt.Errorf("invalid value for claim %s: %s", key, v)
}
case []string:
if !strInSlice(expectedValue, v) {
return fmt.Errorf("invalid value for claim %s: %s", key, v)
}
default:
return fmt.Errorf("unsupported claim type for %s: %T", key, value)
}
}

return nil
}

// fetchJWKS fetches the JSON Web Key Set (JWKS) from the given URI
func fetchJWKS() (jwk.Set, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

jwksURI := os.Getenv("JWKS_URI")
return jwk.Fetch(ctx, jwksURI)
}

func strInSlice(e string, s []string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
Loading

0 comments on commit 6135caf

Please sign in to comment.