From 4419472fa0838e4a99e44a50204904fd695ddfed Mon Sep 17 00:00:00 2001 From: Giannis Katsanos Date: Sat, 3 Feb 2024 16:56:07 +0200 Subject: [PATCH] feat: HTTP middleware 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. --- go.mod | 6 +- go.sum | 13 ++++ http/middleware.go | 154 +++++++++++++++++++++++++++++++++++++++ http/middleware_test.go | 64 +++++++++++++++++ jwk.go | 57 +++++++++++++++ jwks/jwk_test.go | 46 ++++++++++++ jwks/jwks.go | 25 +++++++ jwt.go | 67 +++++++++++++++++ jwt/jwt.go | 156 ++++++++++++++++++++++++++++++++++++++++ jwt_test.go | 62 ++++++++++++++++ 10 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 http/middleware.go create mode 100644 http/middleware_test.go create mode 100644 jwk.go create mode 100644 jwks/jwk_test.go create mode 100644 jwks/jwks.go create mode 100644 jwt.go create mode 100644 jwt/jwt.go create mode 100644 jwt_test.go diff --git a/go.mod b/go.mod index c05a2e5e..ec39be71 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 6a56e69b..998ac782 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/http/middleware.go b/http/middleware.go new file mode 100644 index 00000000..e8458b1e --- /dev/null +++ b/http/middleware.go @@ -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 +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 + } +} diff --git a/http/middleware_test.go b/http/middleware_test.go new file mode 100644 index 00000000..76047a3c --- /dev/null +++ b/http/middleware_test.go @@ -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) +} diff --git a/jwk.go b/jwk.go new file mode 100644 index 00000000..f1968c50 --- /dev/null +++ b/jwk.go @@ -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 +} diff --git a/jwks/jwk_test.go b/jwks/jwk_test.go new file mode 100644 index 00000000..d392dc15 --- /dev/null +++ b/jwks/jwk_test.go @@ -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) +} diff --git a/jwks/jwks.go b/jwks/jwks.go new file mode 100644 index 00000000..096c3dfe --- /dev/null +++ b/jwks/jwks.go @@ -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 +} diff --git a/jwt.go b/jwt.go new file mode 100644 index 00000000..cbec75fa --- /dev/null +++ b/jwt.go @@ -0,0 +1,67 @@ +package clerk + +import ( + "context" + "encoding/json" + + "github.com/go-jose/go-jose/v3/jwt" +) + +type key string + +const clerkActiveSessionClaims = key("clerkActiveSessionClaims") + +// ContextWithSessionClaims returns a new context which includes the +// active session claims. +func ContextWithSessionClaims(ctx context.Context, value any) context.Context { + return context.WithValue(ctx, clerkActiveSessionClaims, value) +} + +// SessionClaimsFromContext returns the active session claims from +// the context. +func SessionClaimsFromContext(ctx context.Context) (*SessionClaims, bool) { + claims, ok := ctx.Value(clerkActiveSessionClaims).(*SessionClaims) + return claims, ok +} + +// SessionClaims represents Clerk specific JWT claims. +type SessionClaims struct { + jwt.Claims + SessionID string `json:"sid"` + AuthorizedParty string `json:"azp"` + ActiveOrganizationID string `json:"org_id"` + ActiveOrganizationSlug string `json:"org_slug"` + ActiveOrganizationRole string `json:"org_role"` + ActiveOrganizationPermissions []string `json:"org_permissions"` + Actor json.RawMessage `json:"act,omitempty"` +} + +// HasPermission checks if the session claims contain the provided +// organization permission. +// Use this helper to check if a user has the specific permission in +// the active organization. +func (s *SessionClaims) HasPermission(permission string) bool { + for _, sessPermission := range s.ActiveOrganizationPermissions { + if sessPermission == permission { + return true + } + } + return false +} + +// HasRole checks if the session claims contain the provided +// organization role. +// However, the HasPermission helper is the recommended way to +// check for permissions. Complex role checks can usually be +// translated to a single permission check. +// For example, checks for an "admin" role that can modify a resource +// can be replaced by checks for a "modify" permission. +func (s *SessionClaims) HasRole(role string) bool { + return s.ActiveOrganizationRole == role +} + +// Claims holds generic JWT claims. +type Claims struct { + jwt.Claims + Extra map[string]any +} diff --git a/jwt/jwt.go b/jwt/jwt.go new file mode 100644 index 00000000..f1fbb635 --- /dev/null +++ b/jwt/jwt.go @@ -0,0 +1,156 @@ +// Package jwt provides operations for decoding and validating +// JSON Web Tokens. +package jwt + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/clerk/clerk-sdk-go/v2" + "github.com/clerk/clerk-sdk-go/v2/jwks" + "github.com/go-jose/go-jose/v3/jwt" +) + +type VerifyParams struct { + // Token is the JWT that will be verified. + Token string + // JWK is a custom JSON Web Key that can be provided to skip + // fetching one. + JWK *clerk.JSONWebKey + CustomClaims any + // Leeway is the duration which the JWT is considered valid after + // it's expired. Useful for defending against server clock skews. + Leeway time.Duration + // IsSatellite signifies that the JWT is verified on a satellite domain. + IsSatellite bool + // ProxyURL is the URL of the server that proxies the Clerk Frontend API. + ProxyURL *string + // List of values that should match the azp claim. + // Use SetAuthorizedParties to set the value. + authorizedParties map[string]struct{} +} + +// SetAuthorizedParties accepts a list of authorized parties to be +// set on the params. +func (params *VerifyParams) SetAuthorizedParties(parties ...string) { + azp := make(map[string]struct{}) + for _, p := range parties { + azp[p] = struct{}{} + } + params.authorizedParties = azp +} + +// Verify verifies a Clerk session JWT and returns the parsed +// clerk.SessionClaims. +func Verify(ctx context.Context, params *VerifyParams) (*clerk.SessionClaims, error) { + parsedToken, err := jwt.ParseSigned(params.Token) + if err != nil { + return nil, err + } + if len(parsedToken.Headers) == 0 { + return nil, fmt.Errorf("missing jwt headers") + } + + kid := parsedToken.Headers[0].KeyID + if kid == "" { + return nil, fmt.Errorf("missing jwt kid header claim") + } + + jwk := params.JWK + if jwk == nil { + jwk, err = getJWK(ctx, kid) + if err != nil { + return nil, fmt.Errorf("get jwk: %w", err) + } + } + + if parsedToken.Headers[0].Algorithm != jwk.Algorithm { + return nil, fmt.Errorf("invalid signing algorithm %s", jwk.Algorithm) + } + + claims := &clerk.SessionClaims{} + allClaims := []any{claims} + if params.CustomClaims != nil { + allClaims = append(allClaims, params.CustomClaims) + } + err = parsedToken.Claims(jwk.Key, allClaims...) + if err != nil { + return nil, err + } + + err = claims.Claims.ValidateWithLeeway(jwt.Expected{Time: time.Now().UTC()}, params.Leeway) + if err != nil { + return nil, err + } + + iss := claims.Issuer + if params.ProxyURL != nil && *params.ProxyURL != "" { + iss = *params.ProxyURL + } + // Non-satellite domains must validate the issuer. + if !params.IsSatellite && !isValidIssuer(iss) { + return nil, fmt.Errorf("invalid issuer %s", iss) + } + + if claims.AuthorizedParty != "" && len(params.authorizedParties) > 0 { + if _, ok := params.authorizedParties[claims.AuthorizedParty]; !ok { + return nil, fmt.Errorf("invalid authorized party %s", claims.AuthorizedParty) + } + } + + return claims, nil +} + +func getJWK(ctx context.Context, kid string) (*clerk.JSONWebKey, error) { + // TODO Avoid multiple requests by caching results for the same + // instance. + jwks, err := jwks.Get(ctx, &jwks.GetParams{}) + if err != nil { + return nil, err + } + for _, k := range jwks.Keys { + if k.KeyID == kid { + return &k, nil + } + } + return nil, fmt.Errorf("no jwk key found for kid %s", kid) +} + +func isValidIssuer(iss string) bool { + return strings.HasPrefix(iss, "https://clerk.") || + strings.Contains(iss, ".clerk.accounts") +} + +type DecodeParams struct { + Token string +} + +// Decode decodes a JWT without verifying it. +// WARNING: The token is not validated, therefore the returned Claims +// should NOT be trusted. +func Decode(_ context.Context, params *DecodeParams) (*clerk.Claims, error) { + parsedToken, err := jwt.ParseSigned(params.Token) + if err != nil { + return nil, err + } + + standardClaims := jwt.Claims{} + extraClaims := make(map[string]any) + err = parsedToken.UnsafeClaimsWithoutVerification(&standardClaims, &extraClaims) + if err != nil { + return nil, err + } + + // Delete any standard claims included in the extra claims. + standardClaimsKeys := []string{"iss", "sub", "aud", "exp", "nbf", "iat", "jti"} + for _, key := range standardClaimsKeys { + delete(extraClaims, key) + } + + return &clerk.Claims{ + Claims: standardClaims, + Extra: extraClaims, + }, nil +} diff --git a/jwt_test.go b/jwt_test.go new file mode 100644 index 00000000..8cb9944b --- /dev/null +++ b/jwt_test.go @@ -0,0 +1,62 @@ +package clerk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSessionClaimsHasRole(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + active string + role string + want bool + }{ + { + active: "active", + role: "non-active", + want: false, + }, + { + active: "active", + role: "active", + want: true, + }, + } { + claims := SessionClaims{ + ActiveOrganizationRole: tc.active, + } + require.Equal(t, claims.HasRole(tc.role), tc.want) + } +} + +func TestSessionClaimsHasPermission(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + active []string + permission string + want bool + }{ + { + active: []string{"active"}, + permission: "non-active", + want: false, + }, + { + active: []string{"active", "non-active"}, + permission: "active", + want: true, + }, + { + active: []string{}, + permission: "active", + want: false, + }, + } { + claims := SessionClaims{ + ActiveOrganizationPermissions: tc.active, + } + require.Equal(t, claims.HasPermission(tc.permission), tc.want) + } +}