Skip to content

Commit

Permalink
feat: Claims interface now has EncodeWithSigner(nkeys.KeyPair, fn: …
Browse files Browse the repository at this point in the history
…SignFn)` where signing can be delegated to a function. The `type SignFn func(pub string, data []byte) ([]byte, error)` is provided with the public key whose matching private key should be used to sign the provided payload.

This feature enables an external signing service to be incorporated in the workflow for signing a JWT.

Signed-off-by: Alberto Ricart <[email protected]>
  • Loading branch information
aricart committed Nov 26, 2024
1 parent f9c7776 commit 977df3d
Show file tree
Hide file tree
Showing 16 changed files with 289 additions and 46 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/go-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ jobs:
strategy:
matrix:
include:
- go: "stable"
- go: stable
os: ubuntu-latest
canonical: true
- go: "stable"
- go: stable
os: windows-latest
canonical: false

Expand All @@ -25,7 +25,7 @@ jobs:
fetch-depth: 1

- name: Setup Go
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: ${{matrix.go}}

Expand Down
18 changes: 11 additions & 7 deletions v2/account_claims.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2018-2023 The NATS Authors
* Copyright 2018-2024 The NATS Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand Down Expand Up @@ -133,7 +133,7 @@ func (o *OperatorLimits) Validate(vr *ValidationResults) {
}
}

// Mapping for publishes
// WeightedMapping for publishes
type WeightedMapping struct {
Subject Subject `json:"subject"`
Weight uint8 `json:"weight,omitempty"`
Expand Down Expand Up @@ -177,13 +177,13 @@ func (a *Account) AddMapping(sub Subject, to ...WeightedMapping) {
a.Mappings[sub] = to
}

// Enable external authorization for account users.
// ExternalAuthorization enables external authorization for account users.
// AuthUsers are those users specified to bypass the authorization callout and should be used for the authorization service itself.
// AllowedAccounts specifies which accounts, if any, that the authorization service can bind an authorized user to.
// The authorization response, a user JWT, will still need to be signed by the correct account.
// If optional XKey is specified, that is the public xkey (x25519) and the server will encrypt the request such that only the
// holder of the private key can decrypt. The auth service can also optionally encrypt the response back to the server using it's
// publick xkey which will be in the authorization request.
// public xkey which will be in the authorization request.
type ExternalAuthorization struct {
AuthUsers StringList `json:"auth_users,omitempty"`
AllowedAccounts StringList `json:"allowed_accounts,omitempty"`
Expand All @@ -194,12 +194,12 @@ func (ac *ExternalAuthorization) IsEnabled() bool {
return len(ac.AuthUsers) > 0
}

// Helper function to determine if external authorization is enabled.
// HasExternalAuthorization helper function to determine if external authorization is enabled.
func (a *Account) HasExternalAuthorization() bool {
return a.Authorization.IsEnabled()
}

// Helper function to setup external authorization.
// EnableExternalAuthorization helper function to setup external authorization.
func (a *Account) EnableExternalAuthorization(users ...string) {
a.Authorization.AuthUsers.Add(users...)
}
Expand Down Expand Up @@ -357,13 +357,17 @@ func NewAccountClaims(subject string) *AccountClaims {

// Encode converts account claims into a JWT string
func (a *AccountClaims) Encode(pair nkeys.KeyPair) (string, error) {
return a.EncodeWithSigner(pair, nil)
}

func (a *AccountClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
if !nkeys.IsValidPublicAccountKey(a.Subject) {
return "", errors.New("expected subject to be account public key")
}
sort.Sort(a.Exports)
sort.Sort(a.Imports)
a.Type = AccountClaim
return a.ClaimsData.encode(pair, a)
return a.ClaimsData.encode(pair, a, fn)
}

// DecodeAccountClaims decodes account claims from a JWT string
Expand Down
39 changes: 38 additions & 1 deletion v2/account_claims_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2018-2023 The NATS Authors
* Copyright 2018-2024 The NATS Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand Down Expand Up @@ -1018,3 +1018,40 @@ func TestClusterTraffic_Valid(t *testing.T) {
}
}
}

func TestSignFn(t *testing.T) {
okp := createOperatorNKey(t)
opub := publicKey(okp, t)
opk, err := nkeys.FromPublicKey(opub)

akp := createAccountNKey(t)
pub := publicKey(akp, t)

var ok bool
ac := NewAccountClaims(pub)
ac.Name = "A"
s, err := ac.EncodeWithSigner(opk, func(pub string, data []byte) ([]byte, error) {
if pub != opub {
t.Fatal("expected pub key in callback to match")
}
ok = true
return okp.Sign(data)
})

if err != nil {
t.Fatal("error encoding")
}
if !ok {
t.Fatal("expected ok to be true")
}

ac, err = DecodeAccountClaims(s)
if err != nil {
t.Fatal("error decoding encoded jwt")
}
vr := CreateValidationResults()
ac.Validate(vr)
if !vr.IsEmpty() {
t.Fatalf("claims validation should not have failed, got %+v", vr.Issues)
}
}
8 changes: 6 additions & 2 deletions v2/activation_claims.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2018 The NATS Authors
* Copyright 2018-2024 The NATS Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand Down Expand Up @@ -72,11 +72,15 @@ func NewActivationClaims(subject string) *ActivationClaims {

// Encode turns an activation claim into a JWT strimg
func (a *ActivationClaims) Encode(pair nkeys.KeyPair) (string, error) {
return a.EncodeWithSigner(pair, nil)
}

func (a *ActivationClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
if !nkeys.IsValidPublicAccountKey(a.ClaimsData.Subject) {
return "", errors.New("expected subject to be an account")
}
a.Type = ActivationClaim
return a.ClaimsData.encode(pair, a)
return a.ClaimsData.encode(pair, a, fn)
}

// DecodeActivationClaims tries to create an activation claim from a JWT string
Expand Down
33 changes: 32 additions & 1 deletion v2/activation_claims_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2018-2020 The NATS Authors
* Copyright 2018-2024 The NATS Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand Down Expand Up @@ -395,3 +395,34 @@ func TestActivationClaimRevocation(t *testing.T) {
t.Fatal("account validation shouldn't have failed")
}
}

func TestActivationClaimsSignFn(t *testing.T) {
akp := createAccountNKey(t)
target := createAccountNKey(t)

act := NewActivationClaims(publicKey(target, t))
act.ImportSubject = "foo"
act.ImportType = Stream
ok := false
s, err := act.EncodeWithSigner(akp, func(pub string, data []byte) ([]byte, error) {
ok = true
if pub != publicKey(akp, t) {
t.Fatal("expected pub key to match account")
}
return akp.Sign(data)
})
if !ok {
t.Fatal("expected ok to be true")
}

act, err = DecodeActivationClaims(s)
if err != nil {
t.Fatal(err)
}

vr := CreateValidationResults()
act.Validate(vr)
if !vr.IsEmpty() {
t.Fatalf("claims validation should not have failed, got %+v", vr.Issues)
}
}
14 changes: 11 additions & 3 deletions v2/authorization_claims.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The NATS Authors
* Copyright 2022-2024 The NATS Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand Down Expand Up @@ -113,8 +113,12 @@ func (ac *AuthorizationRequestClaims) Validate(vr *ValidationResults) {

// Encode tries to turn the auth request claims into a JWT string.
func (ac *AuthorizationRequestClaims) Encode(pair nkeys.KeyPair) (string, error) {
return ac.EncodeWithSigner(pair, nil)
}

func (ac *AuthorizationRequestClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
ac.Type = AuthorizationRequestClaim
return ac.ClaimsData.encode(pair, ac)
return ac.ClaimsData.encode(pair, ac, fn)
}

// DecodeAuthorizationRequestClaims tries to parse an auth request claims from a JWT string
Expand Down Expand Up @@ -242,6 +246,10 @@ func (ar *AuthorizationResponseClaims) Validate(vr *ValidationResults) {

// Encode tries to turn the auth request claims into a JWT string.
func (ar *AuthorizationResponseClaims) Encode(pair nkeys.KeyPair) (string, error) {
return ar.EncodeWithSigner(pair, nil)
}

func (ar *AuthorizationResponseClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
ar.Type = AuthorizationResponseClaim
return ar.ClaimsData.encode(pair, ar)
return ar.ClaimsData.encode(pair, ar, fn)
}
39 changes: 38 additions & 1 deletion v2/authorization_claims_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The NATS Authors
* Copyright 2022-2024 The NATS Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand Down Expand Up @@ -155,3 +155,40 @@ func TestAuthorizationResponse_Decode(t *testing.T) {
AssertTrue(nkeys.IsValidPublicUserKey(r.Subject), t)
AssertTrue(nkeys.IsValidPublicServerKey(r.Audience), t)
}

func TestNewAuthorizationRequestSignerFn(t *testing.T) {
skp, _ := nkeys.CreateServer()

kp, err := nkeys.CreateUser()
if err != nil {
t.Fatalf("Error creating user: %v", err)
}

// the subject of the claim is the user we are generating an authorization response
ac := NewAuthorizationRequestClaims(publicKey(kp, t))
ac.Server.Name = "NATS-1"
ac.UserNkey = publicKey(kp, t)

ok := false
ar, err := ac.EncodeWithSigner(skp, func(pub string, data []byte) ([]byte, error) {
ok = true
return skp.Sign(data)
})
if err != nil {
t.Fatal("error signing request")
}
if !ok {
t.Fatal("not signed by signer function")
}

ac2, err := DecodeAuthorizationRequestClaims(ar)
if err != nil {
t.Fatal("error decoding authorization request jwt", err)
}

vr := CreateValidationResults()
ac2.Validate(vr)
if !vr.IsEmpty() {
t.Fatalf("claims validation should not have failed, got %+v", vr.Issues)
}
}
26 changes: 21 additions & 5 deletions v2/claims.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2018-2022 The NATS Authors
* Copyright 2018-2024 The NATS Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand Down Expand Up @@ -68,10 +68,16 @@ func IsGenericClaimType(s string) bool {
}
}

// SignFn is used in an external sign environment. The function should be
// able to locate the private key for the specified pub key specified and sign the
// specified data returning the signature as generated.
type SignFn func(pub string, data []byte) ([]byte, error)

// Claims is a JWT claims
type Claims interface {
Claims() *ClaimsData
Encode(kp nkeys.KeyPair) (string, error)
EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error)
ExpectedPrefixes() []nkeys.PrefixByte
Payload() interface{}
String() string
Expand Down Expand Up @@ -121,7 +127,7 @@ func serialize(v interface{}) (string, error) {
return encodeToString(j), nil
}

func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (string, error) {
func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims, fn SignFn) (string, error) {
if header == nil {
return "", errors.New("header is required")
}
Expand Down Expand Up @@ -200,7 +206,17 @@ func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (s
if header.Algorithm == AlgorithmNkeyOld {
return "", errors.New(AlgorithmNkeyOld + " not supported to write jwtV2")
} else if header.Algorithm == AlgorithmNkey {
sig, err := kp.Sign([]byte(toSign))
var sig []byte
var err error
if fn != nil {
pk, err := kp.PublicKey()
if err != nil {
return "", err
}
sig, err = fn(pk, []byte(toSign))
} else {
sig, err = kp.Sign([]byte(toSign))
}
if err != nil {
return "", err
}
Expand All @@ -224,8 +240,8 @@ func (c *ClaimsData) hash() (string, error) {

// Encode encodes a claim into a JWT token. The claim is signed with the
// provided nkey's private key
func (c *ClaimsData) encode(kp nkeys.KeyPair, payload Claims) (string, error) {
return c.doEncode(&Header{TokenTypeJwt, AlgorithmNkey}, kp, payload)
func (c *ClaimsData) encode(kp nkeys.KeyPair, payload Claims, fn SignFn) (string, error) {
return c.doEncode(&Header{TokenTypeJwt, AlgorithmNkey}, kp, payload, fn)
}

// Returns a JSON representation of the claim
Expand Down
Loading

0 comments on commit 977df3d

Please sign in to comment.