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