Skip to content

Commit

Permalink
feat: HTTP middleware (#214)
Browse files Browse the repository at this point in the history
HTTP middleware for enhancing http.Handler with Clerk authentication.
The http package provides two middleware:
- WithHeaderAuthentication checks the Authorization header for a valid
  JWT and sets the active session claims in the request context.
- RequireHeaderAuthentication responds with 403 Forbidden and halts the
  handler execution chain if it's unable to detect valid session claims.
Added a jwt package which implements the core function to validate a
JWT. The function name is Verify and is used by the middleware to
validate the Bearer JWT.
Added a jwk package which provides operations for the JSON Web Keys API.
We need to be able to fetch the instance JWK in order to validate and
parse the Bearer token.
Added a dependency to go-jose/v3 package for performing operations on
JWTs.
  • Loading branch information
gkats authored Feb 8, 2024
1 parent 987858f commit d7d5a50
Show file tree
Hide file tree
Showing 10 changed files with 649 additions and 1 deletion.
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ module github.com/clerk/clerk-sdk-go/v2

go 1.19

require github.com/stretchr/testify v1.8.2
require (
github.com/go-jose/go-jose/v3 v3.0.1
github.com/stretchr/testify v1.8.2
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 h1:0hQKqeLdqlt5iIwVOBErRisrHJAN57yOiPRQItI20fU=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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
154 changes: 154 additions & 0 deletions http/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Package http provides HTTP utilities and handler middleware.
package http

import (
"net/http"
"strings"
"time"

"github.com/clerk/clerk-sdk-go/v2"
"github.com/clerk/clerk-sdk-go/v2/jwt"
)

// RequireHeaderAuthorization will respond with HTTP 403 Forbidden if
// the Authorization header does not contain a valid session token.
func RequireHeaderAuthorization(opts ...AuthorizationOption) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return WithHeaderAuthorization(opts...)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := clerk.SessionClaimsFromContext(r.Context())
if !ok || claims == nil {
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}))
}
}

// WithHeaderAuthorization checks the Authorization request header
// for a valid Clerk authorization JWT. The token is parsed and verified
// and the active session claims are written to the http.Request context.
// The middleware uses Bearer authentication, so the Authorization header
// is expected to have the following format:
// Authorization: Bearer <token>
func WithHeaderAuthorization(opts ...AuthorizationOption) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authorization := strings.TrimSpace(r.Header.Get("Authorization"))
if authorization == "" {
next.ServeHTTP(w, r)
return
}

token := strings.TrimPrefix(authorization, "Bearer ")
_, err := jwt.Decode(r.Context(), &jwt.DecodeParams{Token: token})
if err != nil {
next.ServeHTTP(w, r)
return
}

params := &AuthorizationParams{}
for _, opt := range opts {
err = opt(params)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
}
params.Token = token
claims, err := jwt.Verify(r.Context(), &params.VerifyParams)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}

// Token was verified. Add the session claims to the request context
newCtx := clerk.ContextWithSessionClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(newCtx))
})
}
}

type AuthorizationParams struct {
jwt.VerifyParams
}

// AuthorizationOption is a functional parameter for configuring
// authorization options.
type AuthorizationOption func(*AuthorizationParams) error

// AuthorizedParty sets the authorized parties that will be checked
// against the azp JWT claim.
func AuthorizedParty(parties ...string) AuthorizationOption {
return func(params *AuthorizationParams) error {
params.SetAuthorizedParties(parties...)
return nil
}
}

// CustomClaims allows to pass a type (e.g. struct), which will be populated with the token claims based on json tags.
// You must pass a pointer for this option to work.
func CustomClaims(claims any) AuthorizationOption {
return func(params *AuthorizationParams) error {
params.CustomClaims = claims
return nil
}
}

// Leeway allows to set a custom leeway when comparing time values
// for JWT verification.
// The leeway gives some extra time to the token. That is, if the
// token is expired, it will still be accepted for 'leeway' amount
// of time.
// This option accomodates for clock skew.
func Leeway(leeway time.Duration) AuthorizationOption {
return func(params *AuthorizationParams) error {
params.Leeway = leeway
return nil
}
}

// ProxyURL can be used to set the URL that proxies the Clerk Frontend
// API. Useful for proxy based setups.
// See https://clerk.com/docs/advanced-usage/using-proxies
func ProxyURL(proxyURL string) AuthorizationOption {
return func(params *AuthorizationParams) error {
params.ProxyURL = clerk.String(proxyURL)
return nil
}
}

// Satellite can be used to signify that the authorization happens
// on a satellite domain.
// See https://clerk.com/docs/advanced-usage/satellite-domains
func Satellite(isSatellite bool) AuthorizationOption {
return func(params *AuthorizationParams) error {
params.IsSatellite = isSatellite
return nil
}
}

// JSONWebKey allows to provide a custom JSON Web Key (JWK) based on
// which the authorization JWT will be verified.
// When verifying the authorization JWT without a custom key, the JWK
// will be fetched from the Clerk API and cached for one hour, then
// the JWK will be fetched again from the Clerk API.
// Passing a custom JSON Web Key means that no request to fetch JSON
// web keys will be made. It's the caller's responsibility to refresh
// the JWK when keys are rolled.
func JSONWebKey(key string) AuthorizationOption {
return func(params *AuthorizationParams) error {
// From the Clerk docs: "Note that the JWT Verification key is not in
// PEM format, the header and footer are missing, in order to be shorter
// and single-line for easier setup."
if !strings.HasPrefix(key, "-----BEGIN") {
key = "-----BEGIN PUBLIC KEY-----\n" + key + "\n-----END PUBLIC KEY-----"
}
jwk, err := clerk.JSONWebKeyFromPEM(key)
if err != nil {
return err
}
params.JWK = jwk
return nil
}
}
64 changes: 64 additions & 0 deletions http/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package http

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/clerk/clerk-sdk-go/v2"
"github.com/stretchr/testify/require"
)

func TestWithHeaderAuthorization_InvalidAuthorization(t *testing.T) {
ts := httptest.NewServer(WithHeaderAuthorization()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := clerk.SessionClaimsFromContext(r.Context())
require.False(t, ok)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})))
defer ts.Close()

clerk.SetBackend(clerk.NewBackend(&clerk.BackendConfig{
HTTPClient: ts.Client(),
URL: &ts.URL,
}))

// Request without Authorization header
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
require.NoError(t, err)
res, err := ts.Client().Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)

// Request with invalid Authorization header
req.Header.Add("authorization", "Bearer whatever")
res, err = ts.Client().Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}

func TestRequireHeaderAuthorization_InvalidAuthorization(t *testing.T) {
ts := httptest.NewServer(RequireHeaderAuthorization()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})))
defer ts.Close()

clerk.SetBackend(clerk.NewBackend(&clerk.BackendConfig{
HTTPClient: ts.Client(),
URL: &ts.URL,
}))

// Request without Authorization header
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
require.NoError(t, err)
res, err := ts.Client().Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, res.StatusCode)

// Request with invalid Authorization header
req.Header.Add("authorization", "Bearer whatever")
res, err = ts.Client().Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, res.StatusCode)
}
57 changes: 57 additions & 0 deletions jwk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package clerk

import (
"crypto/x509"
"encoding/pem"
"fmt"

"github.com/go-jose/go-jose/v3"
)

type JSONWebKeySet struct {
APIResource
Keys []JSONWebKey `json:"keys"`
}

type JSONWebKey struct {
APIResource
raw jose.JSONWebKey
Key any `json:"key"`
KeyID string `json:"kid"`
Algorithm string `json:"alg"`
Use string `json:"use"`
}

func (k *JSONWebKey) UnmarshalJSON(data []byte) error {
err := k.raw.UnmarshalJSON(data)
if err != nil {
return err
}
k.Key = k.raw.Key
k.KeyID = k.raw.KeyID
k.Algorithm = k.raw.Algorithm
k.Use = k.raw.Use
return nil
}

// JSONWebKeyFromPEM returns a JWK from an RSA key.
func JSONWebKeyFromPEM(key string) (*JSONWebKey, error) {
block, _ := pem.Decode([]byte(key))
if block == nil {
return nil, fmt.Errorf("invalid PEM-encoded block")
}

if block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("invalid key type, expected a public key")
}

rsaPublicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}

return &JSONWebKey{
Key: rsaPublicKey,
Algorithm: "RS256",
}, nil
}
46 changes: 46 additions & 0 deletions jwks/jwk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package jwks

import (
"context"
"encoding/json"
"net/http"
"testing"

"github.com/clerk/clerk-sdk-go/v2"
"github.com/clerk/clerk-sdk-go/v2/clerktest"
"github.com/stretchr/testify/require"
)

func TestJWKGet(t *testing.T) {
key := map[string]any{
"use": "sig",
"kty": "RSA",
"kid": "the-kid",
"alg": "RS256",
"n": "the-key",
"e": "AQAB",
}
out := map[string]any{
"keys": []map[string]any{key},
}
raw, err := json.Marshal(out)
require.NoError(t, err)

clerk.SetBackend(clerk.NewBackend(&clerk.BackendConfig{
HTTPClient: &http.Client{
Transport: &clerktest.RoundTripper{
T: t,
Out: raw,
Path: "/v1/jwks",
Method: http.MethodGet,
},
},
}))
jwk, err := Get(context.Background(), &GetParams{})
require.NoError(t, err)
require.Equal(t, 1, len(jwk.Keys))
require.NotNil(t, jwk.Keys[0].Key)
require.Equal(t, key["use"], jwk.Keys[0].Use)
require.Equal(t, key["alg"], jwk.Keys[0].Algorithm)
require.Equal(t, key["kid"], jwk.Keys[0].KeyID)
}
25 changes: 25 additions & 0 deletions jwks/jwks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Package jwks provides access to the JWKS endpoint.
package jwks

import (
"context"
"net/http"

"github.com/clerk/clerk-sdk-go/v2"
)

const path = "/jwks"

type GetParams struct {
clerk.APIParams
}

// Get retrieves a JSON Web Key set.
func Get(ctx context.Context, params *GetParams) (*clerk.JSONWebKeySet, error) {
req := clerk.NewAPIRequest(http.MethodGet, path)
req.SetParams(params)

set := &clerk.JSONWebKeySet{}
err := clerk.GetBackend().Call(ctx, req, set)
return set, err
}
Loading

0 comments on commit d7d5a50

Please sign in to comment.