Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow external signing of JWTs #234

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
42 changes: 41 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,43 @@ func TestClusterTraffic_Valid(t *testing.T) {
}
}
}

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

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
36 changes: 35 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,37 @@ 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 err != nil {
t.Fatal(err)
}
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)
}
}
32 changes: 25 additions & 7 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,9 +206,21 @@ 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))
if err != nil {
return "", err
var sig []byte
if fn != nil {
pk, err := kp.PublicKey()
if err != nil {
return "", err
}
sig, err = fn(pk, []byte(toSign))
if err != nil {
return "", err
}
} else {
sig, err = kp.Sign([]byte(toSign))
if err != nil {
return "", err
}
}
eSig = encodeToString(sig)
} else {
Expand All @@ -224,8 +242,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
Loading