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

feat: HTTP middleware #214

merged 1 commit into from
Feb 8, 2024

Conversation

gkats
Copy link
Member

@gkats gkats commented Feb 5, 2024

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.

Example usage:

ctx := context.Background()
rootHandler := func(w http.ResponseWriter, r *http.Request) {
  claims, _ := clerk.SessionClaimsFromContext(r.Context())
  w.Write([]byte(fmt.Sprintf(`{"user_id":"%s"}`, claims.UserID)))
}
// Adds active session claims to the request context
http.HandleFunc("/", clerkhttp.WithHeaderAuthentication(ctx)(rootHandler)

Please note that cookie based authentication is not supported. The SDK v1 middleware supported cookie authentication as well.

Added a new 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. The middleware above uses the Verify() method internally.

Added a new 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.

@gkats gkats requested a review from a team as a code owner February 5, 2024 15:03
@gkats gkats force-pushed the core-1510-middleware branch from 4f2ec26 to 0f8fb45 Compare February 5, 2024 15:07
Copy link
Contributor

@georgepsarakis georgepsarakis left a comment

Choose a reason for hiding this comment

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

👏 Just a few questions, overall looks fine to me.

jwk.go Outdated

rsaPublicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %v", err)
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 %v will print the error struct in Go format?

Suggested change
return nil, fmt.Errorf("failed to parse public key: %v", err)
return nil, fmt.Errorf("failed to parse public key: %s", err.Error())

Copy link
Member Author

Choose a reason for hiding this comment

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

👍 You are right, copy-pasted it from v1. Changed it to wrap the error with %w instead.

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!

jwk/jwk.go Outdated
@@ -0,0 +1,25 @@
// Package jwk provides the JSON Web Tokens API.
package jwk
Copy link
Contributor

Choose a reason for hiding this comment

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

❓ Would jwks be a more easily recognizable name? I'd assume there's more internal/external familiarity but not sure.

Copy link
Member Author

Choose a reason for hiding this comment

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

Indeed. I've opted for a singular term for all packages so far and thought I'd continue the same pattern. Happy to change it though if you think it's a problem.

Copy link
Member

Choose a reason for hiding this comment

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

Since "jwks" is not a plural term but an acronym, I think the singular/plural rationale (which I'm all for!) does not apply here, right? If so, I tend to agree with George's suggestion.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, initially I thought I'd treat this as a "JSON Web Key" API (hence jwk).

I guess everybody else expects the "JSON Web Key Set" API, so I've changed it.

// 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
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.

"github.com/stretchr/testify/require"
)

func TestWithHeaderAuthentication_InvalidAuthorization(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

❓ Don't you need a successful test case as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, setting up tests for token verification is cumbersome, but I'll get back to this. I'll open a ticket to track this.

jwt.go Outdated

type key string

const activeSessionClaims = key("activeSessionClaims")
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 We can prefix/namespace the context key with clerk so the scope is even clearer if someone inspects its contents.

Copy link
Member Author

Choose a reason for hiding this comment

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

Great idea! Updated.

jwt.go Show resolved Hide resolved
jwt/jwt.go Show resolved Hide resolved
@gkats gkats force-pushed the core-1510-middleware branch from 0f8fb45 to e9f0b8c Compare February 6, 2024 13:31
"github.com/stretchr/testify/require"
)

func TestWithHeaderAuthentication_InvalidAuthorization(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

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

🙃 Looks like there's a mix-up between Authentication and Authorization, even in the PR description. But Authorization seems to be the right term for this case.

Suggested change
func TestWithHeaderAuthentication_InvalidAuthorization(t *testing.T) {
func TestWithHeaderAuthorization_InvalidAuthorization(t *testing.T) {

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, the problem is that the header is called Authorization but it's often used for authentication. I'll try to reconcile.

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated!

@gkats gkats force-pushed the core-1510-middleware branch from e9f0b8c to a1ac263 Compare February 7, 2024 10:24
Copy link
Member

@agis agis left a comment

Choose a reason for hiding this comment

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

Amazing work! Really like the new direction where this is headed 💯 👏🏽

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

// RequireAuthorization attempts to get Clerk authorization claims
Copy link
Member

Choose a reason for hiding this comment

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

🔧 s/RequireAuthorization/RequireHeaderAuthorization

Also, how about the following?

RequireHeaderAuthorization will respond with HTTP 403 if the Authorization header does not contain a valid session token.

Copy link
Member Author

Choose a reason for hiding this comment

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

👍 Nice, updated!

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())
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.

http/middleware.go Show resolved Hide resolved
}

// Token was verified. Add the session claims to the request context
sessionCtx := clerk.ContextWithSessionClaims(r.Context(), claims)
Copy link
Member

Choose a reason for hiding this comment

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

🙃 Perhaps newCtx is a more accurate name? Perhaps not introducing a variable at all would be OK here as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated to newCtx.

}

// JSONWebKey allows to provide a custom JSON Web Key based on which
// the authorization JWT will be verified.
Copy link
Member

Choose a reason for hiding this comment

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

🔧 I believe it's worth noting here the runtime implications this option has. For example, a few questions raised when I read this:

  1. if I set this, does it mean my SDK will never query the FAPI/BAPI JWKs endpoints?
    a. if so, what happens if the keys are rolled and hence the JWKs need to be refreshed?
    b. if not, how often the JWKs values are queried via FAPI/BAPI?

Copy link
Member Author

Choose a reason for hiding this comment

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

Enhanced the comment with more info.

jwt/jwt.go Show resolved Hide resolved
jwt/jwt.go Outdated

// Decode decodes a JWT without verifying it. This is a quick way
// to validate the format of a JSON web token, but the resulting Claims
// should be considered untrusted.
Copy link
Member

Choose a reason for hiding this comment

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

🔧 It's worth putting this under a WARNING: The token is not validated, therefore the returned Claims should NOT be trusted.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

Use string `json:"use"`
}

func (k *JSONWebKey) UnmarshalJSON(data []byte) error {
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.

jwk.go Outdated

type JSONWebKey struct {
APIResource
jose.JSONWebKey
Copy link
Member

Choose a reason for hiding this comment

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

🔧 Since we don't want to expose jose, shouldn't we avoid embedding this and instead wrap it with our own implementation?

Copy link
Member Author

Choose a reason for hiding this comment

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

Well, yes! Great catch, I missed this. Used an unexported field instead.

jwk/jwk.go Outdated
@@ -0,0 +1,25 @@
// Package jwk provides the JSON Web Tokens API.
Copy link
Member

Choose a reason for hiding this comment

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

🔧 How about something like:

Package jwks provides access to the JWKs endpoint.

See https://clerk.com/docs/reference/backend-api/tag/JWKS

Copy link
Member Author

Choose a reason for hiding this comment

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

👍 Updated.

@gkats gkats force-pushed the core-1510-middleware branch 3 times, most recently from 6b18595 to ca6040b Compare February 7, 2024 14:22
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())
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?

}

token := strings.TrimPrefix(authorization, "Bearer ")
_, err := jwt.Decode(ctx, &jwt.DecodeParams{Token: token})
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 need the Request context here:

Suggested change
_, err := jwt.Decode(ctx, &jwt.DecodeParams{Token: token})
_, err := jwt.Decode(r.Context(), &jwt.DecodeParams{Token: token})


// 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!

}
}
params.Token = token
claims, err := jwt.Verify(ctx, &params.VerifyParams)
Copy link
Contributor

Choose a reason for hiding this comment

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

🔧 Suggestion for a later PR to perhaps consider allowing client injection dependency here for testing.

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.

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


// ContextWithSessionClaims returns a new context which includes the
// active session claims.
func ContextWithSessionClaims(ctx context.Context, value any) context.Context {
Copy link
Contributor

Choose a reason for hiding this comment

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

🔧 Shouldn't the value type here be *SessionClaims 🤔 ?

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'm not sure about that. TBD after we settle on custom type for the claims.

// JWK is a custom JSON Web Key that can be provided to skip
// fetching one.
JWK *clerk.JSONWebKey
CustomClaims any
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh just saw your comment here, please ignore my comment above referencing the same issue 😬

jwt/jwt.go Outdated
return nil, err
}

err = claims.Claims.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, params.Leeway)
Copy link
Contributor

Choose a reason for hiding this comment

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

🔧

Suggested change
err = claims.Claims.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, params.Leeway)
err = claims.Claims.ValidateWithLeeway(jwt.Expected{Time: time.Now().UTC()}, params.Leeway)

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.
@gkats gkats force-pushed the core-1510-middleware branch from ca6040b to 4419472 Compare February 7, 2024 17:12
Copy link
Member

@agis agis left a comment

Choose a reason for hiding this comment

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

Approving for expediency, I don't see any blockers here since we're not releasing yet. Awesome work! 💯

// 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?

// 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.

// 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") {
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! :)

}

type DecodeParams struct {
Token string
Copy link
Member

Choose a reason for hiding this comment

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

❓ What's the reason for wrapping Token in a struct? Is it for providing us a way to add more options without breaking compatibility (i.e. keeping the function signature intact)?

If so, I'm having a hard time imagining something that Decode would need to be extended with, that a functional option wouldn't be a fit for. In other words, perhaps it's perfectly fine to go with Decode(ctx, string) and in the future extend with functional options if needed? The reason I'm suggesting this is because the latter seems a simpler/easier to use API to me.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, we're trying to use custom types for method arguments so that we can easily extend them in the future without introducing breaking changes in the API.

return nil, fmt.Errorf("invalid issuer %s", iss)
}

if claims.AuthorizedParty != "" && len(params.authorizedParties) > 0 {
Copy link
Member

Choose a reason for hiding this comment

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

Interesting. That's maybe worthy of more investigation, but it's out of scope of this PR. I'll create an internal ticket to investigate further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants