Skip to content

Commit

Permalink
Support wildcard scopes in M2M auth (#184)
Browse files Browse the repository at this point in the history
  • Loading branch information
logan-stytch authored Jun 11, 2024
1 parent 3a2826f commit 2139d95
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 22 deletions.
2 changes: 1 addition & 1 deletion stytch/config/version.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package config

const APIVersion = "14.2.0"
const APIVersion = "15.1.0"
24 changes: 14 additions & 10 deletions stytch/consumer/m2m.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/stytchauth/stytch-go/v15/stytch"
"github.com/stytchauth/stytch-go/v15/stytch/config"
"github.com/stytchauth/stytch-go/v15/stytch/consumer/m2m"
"github.com/stytchauth/stytch-go/v15/stytch/shared"
"github.com/stytchauth/stytch-go/v15/stytch/stytcherror"
)

Expand Down Expand Up @@ -133,6 +134,7 @@ func (c *M2MClient) Token(
// ADDIMPORT: "github.com/golang-jwt/jwt/v5"
// ADDIMPORT: "github.com/MicahParks/keyfunc/v2"
// ADDIMPORT: "github.com/stytchauth/stytch-go/v15/stytch/stytcherror"
// ADDIMPORT: "github.com/stytchauth/stytch-go/v15/stytch/shared"

// AuthenticateToken validates an access token issued by Stytch from the Token endpoint.
// M2M access tokens are JWTs signed with the project's JWKs, and can be validated locally using any Stytch client library.
Expand Down Expand Up @@ -171,16 +173,18 @@ func (c *M2MClient) AuthenticateToken(
return nil, fmt.Errorf("could not find scope claim in claims: %v", claims)
}

for _, want := range req.RequiredScopes {
found := false
for _, have := range strings.Split(scope, " ") {
if have == want {
found = true
}
}
if !found {
return nil, fmt.Errorf("%w: scope %s was not found in [%s]", m2m.ErrMissingScope, want, scope)
}
hasScopes := strings.Split(scope, " ")
var authorizationFunc m2m.ScopeAuthorizationFunc = shared.PerformM2MAuthorizationCheck
if req.AuthorizationFunc != nil {
authorizationFunc = *req.AuthorizationFunc
}

err = authorizationFunc(m2m.ScopeAuthorizationFuncParams{
HasScopes: hasScopes,
RequiredScopes: req.RequiredScopes,
})
if err != nil {
return nil, err
}

return marshalJWTIntoResponse(claims, scope)
Expand Down
28 changes: 18 additions & 10 deletions stytch/consumer/m2m/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ const (
// ADDIMPORT: "github.com/golang-jwt/jwt/v5"

type TokenParams struct {
// The ID of the client.
// ClientID is the ID of the client.
ClientID string
// The secret of the client.
// ClientSecret is the secret of the client.
ClientSecret string
// An array scopes requested. If omitted, all scopes assigned to the client will be returned.
// Scopes is an array scopes requested. If omitted, all scopes assigned to the client will be returned.
Scopes []string
}

Expand All @@ -136,23 +136,31 @@ type TokenResponse struct {
TokenType string `json:"token_type"`
}

var (
ErrJWTTooOld = errors.New("JWT too old")
ErrMissingScope = errors.New("missing requested scope")
)
var ErrJWTTooOld = errors.New("JWT too old")

type ScopeAuthorizationFuncParams struct {
HasScopes []string
RequiredScopes []string
}

type ScopeAuthorizationFunc func(ScopeAuthorizationFuncParams) error

type AuthenticateTokenParams struct {
AccessToken string
RequiredScopes []string
MaxTokenAge time.Duration
// AuthorizationFunc is a custom function to authorize the client's scopes. If omitted, the default function will be used.
// The default function assumes scopes are either direct string matches or written in the form "action:resource".
// See the documentation for `shared.PerformAuthorizationCheck` for more information.
AuthorizationFunc *ScopeAuthorizationFunc
}

type AuthenticateTokenResponse struct {
// An array of scopes granted to the token holder.
// Scopes is an array of scopes granted to the token holder.
Scopes []string
// The ID of the client that was issued the token
// ClientID is the ID of the client that was issued the token
ClientID string
// A map of custom claims present in the token
// CustomClaims is a map of custom claims present in the token
CustomClaims map[string]any
}

Expand Down
2 changes: 1 addition & 1 deletion stytch/consumer/m2m_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func TestM2MClient_AuthenticateToken(t *testing.T) {
AccessToken: token,
RequiredScopes: []string{"write:users"},
})
assert.ErrorIs(t, err, m2m.ErrMissingScope)
assert.ErrorIs(t, err, stytcherror.NewM2MPermissionError())
assert.Nil(t, s)
})

Expand Down
49 changes: 49 additions & 0 deletions stytch/shared/m2m_authorization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package shared

import (
"strings"

"github.com/stytchauth/stytch-go/v15/stytch/consumer/m2m"
"github.com/stytchauth/stytch-go/v15/stytch/stytcherror"
)

func PerformM2MAuthorizationCheck(params m2m.ScopeAuthorizationFuncParams) error {
clientScopes := map[string][]string{}
for _, scope := range params.HasScopes {
action := scope
resource := "-"
if strings.Contains(scope, ":") {
parts := strings.SplitN(scope, ":", 2)
action = parts[0]
resource = parts[1]
}
clientScopes[action] = append(clientScopes[action], resource)
}

for _, requiredScope := range params.RequiredScopes {
requiredAction := requiredScope
requiredResource := "-"
if strings.Contains(requiredScope, ":") {
parts := strings.SplitN(requiredScope, ":", 2)
requiredAction = parts[0]
requiredResource = parts[1]
}
resources, ok := clientScopes[requiredAction]
if !ok {
return stytcherror.NewM2MPermissionError()
}

found := false
for _, resource := range resources {
if resource == "*" || resource == requiredResource {
found = true
break
}
}

if !found {
return stytcherror.NewM2MPermissionError()
}
}
return nil
}
78 changes: 78 additions & 0 deletions stytch/shared/m2m_authorization_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package shared_test

import (
"testing"

"github.com/stytchauth/stytch-go/v15/stytch/consumer/m2m"
"github.com/stytchauth/stytch-go/v15/stytch/shared"
)

func TestPerformM2MAuthorizationCheck(t *testing.T) {
tests := []struct {
name string
has []string
needs []string
expectError bool
}{
{
name: "basic",
has: []string{"read:users", "write:users"},
needs: []string{"read:users"},
expectError: false,
},
{
name: "multiple required scopes",
has: []string{"read:users", "write:users", "read:books"},
needs: []string{"read:users", "read:books"},
expectError: false,
},
{
name: "simple scopes",
has: []string{"read_users", "write_users"},
needs: []string{"read_users"},
expectError: false,
},
{
name: "wildcard resource",
has: []string{"read:*", "write:*"},
needs: []string{"read:users"},
expectError: false,
},
{
name: "missing required scope",
has: []string{"read:users"},
needs: []string{"write:users"},
expectError: true,
},
{
name: "missing required scope with wildcard",
has: []string{"read:users", "write:*"},
needs: []string{"delete:books"},
expectError: true,
},
{
name: "has simple scope and wants specific scope",
has: []string{"read"},
needs: []string{"read:users"},
expectError: true,
},
{
name: "has specific scope and wants simple scope",
has: []string{"read:users"},
needs: []string{"read"},
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := shared.PerformM2MAuthorizationCheck(m2m.ScopeAuthorizationFuncParams{
HasScopes: tt.has,
RequiredScopes: tt.needs,
})
if (err != nil) != tt.expectError {
t.Errorf("PerformM2MAuthorizationCheck(%v, %v) error = %v, expectError %v", tt.has, tt.needs, err, tt.expectError)
}
})
}
}
10 changes: 10 additions & 0 deletions stytch/stytcherror/stytcherror.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,14 @@ func NewPermissionError() error {
}
}

func NewM2MPermissionError() error {
msg := "The Client is not authorized to perform the requested action on that resource."
return Error{
StatusCode: 403,
ErrorType: "m2m_authorization_error",
ErrorMessage: Message(msg + ", v" + config.APIVersion),
ErrorURL: "https://stytch.com/docs/api/errors/403",
}
}

var ErrJWKSNotInitialized = errors.New("JWKS not initialized")

0 comments on commit 2139d95

Please sign in to comment.