Skip to content

Commit

Permalink
feat: HTTP middleware
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 committed Feb 5, 2024
1 parent 32f2af8 commit 0f8fb45
Show file tree
Hide file tree
Showing 9 changed files with 586 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
150 changes: 150 additions & 0 deletions http/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Package http provides HTTP utilities and handler middleware.
package http

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

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

// RequireAuthorization attempts to get Clerk authorization claims
// from the http.Request context and responds with status 403 Forbidden
// if it fails to verify the claims.
func RequireHeaderAuthorization(ctx context.Context, opts ...AuthorizationOption) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return WithHeaderAuthorization(ctx, 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(ctx context.Context, 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(ctx, &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(ctx, &params.VerifyParams)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}

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

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 based on which
// the authorization JWT will be verified.
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
}
}
67 changes: 67 additions & 0 deletions http/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package http

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

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

func TestWithHeaderAuthentication_InvalidAuthorization(t *testing.T) {
ctx := context.Background()
ts := httptest.NewServer(WithHeaderAuthorization(ctx)(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 TestRequireHeaderAuthentication_InvalidAuthorization(t *testing.T) {
ctx := context.Background()
ts := httptest.NewServer(RequireHeaderAuthorization(ctx)(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
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.JSONWebKey.UnmarshalJSON(data)
if err != nil {
return err
}
k.Key = k.JSONWebKey.Key
k.KeyID = k.JSONWebKey.KeyID
k.Algorithm = k.JSONWebKey.Algorithm
k.Use = k.JSONWebKey.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: %v", err)
}

return &JSONWebKey{
Key: rsaPublicKey,
Algorithm: "RS256",
}, nil
}
25 changes: 25 additions & 0 deletions jwk/jwk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Package jwk provides the JSON Web Tokens API.
package jwk

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
}
46 changes: 46 additions & 0 deletions jwk/jwk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package jwk

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)
}
Loading

0 comments on commit 0f8fb45

Please sign in to comment.