Skip to content

Commit 698cd7e

Browse files
authored
Allow external signing of JWTs (#234)
* feat: Claims interface now has `EncodeWithSigner(nkeys.KeyPair`, fn: 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]> * go mod Signed-off-by: Alberto Ricart <[email protected]> --------- Signed-off-by: Alberto Ricart <[email protected]>
1 parent f9c7776 commit 698cd7e

18 files changed

+308
-57
lines changed

.github/workflows/go-test.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ jobs:
77
strategy:
88
matrix:
99
include:
10-
- go: "stable"
10+
- go: stable
1111
os: ubuntu-latest
1212
canonical: true
13-
- go: "stable"
13+
- go: stable
1414
os: windows-latest
1515
canonical: false
1616

@@ -25,7 +25,7 @@ jobs:
2525
fetch-depth: 1
2626

2727
- name: Setup Go
28-
uses: actions/setup-go@v3
28+
uses: actions/setup-go@v5
2929
with:
3030
go-version: ${{matrix.go}}
3131

v2/account_claims.go

+11-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018-2023 The NATS Authors
2+
* Copyright 2018-2024 The NATS Authors
33
* Licensed under the Apache License, Version 2.0 (the "License");
44
* you may not use this file except in compliance with the License.
55
* You may obtain a copy of the License at
@@ -133,7 +133,7 @@ func (o *OperatorLimits) Validate(vr *ValidationResults) {
133133
}
134134
}
135135

136-
// Mapping for publishes
136+
// WeightedMapping for publishes
137137
type WeightedMapping struct {
138138
Subject Subject `json:"subject"`
139139
Weight uint8 `json:"weight,omitempty"`
@@ -177,13 +177,13 @@ func (a *Account) AddMapping(sub Subject, to ...WeightedMapping) {
177177
a.Mappings[sub] = to
178178
}
179179

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

197-
// Helper function to determine if external authorization is enabled.
197+
// HasExternalAuthorization helper function to determine if external authorization is enabled.
198198
func (a *Account) HasExternalAuthorization() bool {
199199
return a.Authorization.IsEnabled()
200200
}
201201

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

358358
// Encode converts account claims into a JWT string
359359
func (a *AccountClaims) Encode(pair nkeys.KeyPair) (string, error) {
360+
return a.EncodeWithSigner(pair, nil)
361+
}
362+
363+
func (a *AccountClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
360364
if !nkeys.IsValidPublicAccountKey(a.Subject) {
361365
return "", errors.New("expected subject to be account public key")
362366
}
363367
sort.Sort(a.Exports)
364368
sort.Sort(a.Imports)
365369
a.Type = AccountClaim
366-
return a.ClaimsData.encode(pair, a)
370+
return a.ClaimsData.encode(pair, a, fn)
367371
}
368372

369373
// DecodeAccountClaims decodes account claims from a JWT string

v2/account_claims_test.go

+41-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018-2023 The NATS Authors
2+
* Copyright 2018-2024 The NATS Authors
33
* Licensed under the Apache License, Version 2.0 (the "License");
44
* you may not use this file except in compliance with the License.
55
* You may obtain a copy of the License at
@@ -1018,3 +1018,43 @@ func TestClusterTraffic_Valid(t *testing.T) {
10181018
}
10191019
}
10201020
}
1021+
1022+
func TestSignFn(t *testing.T) {
1023+
okp := createOperatorNKey(t)
1024+
opub := publicKey(okp, t)
1025+
opk, err := nkeys.FromPublicKey(opub)
1026+
if err != nil {
1027+
t.Fatal(err)
1028+
}
1029+
1030+
akp := createAccountNKey(t)
1031+
pub := publicKey(akp, t)
1032+
1033+
var ok bool
1034+
ac := NewAccountClaims(pub)
1035+
ac.Name = "A"
1036+
s, err := ac.EncodeWithSigner(opk, func(pub string, data []byte) ([]byte, error) {
1037+
if pub != opub {
1038+
t.Fatal("expected pub key in callback to match")
1039+
}
1040+
ok = true
1041+
return okp.Sign(data)
1042+
})
1043+
1044+
if err != nil {
1045+
t.Fatal("error encoding")
1046+
}
1047+
if !ok {
1048+
t.Fatal("expected ok to be true")
1049+
}
1050+
1051+
ac, err = DecodeAccountClaims(s)
1052+
if err != nil {
1053+
t.Fatal("error decoding encoded jwt")
1054+
}
1055+
vr := CreateValidationResults()
1056+
ac.Validate(vr)
1057+
if !vr.IsEmpty() {
1058+
t.Fatalf("claims validation should not have failed, got %+v", vr.Issues)
1059+
}
1060+
}

v2/activation_claims.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018 The NATS Authors
2+
* Copyright 2018-2024 The NATS Authors
33
* Licensed under the Apache License, Version 2.0 (the "License");
44
* you may not use this file except in compliance with the License.
55
* You may obtain a copy of the License at
@@ -72,11 +72,15 @@ func NewActivationClaims(subject string) *ActivationClaims {
7272

7373
// Encode turns an activation claim into a JWT strimg
7474
func (a *ActivationClaims) Encode(pair nkeys.KeyPair) (string, error) {
75+
return a.EncodeWithSigner(pair, nil)
76+
}
77+
78+
func (a *ActivationClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
7579
if !nkeys.IsValidPublicAccountKey(a.ClaimsData.Subject) {
7680
return "", errors.New("expected subject to be an account")
7781
}
7882
a.Type = ActivationClaim
79-
return a.ClaimsData.encode(pair, a)
83+
return a.ClaimsData.encode(pair, a, fn)
8084
}
8185

8286
// DecodeActivationClaims tries to create an activation claim from a JWT string

v2/activation_claims_test.go

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018-2020 The NATS Authors
2+
* Copyright 2018-2024 The NATS Authors
33
* Licensed under the Apache License, Version 2.0 (the "License");
44
* you may not use this file except in compliance with the License.
55
* You may obtain a copy of the License at
@@ -395,3 +395,37 @@ func TestActivationClaimRevocation(t *testing.T) {
395395
t.Fatal("account validation shouldn't have failed")
396396
}
397397
}
398+
399+
func TestActivationClaimsSignFn(t *testing.T) {
400+
akp := createAccountNKey(t)
401+
target := createAccountNKey(t)
402+
403+
act := NewActivationClaims(publicKey(target, t))
404+
act.ImportSubject = "foo"
405+
act.ImportType = Stream
406+
ok := false
407+
s, err := act.EncodeWithSigner(akp, func(pub string, data []byte) ([]byte, error) {
408+
ok = true
409+
if pub != publicKey(akp, t) {
410+
t.Fatal("expected pub key to match account")
411+
}
412+
return akp.Sign(data)
413+
})
414+
if err != nil {
415+
t.Fatal(err)
416+
}
417+
if !ok {
418+
t.Fatal("expected ok to be true")
419+
}
420+
421+
act, err = DecodeActivationClaims(s)
422+
if err != nil {
423+
t.Fatal(err)
424+
}
425+
426+
vr := CreateValidationResults()
427+
act.Validate(vr)
428+
if !vr.IsEmpty() {
429+
t.Fatalf("claims validation should not have failed, got %+v", vr.Issues)
430+
}
431+
}

v2/authorization_claims.go

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022 The NATS Authors
2+
* Copyright 2022-2024 The NATS Authors
33
* Licensed under the Apache License, Version 2.0 (the "License");
44
* you may not use this file except in compliance with the License.
55
* You may obtain a copy of the License at
@@ -113,8 +113,12 @@ func (ac *AuthorizationRequestClaims) Validate(vr *ValidationResults) {
113113

114114
// Encode tries to turn the auth request claims into a JWT string.
115115
func (ac *AuthorizationRequestClaims) Encode(pair nkeys.KeyPair) (string, error) {
116+
return ac.EncodeWithSigner(pair, nil)
117+
}
118+
119+
func (ac *AuthorizationRequestClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
116120
ac.Type = AuthorizationRequestClaim
117-
return ac.ClaimsData.encode(pair, ac)
121+
return ac.ClaimsData.encode(pair, ac, fn)
118122
}
119123

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

243247
// Encode tries to turn the auth request claims into a JWT string.
244248
func (ar *AuthorizationResponseClaims) Encode(pair nkeys.KeyPair) (string, error) {
249+
return ar.EncodeWithSigner(pair, nil)
250+
}
251+
252+
func (ar *AuthorizationResponseClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
245253
ar.Type = AuthorizationResponseClaim
246-
return ar.ClaimsData.encode(pair, ar)
254+
return ar.ClaimsData.encode(pair, ar, fn)
247255
}

v2/authorization_claims_test.go

+38-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022 The NATS Authors
2+
* Copyright 2022-2024 The NATS Authors
33
* Licensed under the Apache License, Version 2.0 (the "License");
44
* you may not use this file except in compliance with the License.
55
* You may obtain a copy of the License at
@@ -155,3 +155,40 @@ func TestAuthorizationResponse_Decode(t *testing.T) {
155155
AssertTrue(nkeys.IsValidPublicUserKey(r.Subject), t)
156156
AssertTrue(nkeys.IsValidPublicServerKey(r.Audience), t)
157157
}
158+
159+
func TestNewAuthorizationRequestSignerFn(t *testing.T) {
160+
skp, _ := nkeys.CreateServer()
161+
162+
kp, err := nkeys.CreateUser()
163+
if err != nil {
164+
t.Fatalf("Error creating user: %v", err)
165+
}
166+
167+
// the subject of the claim is the user we are generating an authorization response
168+
ac := NewAuthorizationRequestClaims(publicKey(kp, t))
169+
ac.Server.Name = "NATS-1"
170+
ac.UserNkey = publicKey(kp, t)
171+
172+
ok := false
173+
ar, err := ac.EncodeWithSigner(skp, func(pub string, data []byte) ([]byte, error) {
174+
ok = true
175+
return skp.Sign(data)
176+
})
177+
if err != nil {
178+
t.Fatal("error signing request")
179+
}
180+
if !ok {
181+
t.Fatal("not signed by signer function")
182+
}
183+
184+
ac2, err := DecodeAuthorizationRequestClaims(ar)
185+
if err != nil {
186+
t.Fatal("error decoding authorization request jwt", err)
187+
}
188+
189+
vr := CreateValidationResults()
190+
ac2.Validate(vr)
191+
if !vr.IsEmpty() {
192+
t.Fatalf("claims validation should not have failed, got %+v", vr.Issues)
193+
}
194+
}

v2/claims.go

+25-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018-2022 The NATS Authors
2+
* Copyright 2018-2024 The NATS Authors
33
* Licensed under the Apache License, Version 2.0 (the "License");
44
* you may not use this file except in compliance with the License.
55
* You may obtain a copy of the License at
@@ -68,10 +68,16 @@ func IsGenericClaimType(s string) bool {
6868
}
6969
}
7070

71+
// SignFn is used in an external sign environment. The function should be
72+
// able to locate the private key for the specified pub key specified and sign the
73+
// specified data returning the signature as generated.
74+
type SignFn func(pub string, data []byte) ([]byte, error)
75+
7176
// Claims is a JWT claims
7277
type Claims interface {
7378
Claims() *ClaimsData
7479
Encode(kp nkeys.KeyPair) (string, error)
80+
EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error)
7581
ExpectedPrefixes() []nkeys.PrefixByte
7682
Payload() interface{}
7783
String() string
@@ -121,7 +127,7 @@ func serialize(v interface{}) (string, error) {
121127
return encodeToString(j), nil
122128
}
123129

124-
func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (string, error) {
130+
func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims, fn SignFn) (string, error) {
125131
if header == nil {
126132
return "", errors.New("header is required")
127133
}
@@ -200,9 +206,21 @@ func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (s
200206
if header.Algorithm == AlgorithmNkeyOld {
201207
return "", errors.New(AlgorithmNkeyOld + " not supported to write jwtV2")
202208
} else if header.Algorithm == AlgorithmNkey {
203-
sig, err := kp.Sign([]byte(toSign))
204-
if err != nil {
205-
return "", err
209+
var sig []byte
210+
if fn != nil {
211+
pk, err := kp.PublicKey()
212+
if err != nil {
213+
return "", err
214+
}
215+
sig, err = fn(pk, []byte(toSign))
216+
if err != nil {
217+
return "", err
218+
}
219+
} else {
220+
sig, err = kp.Sign([]byte(toSign))
221+
if err != nil {
222+
return "", err
223+
}
206224
}
207225
eSig = encodeToString(sig)
208226
} else {
@@ -224,8 +242,8 @@ func (c *ClaimsData) hash() (string, error) {
224242

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

231249
// Returns a JSON representation of the claim

0 commit comments

Comments
 (0)