Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: HTTP middleware #214

Merged
merged 1 commit into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Conceptually speaking, what's the difference between r.Context() and ctx here?

Actually, why does WithHeaderAuthorization accepts a context in the first place? Shouldn't it work off of the incoming request's context?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, I had the same doubts.

I left the ctx context for possible future usage.

Although it's not actually needed, if we don't add it now and we need it in a future version, we'll break the API.

Do you think it's better to remove it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I can think of a good usage pattern here too, since the middleware setup doesn't happen within an operation context. Perhaps we should remove it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the context from the arguments.

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
agis marked this conversation as resolved.
Show resolved Hide resolved
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ I think you're already aware but just adding a reference for #202 before we solidify the option design!

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Shall we validate that proxyURL is indeed a valid URL?

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Is there any reason for someone to call this as Satellite(false)? If not, then perhaps this should be simplified to Satellite() (and always set params.IsSatellite = true).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess there's not. I mainly copied whatever was there before. The only thing I can think of is the usage difference

opts := []Opt{}
if isSatellite {
  opts = append(opts, Satellite())
}
WithHeaderAuthorization(opts...)

// vs
WithHeaderAuthorization(Satellite(isSatellite))

I believe the above is a valid case for leaving the API as is.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Might be worth adding a unit test here, even for illustrating the use a bit better.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intention was to add an integration test for the options, but setting the tests up took more time than expected. I'm planning to get back to this once I find some time.

// PEM format, the header and footer are missing, in order to be shorter
// and single-line for easier setup."
if !strings.HasPrefix(key, "-----BEGIN") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether it's worth also checking for HasSuffix here, or is it fine to assume it will be well-formed? It's an edge case, but it might be more fool-proof to do:

if !strings.HasPrefix(...) {
  // add prefix
}

if !strings.HasSuffix(...) {
  // add suffix
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this in general.

If it was my decision, I think we should always require the full key here.

I simply carried over the logic from v1, but I could add a check for the suffix as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that it would be more fool-proof than what v1 does, but then again I understand if you decide it's not even worth it, since it's an edge case. Up to you! :)

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ There's probably good reason, I just don't see it yet, would you mind explaining quickly why the custom deserializer is required?

Copy link
Member Author

@gkats gkats Feb 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to avoid using a go-jose type and instead make sure we use only our own package's (clerk) types everywhere.

There's no reason to expose types that are meant for internal use anywhere. We unfortunately do this in v1 of our SDK.

The go-jose library is able to decode JSON web keys and we can leverage that to get a response from our Backend API GET /v1/jwks endpoint and translate it to our own clerk.JSONWebKeySet and effectively clerk.JSONWebKey.

Unfortunately, it looks like extracting the key ID and key from a JWK response is not that straightforward. Depending on the algorithm that's used the JWK response might have different fields set, each with different meaning.

The go-jose library handles unmarshaling nicely, but its raw unmarshaled type is unexported.

So, what I thought we could do is use the jose.JSONWebKey type to unmarshal the raw JSON and then copy over the resulting fields to our type.

I initially tried type aliasing but it didn't work as expected.

Hope the above makes the decision clearer. Let me know if I should offer more details! Perhaps I can add a comment explaining what's going on here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to avoid using a go-jose type and instead make sure we use only our own package's (clerk) types everywhere. There's no reason to expose types that are meant for internal use anywhere. We unfortunately do this in v1 of our SDK.

Very good call. This provides us with flexibility to change the underlying library (go-jose) without it being a breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, yes good call indeed. Now that the field is unexported the purpose is also much clearer!

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
Loading