-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
10 changed files
with
649 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), ¶ms.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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.