From 17a6134277d3336eb83fa19a7f903833dc27c8c2 Mon Sep 17 00:00:00 2001 From: aricart Date: Tue, 4 Feb 2020 09:48:04 -0400 Subject: [PATCH 01/14] v2 move with history --- account_claims.go => v2/account_claims.go | 0 account_claims_test.go => v2/account_claims_test.go | 0 activation_claims.go => v2/activation_claims.go | 0 activation_claims_test.go => v2/activation_claims_test.go | 0 claims.go => v2/claims.go | 0 cluster_claims.go => v2/cluster_claims.go | 0 cluster_claims_test.go => v2/cluster_claims_test.go | 0 creds_utils.go => v2/creds_utils.go | 0 creds_utils_test.go => v2/creds_utils_test.go | 0 decoder_test.go => v2/decoder_test.go | 0 exports.go => v2/exports.go | 0 exports_test.go => v2/exports_test.go | 0 genericclaims_test.go => v2/genericclaims_test.go | 0 genericlaims.go => v2/genericlaims.go | 0 go.mod => v2/go.mod | 0 go.sum => v2/go.sum | 0 header.go => v2/header.go | 0 imports.go => v2/imports.go | 0 imports_test.go => v2/imports_test.go | 0 operator_claims.go => v2/operator_claims.go | 0 operator_claims_test.go => v2/operator_claims_test.go | 0 revocation_list.go => v2/revocation_list.go | 0 server_claims.go => v2/server_claims.go | 0 server_claims_test.go => v2/server_claims_test.go | 0 types.go => v2/types.go | 0 types_test.go => v2/types_test.go | 0 user_claims.go => v2/user_claims.go | 0 user_claims_test.go => v2/user_claims_test.go | 0 util_test.go => v2/util_test.go | 0 validation.go => v2/validation.go | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename account_claims.go => v2/account_claims.go (100%) rename account_claims_test.go => v2/account_claims_test.go (100%) rename activation_claims.go => v2/activation_claims.go (100%) rename activation_claims_test.go => v2/activation_claims_test.go (100%) rename claims.go => v2/claims.go (100%) rename cluster_claims.go => v2/cluster_claims.go (100%) rename cluster_claims_test.go => v2/cluster_claims_test.go (100%) rename creds_utils.go => v2/creds_utils.go (100%) rename creds_utils_test.go => v2/creds_utils_test.go (100%) rename decoder_test.go => v2/decoder_test.go (100%) rename exports.go => v2/exports.go (100%) rename exports_test.go => v2/exports_test.go (100%) rename genericclaims_test.go => v2/genericclaims_test.go (100%) rename genericlaims.go => v2/genericlaims.go (100%) rename go.mod => v2/go.mod (100%) rename go.sum => v2/go.sum (100%) rename header.go => v2/header.go (100%) rename imports.go => v2/imports.go (100%) rename imports_test.go => v2/imports_test.go (100%) rename operator_claims.go => v2/operator_claims.go (100%) rename operator_claims_test.go => v2/operator_claims_test.go (100%) rename revocation_list.go => v2/revocation_list.go (100%) rename server_claims.go => v2/server_claims.go (100%) rename server_claims_test.go => v2/server_claims_test.go (100%) rename types.go => v2/types.go (100%) rename types_test.go => v2/types_test.go (100%) rename user_claims.go => v2/user_claims.go (100%) rename user_claims_test.go => v2/user_claims_test.go (100%) rename util_test.go => v2/util_test.go (100%) rename validation.go => v2/validation.go (100%) diff --git a/account_claims.go b/v2/account_claims.go similarity index 100% rename from account_claims.go rename to v2/account_claims.go diff --git a/account_claims_test.go b/v2/account_claims_test.go similarity index 100% rename from account_claims_test.go rename to v2/account_claims_test.go diff --git a/activation_claims.go b/v2/activation_claims.go similarity index 100% rename from activation_claims.go rename to v2/activation_claims.go diff --git a/activation_claims_test.go b/v2/activation_claims_test.go similarity index 100% rename from activation_claims_test.go rename to v2/activation_claims_test.go diff --git a/claims.go b/v2/claims.go similarity index 100% rename from claims.go rename to v2/claims.go diff --git a/cluster_claims.go b/v2/cluster_claims.go similarity index 100% rename from cluster_claims.go rename to v2/cluster_claims.go diff --git a/cluster_claims_test.go b/v2/cluster_claims_test.go similarity index 100% rename from cluster_claims_test.go rename to v2/cluster_claims_test.go diff --git a/creds_utils.go b/v2/creds_utils.go similarity index 100% rename from creds_utils.go rename to v2/creds_utils.go diff --git a/creds_utils_test.go b/v2/creds_utils_test.go similarity index 100% rename from creds_utils_test.go rename to v2/creds_utils_test.go diff --git a/decoder_test.go b/v2/decoder_test.go similarity index 100% rename from decoder_test.go rename to v2/decoder_test.go diff --git a/exports.go b/v2/exports.go similarity index 100% rename from exports.go rename to v2/exports.go diff --git a/exports_test.go b/v2/exports_test.go similarity index 100% rename from exports_test.go rename to v2/exports_test.go diff --git a/genericclaims_test.go b/v2/genericclaims_test.go similarity index 100% rename from genericclaims_test.go rename to v2/genericclaims_test.go diff --git a/genericlaims.go b/v2/genericlaims.go similarity index 100% rename from genericlaims.go rename to v2/genericlaims.go diff --git a/go.mod b/v2/go.mod similarity index 100% rename from go.mod rename to v2/go.mod diff --git a/go.sum b/v2/go.sum similarity index 100% rename from go.sum rename to v2/go.sum diff --git a/header.go b/v2/header.go similarity index 100% rename from header.go rename to v2/header.go diff --git a/imports.go b/v2/imports.go similarity index 100% rename from imports.go rename to v2/imports.go diff --git a/imports_test.go b/v2/imports_test.go similarity index 100% rename from imports_test.go rename to v2/imports_test.go diff --git a/operator_claims.go b/v2/operator_claims.go similarity index 100% rename from operator_claims.go rename to v2/operator_claims.go diff --git a/operator_claims_test.go b/v2/operator_claims_test.go similarity index 100% rename from operator_claims_test.go rename to v2/operator_claims_test.go diff --git a/revocation_list.go b/v2/revocation_list.go similarity index 100% rename from revocation_list.go rename to v2/revocation_list.go diff --git a/server_claims.go b/v2/server_claims.go similarity index 100% rename from server_claims.go rename to v2/server_claims.go diff --git a/server_claims_test.go b/v2/server_claims_test.go similarity index 100% rename from server_claims_test.go rename to v2/server_claims_test.go diff --git a/types.go b/v2/types.go similarity index 100% rename from types.go rename to v2/types.go diff --git a/types_test.go b/v2/types_test.go similarity index 100% rename from types_test.go rename to v2/types_test.go diff --git a/user_claims.go b/v2/user_claims.go similarity index 100% rename from user_claims.go rename to v2/user_claims.go diff --git a/user_claims_test.go b/v2/user_claims_test.go similarity index 100% rename from user_claims_test.go rename to v2/user_claims_test.go diff --git a/util_test.go b/v2/util_test.go similarity index 100% rename from util_test.go rename to v2/util_test.go diff --git a/validation.go b/v2/validation.go similarity index 100% rename from validation.go rename to v2/validation.go From d8ef752561283235c5ec055eb1e6aa7d1c98c4b3 Mon Sep 17 00:00:00 2001 From: aricart Date: Tue, 4 Feb 2020 09:56:23 -0400 Subject: [PATCH 02/14] restored original files to preserve history --- account_claims.go | 222 ++++++++++++++++ account_claims_test.go | 535 ++++++++++++++++++++++++++++++++++++++ activation_claims.go | 166 ++++++++++++ activation_claims_test.go | 426 ++++++++++++++++++++++++++++++ claims.go | 302 +++++++++++++++++++++ cluster_claims.go | 94 +++++++ cluster_claims_test.go | 132 ++++++++++ creds_utils.go | 203 +++++++++++++++ creds_utils_test.go | 211 +++++++++++++++ decoder_test.go | 404 ++++++++++++++++++++++++++++ exports.go | 236 +++++++++++++++++ exports_test.go | 290 +++++++++++++++++++++ genericclaims_test.go | 60 +++++ genericlaims.go | 73 ++++++ go.mod | 5 + go.sum | 9 + header.go | 71 +++++ imports.go | 151 +++++++++++ imports_test.go | 410 +++++++++++++++++++++++++++++ operator_claims.go | 204 +++++++++++++++ operator_claims_test.go | 354 +++++++++++++++++++++++++ revocation_list.go | 32 +++ server_claims.go | 94 +++++++ server_claims_test.go | 132 ++++++++++ types.go | 334 ++++++++++++++++++++++++ types_test.go | 266 +++++++++++++++++++ user_claims.go | 106 ++++++++ user_claims_test.go | 381 +++++++++++++++++++++++++++ util_test.go | 113 ++++++++ validation.go | 107 ++++++++ 30 files changed, 6123 insertions(+) create mode 100644 account_claims.go create mode 100644 account_claims_test.go create mode 100644 activation_claims.go create mode 100644 activation_claims_test.go create mode 100644 claims.go create mode 100644 cluster_claims.go create mode 100644 cluster_claims_test.go create mode 100644 creds_utils.go create mode 100644 creds_utils_test.go create mode 100644 decoder_test.go create mode 100644 exports.go create mode 100644 exports_test.go create mode 100644 genericclaims_test.go create mode 100644 genericlaims.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 header.go create mode 100644 imports.go create mode 100644 imports_test.go create mode 100644 operator_claims.go create mode 100644 operator_claims_test.go create mode 100644 revocation_list.go create mode 100644 server_claims.go create mode 100644 server_claims_test.go create mode 100644 types.go create mode 100644 types_test.go create mode 100644 user_claims.go create mode 100644 user_claims_test.go create mode 100644 util_test.go create mode 100644 validation.go diff --git a/account_claims.go b/account_claims.go new file mode 100644 index 0000000..945bd98 --- /dev/null +++ b/account_claims.go @@ -0,0 +1,222 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "sort" + "time" + + "github.com/nats-io/nkeys" +) + +// NoLimit is used to indicate a limit field is unlimited in value. +const NoLimit = -1 + +// OperatorLimits are used to limit access by an account +type OperatorLimits struct { + Subs int64 `json:"subs,omitempty"` // Max number of subscriptions + Conn int64 `json:"conn,omitempty"` // Max number of active connections + LeafNodeConn int64 `json:"leaf,omitempty"` // Max number of active leaf node connections + Imports int64 `json:"imports,omitempty"` // Max number of imports + Exports int64 `json:"exports,omitempty"` // Max number of exports + Data int64 `json:"data,omitempty"` // Max number of bytes + Payload int64 `json:"payload,omitempty"` // Max message payload + WildcardExports bool `json:"wildcards,omitempty"` // Are wildcards allowed in exports +} + +// IsEmpty returns true if all of the limits are 0/false. +func (o *OperatorLimits) IsEmpty() bool { + return *o == OperatorLimits{} +} + +// IsUnlimited returns true if all limits are +func (o *OperatorLimits) IsUnlimited() bool { + return *o == OperatorLimits{NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, true} +} + +// Validate checks that the operator limits contain valid values +func (o *OperatorLimits) Validate(vr *ValidationResults) { + // negative values mean unlimited, so all numbers are valid +} + +// Account holds account specific claims data +type Account struct { + Imports Imports `json:"imports,omitempty"` + Exports Exports `json:"exports,omitempty"` + Identities []Identity `json:"identity,omitempty"` + Limits OperatorLimits `json:"limits,omitempty"` + SigningKeys StringList `json:"signing_keys,omitempty"` + Revocations RevocationList `json:"revocations,omitempty"` +} + +// Validate checks if the account is valid, based on the wrapper +func (a *Account) Validate(acct *AccountClaims, vr *ValidationResults) { + a.Imports.Validate(acct.Subject, vr) + a.Exports.Validate(vr) + a.Limits.Validate(vr) + + for _, i := range a.Identities { + i.Validate(vr) + } + + if !a.Limits.IsEmpty() && a.Limits.Imports >= 0 && int64(len(a.Imports)) > a.Limits.Imports { + vr.AddError("the account contains more imports than allowed by the operator") + } + + // Check Imports and Exports for limit violations. + if a.Limits.Imports != NoLimit { + if int64(len(a.Imports)) > a.Limits.Imports { + vr.AddError("the account contains more imports than allowed by the operator") + } + } + if a.Limits.Exports != NoLimit { + if int64(len(a.Exports)) > a.Limits.Exports { + vr.AddError("the account contains more exports than allowed by the operator") + } + // Check for wildcard restrictions + if !a.Limits.WildcardExports { + for _, ex := range a.Exports { + if ex.Subject.HasWildCards() { + vr.AddError("the account contains wildcard exports that are not allowed by the operator") + } + } + } + } + + for _, k := range a.SigningKeys { + if !nkeys.IsValidPublicAccountKey(k) { + vr.AddError("%s is not an account public key", k) + } + } +} + +// AccountClaims defines the body of an account JWT +type AccountClaims struct { + ClaimsData + Account `json:"nats,omitempty"` +} + +// NewAccountClaims creates a new account JWT +func NewAccountClaims(subject string) *AccountClaims { + if subject == "" { + return nil + } + c := &AccountClaims{} + // Set to unlimited to start. We do it this way so we get compiler + // errors if we add to the OperatorLimits. + c.Limits = OperatorLimits{NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, true} + c.Subject = subject + return c +} + +// Encode converts account claims into a JWT string +func (a *AccountClaims) Encode(pair nkeys.KeyPair) (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.ClaimsData.Type = AccountClaim + return a.ClaimsData.Encode(pair, a) +} + +// DecodeAccountClaims decodes account claims from a JWT string +func DecodeAccountClaims(token string) (*AccountClaims, error) { + v := AccountClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (a *AccountClaims) String() string { + return a.ClaimsData.String(a) +} + +// Payload pulls the accounts specific payload out of the claims +func (a *AccountClaims) Payload() interface{} { + return &a.Account +} + +// Validate checks the accounts contents +func (a *AccountClaims) Validate(vr *ValidationResults) { + a.ClaimsData.Validate(vr) + a.Account.Validate(a, vr) + + if nkeys.IsValidPublicAccountKey(a.ClaimsData.Issuer) { + if len(a.Identities) > 0 { + vr.AddWarning("self-signed account JWTs shouldn't contain identity proofs") + } + if !a.Limits.IsEmpty() { + vr.AddWarning("self-signed account JWTs shouldn't contain operator limits") + } + } +} + +// ExpectedPrefixes defines the types that can encode an account jwt, account and operator +func (a *AccountClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} +} + +// Claims returns the accounts claims data +func (a *AccountClaims) Claims() *ClaimsData { + return &a.ClaimsData +} + +// DidSign checks the claims against the account's public key and its signing keys +func (a *AccountClaims) DidSign(op Claims) bool { + if op != nil { + issuer := op.Claims().Issuer + if issuer == a.Subject { + return true + } + return a.SigningKeys.Contains(issuer) + } + return false +} + +// Revoke enters a revocation by publickey using time.Now(). +func (a *AccountClaims) Revoke(pubKey string) { + a.RevokeAt(pubKey, time.Now()) +} + +// RevokeAt enters a revocation by publickey and timestamp into this export +// If there is already a revocation for this public key that is newer, it is kept. +func (a *AccountClaims) RevokeAt(pubKey string, timestamp time.Time) { + if a.Revocations == nil { + a.Revocations = RevocationList{} + } + + a.Revocations.Revoke(pubKey, timestamp) +} + +// ClearRevocation removes any revocation for the public key +func (a *AccountClaims) ClearRevocation(pubKey string) { + a.Revocations.ClearRevocation(pubKey) +} + +// IsRevokedAt checks if the public key is in the revoked list with a timestamp later than +// the one passed in. Generally this method is called with time.Now() but other time's can +// be used for testing. +func (a *AccountClaims) IsRevokedAt(pubKey string, timestamp time.Time) bool { + return a.Revocations.IsRevoked(pubKey, timestamp) +} + +// IsRevoked checks if the public key is in the revoked list with time.Now() +func (a *AccountClaims) IsRevoked(pubKey string) bool { + return a.Revocations.IsRevoked(pubKey, time.Now()) +} diff --git a/account_claims_test.go b/account_claims_test.go new file mode 100644 index 0000000..c9fe4a2 --- /dev/null +++ b/account_claims_test.go @@ -0,0 +1,535 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewAccountClaims(t *testing.T) { + akp := createAccountNKey(t) + akp2 := createAccountNKey(t) + apk := publicKey(akp, t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.ImportSubject = "test" + activation.ImportType = Stream + actJWT := encode(activation, akp2, t) + + account := NewAccountClaims(apk) + if !account.Limits.IsUnlimited() { + t.Fatalf("Expected unlimited operator limits") + } + + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).UTC().Unix() + + account.Imports = Imports{} + account.Imports.Add(&Import{Subject: "test", Name: "test import", Account: apk2, Token: actJWT, To: "my", Type: Stream}) + + vr := CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() { + t.Fatal("Valid account will have no validation results") + } + + actJwt := encode(account, akp, t) + + account2, err := DecodeAccountClaims(actJwt) + if err != nil { + t.Fatal("error decoding account jwt", err) + } + + AssertEquals(account.String(), account2.String(), t) + AssertEquals(account2.IsSelfSigned(), true, t) + + AssertEquals(account2.Claims() != nil, true, t) + AssertEquals(account2.Payload() != nil, true, t) +} + +func TestAccountCanSignOperatorLimits(t *testing.T) { // don't block encoding!!! + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 10 + account.Limits.LeafNodeConn = 2 + + _, err := account.Encode(akp) + if err != nil { + t.Fatal("account should not be able to encode operator limits", err) + } +} + +func TestAccountCanSignIdentities(t *testing.T) { // don't block encoding!!! + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + + _, err := account.Encode(akp) + if err != nil { + t.Fatal("account should not be able to encode identities", err) + } +} + +func TestOperatorCanSignClaims(t *testing.T) { + akp := createAccountNKey(t) + okp := createOperatorNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 1 + account.Limits.LeafNodeConn = 4 + + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + + actJwt := encode(account, okp, t) + + account2, err := DecodeAccountClaims(actJwt) + if err != nil { + t.Fatal("error decoding account jwt", err) + } + + AssertEquals(account.String(), account2.String(), t) + AssertEquals(account2.IsSelfSigned(), false, t) + + if account2.Limits.Conn != 1 { + t.Fatalf("Expected Limits.Conn == 1, got %d", account2.Limits.Conn) + } + if account2.Limits.LeafNodeConn != 4 { + t.Fatalf("Expected Limits.Conn == 4, got %d", account2.Limits.LeafNodeConn) + } +} + +func TestInvalidAccountClaimIssuer(t *testing.T) { + akp := createAccountNKey(t) + ac := NewAccountClaims(publicKey(akp, t)) + ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + aJwt := encode(ac, akp, t) + + temp, err := DecodeGeneric(aJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeAccountClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode account signed by %q", i.name) + t.Fail() + } + } +} + +func TestInvalidAccountSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + pk := publicKey(i.kp, t) + var err error + + c := NewAccountClaims(pk) + if i.ok && err != nil { + t.Fatalf("error encoding activation: %v", err) + } + _, err = c.Encode(i.kp) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode account with with %q subject", i.name) + t.Fail() + } + } +} + +func TestAccountImports(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + + actJwt := encode(account, akp, t) + + account2, err := DecodeAccountClaims(actJwt) + if err != nil { + t.Fatal("error decoding account jwt", err) + } + + AssertEquals(account.String(), account2.String(), t) +} + +func TestNewNilAccountClaim(t *testing.T) { + v := NewAccountClaims("") + if v != nil { + t.Fatal("expected nil account claim") + } +} + +func TestLimitValidationInAccount(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 10 + account.Limits.Imports = 10 + account.Limits.Exports = 10 + account.Limits.Data = 1024 + account.Limits.Payload = 1024 + account.Limits.Subs = 10 + account.Limits.WildcardExports = true + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + + vr := CreateValidationResults() + account.Validate(vr) + + if len(vr.Issues) != 0 { + t.Fatal("valid account should have no validation issues") + } + + account.Limits.Conn = -1 + account.Limits.Imports = -1 + account.Limits.Exports = -1 + account.Limits.Subs = -1 + account.Limits.Data = -1 + account.Limits.Payload = -1 + vr = CreateValidationResults() + account.Validate(vr) + + if len(vr.Issues) != 0 { + t.Fatal("valid account should have no validation issues") + } + + op := createOperatorNKey(t) + opk := publicKey(op, t) + account.Issuer = opk + + vr = CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("operator can encode limits and identity") + } + + account.Identities = nil + account.Issuer = apk + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("bad issuer for limits should have non-blocking validation results") + } + + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + account.Limits = OperatorLimits{} + account.Issuer = apk + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("bad issuer for identities should have non-blocking validation results") + } + + account.Identities = nil + account.Issuer = apk + vr = CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("account can encode without limits and identity") + } +} + +func TestWildcardExportLimit(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 10 + account.Limits.Imports = 10 + account.Limits.Exports = 10 + account.Limits.WildcardExports = true + account.Exports = Exports{ + &Export{Subject: "foo", Type: Stream}, + &Export{Subject: "bar.*", Type: Stream}, + } + + vr := CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() { + t.Fatal("valid account should have no validation issues") + } + + account.Limits.WildcardExports = false + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Fatal("invalid account should have validation issues") + } + + account.Limits.WildcardExports = true + account.Limits.Exports = 1 + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Fatal("invalid account should have validation issues") + } +} + +func TestAccountSigningKeyValidation(t *testing.T) { + okp := createOperatorNKey(t) + + akp1 := createAccountNKey(t) + apk1 := publicKey(akp1, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + ac := NewAccountClaims(apk1) + ac.SigningKeys.Add(apk2) + + var vr ValidationResults + ac.Validate(&vr) + if len(vr.Issues) != 0 { + t.Fatal("expected no validation issues") + } + + // try encoding/decoding + token, err := ac.Encode(okp) + if err != nil { + t.Fatal(err) + } + ac2, err := DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + if len(ac2.SigningKeys) != 1 { + t.Fatal("expected claim to have a signing key") + } + if ac.SigningKeys[0] != apk2 { + t.Fatalf("expected signing key to be %s - got %s", apk2, ac.SigningKeys[0]) + } + + bkp := createUserNKey(t) + ac.SigningKeys.Add(publicKey(bkp, t)) + ac.Validate(&vr) + if len(vr.Issues) != 1 { + t.Fatal("expected 1 validation issue") + } +} + +func TestAccountSignedBy(t *testing.T) { + okp := createOperatorNKey(t) + + akp1 := createAccountNKey(t) + apk1 := publicKey(akp1, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + ac := NewAccountClaims(apk1) + ac.SigningKeys.Add(apk2) + + token, err := ac.Encode(okp) + if err != nil { + t.Fatal(err) + } + ac2, err := DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + if len(ac2.SigningKeys) != 1 { + t.Fatal("expected claim to have a signing key") + } + if ac.SigningKeys[0] != apk2 { + t.Fatalf("expected signing key to be %s - got %s", apk2, ac.SigningKeys[0]) + } + + ukp := createUserNKey(t) + upk := publicKey(ukp, t) + + // claim signed by alternate key + uc := NewUserClaims(upk) + utoken, err := uc.Encode(akp2) + if err != nil { + t.Fatal(err) + } + + uc2, err := DecodeUserClaims(utoken) + if err != nil { + t.Fatal(err) + } + if !ac2.DidSign(uc2) { + t.Fatal("failed to verify user claim") + } + + // claim signed by the account pk + uc3 := NewUserClaims(upk) + utoken2, err := uc3.Encode(akp1) + if err != nil { + t.Fatal(err) + } + uc4, err := DecodeUserClaims(utoken2) + if err != nil { + t.Fatal(err) + } + if !ac2.DidSign(uc4) { + t.Fatal("failed to verify user claim") + } +} + +func TestAddRemoveSigningKey(t *testing.T) { + akp1 := createAccountNKey(t) + apk1 := publicKey(akp1, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + akp3 := createAccountNKey(t) + apk3 := publicKey(akp3, t) + + ac := NewAccountClaims(apk1) + ac.SigningKeys.Add(apk2, apk3) + + if len(ac.SigningKeys) != 2 { + t.Fatal("expected 2 signing keys") + } + + ac.SigningKeys.Remove(publicKey(createAccountNKey(t), t)) + if len(ac.SigningKeys) != 2 { + t.Fatal("expected 2 signing keys") + } + + ac.SigningKeys.Remove(apk2) + if len(ac.SigningKeys) != 1 { + t.Fatal("expected single signing keys") + } +} + +func TestUserRevocation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + account := NewAccountClaims(apk) + + pubKey := "bar" + now := time.Now() + + // test that clear is safe before we add any + account.ClearRevocation(pubKey) + + if account.IsRevokedAt(pubKey, now) { + t.Errorf("no revocation was added so is revoked should be false") + } + + account.RevokeAt(pubKey, now.Add(time.Second*100)) + + if !account.IsRevokedAt(pubKey, now) { + t.Errorf("revocation should hold when timestamp is in the future") + } + + if account.IsRevokedAt(pubKey, now.Add(time.Second*150)) { + t.Errorf("revocation should time out") + } + + account.RevokeAt(pubKey, now.Add(time.Second*50)) // shouldn't change the revocation, you can't move it in + + if !account.IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should hold, 100 > 50") + } + + encoded, _ := account.Encode(akp) + decoded, _ := DecodeAccountClaims(encoded) + + if !decoded.IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should last across encoding") + } + + account.ClearRevocation(pubKey) + + if account.IsRevokedAt(pubKey, now) { + t.Errorf("revocations should be cleared") + } + + account.RevokeAt(pubKey, now.Add(time.Second*1000)) + + if !account.IsRevoked(pubKey) { + t.Errorf("revocation be true we revoked in the future") + } +} diff --git a/activation_claims.go b/activation_claims.go new file mode 100644 index 0000000..99228a7 --- /dev/null +++ b/activation_claims.go @@ -0,0 +1,166 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "crypto/sha256" + "encoding/base32" + "errors" + "fmt" + "strings" + + "github.com/nats-io/nkeys" +) + +// Activation defines the custom parts of an activation claim +type Activation struct { + ImportSubject Subject `json:"subject,omitempty"` + ImportType ExportType `json:"type,omitempty"` + Limits +} + +// IsService returns true if an Activation is for a service +func (a *Activation) IsService() bool { + return a.ImportType == Service +} + +// IsStream returns true if an Activation is for a stream +func (a *Activation) IsStream() bool { + return a.ImportType == Stream +} + +// Validate checks the exports and limits in an activation JWT +func (a *Activation) Validate(vr *ValidationResults) { + if !a.IsService() && !a.IsStream() { + vr.AddError("invalid export type: %q", a.ImportType) + } + + if a.IsService() { + if a.ImportSubject.HasWildCards() { + vr.AddError("services cannot have wildcard subject: %q", a.ImportSubject) + } + } + + a.ImportSubject.Validate(vr) + a.Limits.Validate(vr) +} + +// ActivationClaims holds the data specific to an activation JWT +type ActivationClaims struct { + ClaimsData + Activation `json:"nats,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` +} + +// NewActivationClaims creates a new activation claim with the provided sub +func NewActivationClaims(subject string) *ActivationClaims { + if subject == "" { + return nil + } + ac := &ActivationClaims{} + ac.Subject = subject + return ac +} + +// Encode turns an activation claim into a JWT strimg +func (a *ActivationClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicAccountKey(a.ClaimsData.Subject) { + return "", errors.New("expected subject to be an account") + } + a.ClaimsData.Type = ActivationClaim + return a.ClaimsData.Encode(pair, a) +} + +// DecodeActivationClaims tries to create an activation claim from a JWT string +func DecodeActivationClaims(token string) (*ActivationClaims, error) { + v := ActivationClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +// Payload returns the activation specific part of the JWT +func (a *ActivationClaims) Payload() interface{} { + return a.Activation +} + +// Validate checks the claims +func (a *ActivationClaims) Validate(vr *ValidationResults) { + a.ClaimsData.Validate(vr) + a.Activation.Validate(vr) + if a.IssuerAccount != "" && !nkeys.IsValidPublicAccountKey(a.IssuerAccount) { + vr.AddError("account_id is not an account public key") + } +} + +// ExpectedPrefixes defines the types that can sign an activation jwt, account and oeprator +func (a *ActivationClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} +} + +// Claims returns the generic part of the JWT +func (a *ActivationClaims) Claims() *ClaimsData { + return &a.ClaimsData +} + +func (a *ActivationClaims) String() string { + return a.ClaimsData.String(a) +} + +// HashID returns a hash of the claims that can be used to identify it. +// The hash is calculated by creating a string with +// issuerPubKey.subjectPubKey. and constructing the sha-256 hash and base32 encoding that. +// is the exported subject, minus any wildcards, so foo.* becomes foo. +// the one special case is that if the export start with "*" or is ">" the "_" +func (a *ActivationClaims) HashID() (string, error) { + + if a.Issuer == "" || a.Subject == "" || a.ImportSubject == "" { + return "", fmt.Errorf("not enough data in the activaion claims to create a hash") + } + + subject := cleanSubject(string(a.ImportSubject)) + base := fmt.Sprintf("%s.%s.%s", a.Issuer, a.Subject, subject) + h := sha256.New() + h.Write([]byte(base)) + sha := h.Sum(nil) + hash := base32.StdEncoding.EncodeToString(sha) + + return hash, nil +} + +func cleanSubject(subject string) string { + split := strings.Split(subject, ".") + cleaned := "" + + for i, tok := range split { + if tok == "*" || tok == ">" { + if i == 0 { + cleaned = "_" + break + } + + cleaned = strings.Join(split[:i], ".") + break + } + } + if cleaned == "" { + cleaned = subject + } + return cleaned +} diff --git a/activation_claims_test.go b/activation_claims_test.go new file mode 100644 index 0000000..19532b3 --- /dev/null +++ b/activation_claims_test.go @@ -0,0 +1,426 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewActivationClaims(t *testing.T) { + okp := createOperatorNKey(t) + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + activation := NewActivationClaims(apk) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + activation.Limits.Max = 10 + activation.Limits.Payload = 10 + activation.Limits.Src = "192.0.2.0/24" + + activation.ImportSubject = "foo" + activation.Name = "Foo" + activation.ImportType = Stream + + vr := CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + actJwt := encode(activation, okp, t) + + activation2, err := DecodeActivationClaims(actJwt) + if err != nil { + t.Fatal("failed to decode activation", err) + } + + AssertEquals(activation.String(), activation2.String(), t) + + AssertEquals(activation.Claims() != nil, true, t) + AssertEquals(activation.Payload() != nil, true, t) +} + +func TestInvalidActivationTargets(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewActivationClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode user with with %q subject", i.name) + t.Fail() + } + } +} + +func TestInvalidActivationClaimIssuer(t *testing.T) { + akp := createAccountNKey(t) + ac := NewActivationClaims(publicKey(akp, t)) + ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + aJwt := encode(ac, akp, t) + + temp, err := DecodeGeneric(aJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeAccountClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode account signed by %q", i.name) + t.Fail() + } + } +} + +func TestPublicIsNotValid(t *testing.T) { + c := NewActivationClaims("public") + _, err := c.Encode(createOperatorNKey(t)) + if err == nil { + t.Fatal("should not have encoded public activation anymore") + } +} + +func TestNilActivationClaim(t *testing.T) { + v := NewActivationClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestActivationImportSubjectValidation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Issuer = apk + activation.Subject = apk2 + + activation.ImportSubject = "foo" + activation.Name = "Foo" + activation.ImportType = Stream + + vr := CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportType = Service + + vr = CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "foo.*" // wildcards are bad + + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Error("wildcard service activation should not pass validation") + } + + activation.ImportType = Stream // Stream is ok with wildcards + vr = CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "" // empty strings are bad + + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Error("empty activation should not pass validation") + } + + activation.ImportSubject = "foo bar" // spaces are bad + + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Error("spaces in activation should not pass validation") + } +} + +func TestActivationValidation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Issuer = apk + activation.Subject = apk2 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + + activation.ImportSubject = "foo" + activation.Name = "Foo" + activation.ImportType = Stream + + activation.Limits.Max = 10 + activation.Limits.Payload = 10 + activation.Limits.Src = "192.0.2.0/24" + activation.Limits.Times = []TimeRange{ + { + Start: "01:15:00", + End: "03:15:00", + }, + { + Start: "06:15:00", + End: "09:15:00", + }, + } + + vr := CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "times.*" + activation.ImportType = Stream + activation.Name = "times" + + vr = CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "other.*" + activation.ImportType = Stream + activation.Name = "other" + + activation.Limits.Max = -1 + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + activation.Limits.Max = 10 + activation.Limits.Payload = -1 + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + activation.Limits.Payload = 10 + activation.Limits.Src = "hello world" + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + activation.Limits.Payload = 10 + activation.Limits.Src = "hello world" + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + tr := TimeRange{ + Start: "hello", + End: "03:15:00", + } + activation.Limits.Src = "192.0.2.0/24" + activation.Limits.Times = append(activation.Limits.Times, tr) + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } +} + +func TestActivationHashIDLimits(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Issuer = apk + activation.Subject = apk2 + + _, err := activation.HashID() + if err == nil { + t.Fatal("activation without subject should fail to hash") + } + + activation.ImportSubject = "times.*" + activation.ImportType = Stream + activation.Name = "times" + + hash, err := activation.HashID() + if err != nil { + t.Fatalf("activation with subject should hash %v", err) + } + + activation2 := NewActivationClaims(apk) + activation2.Issuer = apk + activation2.Subject = apk2 + activation2.ImportSubject = "times.*.bar" + activation2.ImportType = Stream + activation2.Name = "times" + + hash2, err := activation2.HashID() + if err != nil { + t.Fatalf("activation with subject should hash %v", err) + } + + if hash != hash2 { + t.Fatal("subjects should be stripped to create hash") + } +} + +func TestActivationClaimAccountIDValidation(t *testing.T) { + issuerAccountKP := createAccountNKey(t) + issuerAccountPK := publicKey(issuerAccountKP, t) + + issuerKP := createAccountNKey(t) + issuerPK := publicKey(issuerKP, t) + + account := NewAccountClaims(issuerAccountPK) + account.SigningKeys.Add(issuerPK) + token, err := account.Encode(issuerAccountKP) + if err != nil { + t.Fatal(err) + } + account, err = DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + + importerKP := createAccountNKey(t) + importerPK := publicKey(importerKP, t) + + ac := NewActivationClaims(importerPK) + ac.IssuerAccount = issuerAccountPK + ac.Name = "foo.bar" + ac.Activation.ImportSubject = Subject("foo.bar") + ac.Activation.ImportType = Stream + + var vr ValidationResults + ac.Validate(&vr) + if len(vr.Issues) != 0 { + t.Fatalf("expected no validation errors: %v", vr.Issues[0].Error()) + } + token, err = ac.Encode(issuerKP) + if err != nil { + t.Fatal(err) + } + ac, err = DecodeActivationClaims(token) + if err != nil { + t.Fatal(err) + } + if ac.Issuer != issuerPK { + t.Fatal("expected activation subject to be different") + } + if ac.IssuerAccount != issuerAccountPK { + t.Fatal("expected activation account id to be different") + } + + ac.IssuerAccount = publicKey(createUserNKey(t), t) + ac.Validate(&vr) + if len(vr.Issues) != 1 { + t.Fatal("expected validation error") + } + + if !account.DidSign(ac) { + t.Fatal("expected account to have signed activation") + } +} + +func TestCleanSubject(t *testing.T) { + input := [][]string{ + {"foo", "foo"}, + {"*", "_"}, + {">", "_"}, + {"foo.*", "foo"}, + {"foo.bar.>", "foo.bar"}, + {"foo.*.bar", "foo"}, + {"bam.boom.blat.*", "bam.boom.blat"}, + {"*.blam", "_"}, + } + + for _, pair := range input { + clean := cleanSubject(pair[0]) + if pair[1] != clean { + t.Errorf("Expected %s but got %s", pair[1], clean) + } + } +} diff --git a/claims.go b/claims.go new file mode 100644 index 0000000..d402bcc --- /dev/null +++ b/claims.go @@ -0,0 +1,302 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "crypto/sha512" + "encoding/base32" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nkeys" +) + +// ClaimType is used to indicate the type of JWT being stored in a Claim +type ClaimType string + +const ( + // AccountClaim is the type of an Account JWT + AccountClaim = "account" + //ActivationClaim is the type of an activation JWT + ActivationClaim = "activation" + //UserClaim is the type of an user JWT + UserClaim = "user" + //ServerClaim is the type of an server JWT + ServerClaim = "server" + //ClusterClaim is the type of an cluster JWT + ClusterClaim = "cluster" + //OperatorClaim is the type of an operator JWT + OperatorClaim = "operator" +) + +// Claims is a JWT claims +type Claims interface { + Claims() *ClaimsData + Encode(kp nkeys.KeyPair) (string, error) + ExpectedPrefixes() []nkeys.PrefixByte + Payload() interface{} + String() string + Validate(vr *ValidationResults) + Verify(payload string, sig []byte) bool +} + +// ClaimsData is the base struct for all claims +type ClaimsData struct { + Audience string `json:"aud,omitempty"` + Expires int64 `json:"exp,omitempty"` + ID string `json:"jti,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + Issuer string `json:"iss,omitempty"` + Name string `json:"name,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Subject string `json:"sub,omitempty"` + Tags TagList `json:"tags,omitempty"` + Type ClaimType `json:"type,omitempty"` +} + +// Prefix holds the prefix byte for an NKey +type Prefix struct { + nkeys.PrefixByte +} + +func encodeToString(d []byte) string { + return base64.RawURLEncoding.EncodeToString(d) +} + +func decodeString(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +func serialize(v interface{}) (string, error) { + j, err := json.Marshal(v) + if err != nil { + return "", err + } + return encodeToString(j), nil +} + +func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (string, error) { + if header == nil { + return "", errors.New("header is required") + } + + if kp == nil { + return "", errors.New("keypair is required") + } + + if c.Subject == "" { + return "", errors.New("subject is not set") + } + + h, err := serialize(header) + if err != nil { + return "", err + } + + issuerBytes, err := kp.PublicKey() + if err != nil { + return "", err + } + + prefixes := claim.ExpectedPrefixes() + if prefixes != nil { + ok := false + for _, p := range prefixes { + switch p { + case nkeys.PrefixByteAccount: + if nkeys.IsValidPublicAccountKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteOperator: + if nkeys.IsValidPublicOperatorKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteServer: + if nkeys.IsValidPublicServerKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteCluster: + if nkeys.IsValidPublicClusterKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteUser: + if nkeys.IsValidPublicUserKey(issuerBytes) { + ok = true + } + } + } + if !ok { + return "", fmt.Errorf("unable to validate expected prefixes - %v", prefixes) + } + } + + c.Issuer = string(issuerBytes) + c.IssuedAt = time.Now().UTC().Unix() + + c.ID, err = c.hash() + if err != nil { + return "", err + } + + payload, err := serialize(claim) + if err != nil { + return "", err + } + + sig, err := kp.Sign([]byte(payload)) + if err != nil { + return "", err + } + eSig := encodeToString(sig) + return fmt.Sprintf("%s.%s.%s", h, payload, eSig), nil +} + +func (c *ClaimsData) hash() (string, error) { + j, err := json.Marshal(c) + if err != nil { + return "", err + } + h := sha512.New512_256() + h.Write(j) + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(h.Sum(nil)), nil +} + +// 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) +} + +// Returns a JSON representation of the claim +func (c *ClaimsData) String(claim interface{}) string { + j, err := json.MarshalIndent(claim, "", " ") + if err != nil { + return "" + } + return string(j) +} + +func parseClaims(s string, target Claims) error { + h, err := decodeString(s) + if err != nil { + return err + } + return json.Unmarshal(h, &target) +} + +// Verify verifies that the encoded payload was signed by the +// provided public key. Verify is called automatically with +// the claims portion of the token and the public key in the claim. +// Client code need to insure that the public key in the +// claim is trusted. +func (c *ClaimsData) Verify(payload string, sig []byte) bool { + // decode the public key + kp, err := nkeys.FromPublicKey(c.Issuer) + if err != nil { + return false + } + if err := kp.Verify([]byte(payload), sig); err != nil { + return false + } + return true +} + +// Validate checks a claim to make sure it is valid. Validity checks +// include expiration and not before constraints. +func (c *ClaimsData) Validate(vr *ValidationResults) { + now := time.Now().UTC().Unix() + if c.Expires > 0 && now > c.Expires { + vr.AddTimeCheck("claim is expired") + } + + if c.NotBefore > 0 && c.NotBefore > now { + vr.AddTimeCheck("claim is not yet valid") + } +} + +// IsSelfSigned returns true if the claims issuer is the subject +func (c *ClaimsData) IsSelfSigned() bool { + return c.Issuer == c.Subject +} + +// Decode takes a JWT string decodes it and validates it +// and return the embedded Claims. If the token header +// doesn't match the expected algorithm, or the claim is +// not valid or verification fails an error is returned. +func Decode(token string, target Claims) error { + // must have 3 chunks + chunks := strings.Split(token, ".") + if len(chunks) != 3 { + return errors.New("expected 3 chunks") + } + + _, err := parseHeaders(chunks[0]) + if err != nil { + return err + } + + if err := parseClaims(chunks[1], target); err != nil { + return err + } + + sig, err := decodeString(chunks[2]) + if err != nil { + return err + } + + if !target.Verify(chunks[1], sig) { + return errors.New("claim failed signature verification") + } + + prefixes := target.ExpectedPrefixes() + if prefixes != nil { + ok := false + issuer := target.Claims().Issuer + for _, p := range prefixes { + switch p { + case nkeys.PrefixByteAccount: + if nkeys.IsValidPublicAccountKey(issuer) { + ok = true + } + case nkeys.PrefixByteOperator: + if nkeys.IsValidPublicOperatorKey(issuer) { + ok = true + } + case nkeys.PrefixByteServer: + if nkeys.IsValidPublicServerKey(issuer) { + ok = true + } + case nkeys.PrefixByteCluster: + if nkeys.IsValidPublicClusterKey(issuer) { + ok = true + } + case nkeys.PrefixByteUser: + if nkeys.IsValidPublicUserKey(issuer) { + ok = true + } + } + } + if !ok { + return fmt.Errorf("unable to validate expected prefixes - %v", prefixes) + } + } + + return nil +} diff --git a/cluster_claims.go b/cluster_claims.go new file mode 100644 index 0000000..bbfcf06 --- /dev/null +++ b/cluster_claims.go @@ -0,0 +1,94 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + + "github.com/nats-io/nkeys" +) + +// Cluster stores the cluster specific elements of a cluster JWT +type Cluster struct { + Trust []string `json:"identity,omitempty"` + Accounts []string `json:"accts,omitempty"` + AccountURL string `json:"accturl,omitempty"` + OperatorURL string `json:"opurl,omitempty"` +} + +// Validate checks the cluster and permissions for a cluster JWT +func (c *Cluster) Validate(vr *ValidationResults) { + // fixme validate cluster data +} + +// ClusterClaims defines the data in a cluster JWT +type ClusterClaims struct { + ClaimsData + Cluster `json:"nats,omitempty"` +} + +// NewClusterClaims creates a new cluster JWT with the specified subject/public key +func NewClusterClaims(subject string) *ClusterClaims { + if subject == "" { + return nil + } + c := &ClusterClaims{} + c.Subject = subject + return c +} + +// Encode tries to turn the cluster claims into a JWT string +func (c *ClusterClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicClusterKey(c.Subject) { + return "", errors.New("expected subject to be a cluster public key") + } + c.ClaimsData.Type = ClusterClaim + return c.ClaimsData.Encode(pair, c) +} + +// DecodeClusterClaims tries to parse cluster claims from a JWT string +func DecodeClusterClaims(token string) (*ClusterClaims, error) { + v := ClusterClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (c *ClusterClaims) String() string { + return c.ClaimsData.String(c) +} + +// Payload returns the cluster specific data +func (c *ClusterClaims) Payload() interface{} { + return &c.Cluster +} + +// Validate checks the generic and cluster data in the cluster claims +func (c *ClusterClaims) Validate(vr *ValidationResults) { + c.ClaimsData.Validate(vr) + c.Cluster.Validate(vr) +} + +// ExpectedPrefixes defines the types that can encode a cluster JWT, operator or cluster +func (c *ClusterClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} +} + +// Claims returns the generic data +func (c *ClusterClaims) Claims() *ClaimsData { + return &c.ClaimsData +} diff --git a/cluster_claims_test.go b/cluster_claims_test.go new file mode 100644 index 0000000..5573c8d --- /dev/null +++ b/cluster_claims_test.go @@ -0,0 +1,132 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewClusterClaims(t *testing.T) { + ckp := createClusterNKey(t) + skp := createClusterNKey(t) + + uc := NewClusterClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeClusterClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestClusterClaimsIssuer(t *testing.T) { + ckp := createClusterNKey(t) + skp := createClusterNKey(t) + + uc := NewClusterClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + temp, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), true}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeClusterClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode cluster signed by %q", i.name) + t.Fail() + } + } +} + +func TestClusterSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"server", createServerNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"cluster", createClusterNKey(t), true}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewClusterClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode cluster with with %q subject", i.name) + t.Fail() + } + } +} + +func TestNewNilClusterClaims(t *testing.T) { + v := NewClusterClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestClusterType(t *testing.T) { + c := NewClusterClaims(publicKey(createClusterNKey(t), t)) + s := encode(c, createClusterNKey(t), t) + u, err := DecodeClusterClaims(s) + if err != nil { + t.Fatalf("failed to decode cluster claim: %v", err) + } + + if ClusterClaim != u.Type { + t.Fatalf("type is unexpected %q (wanted cluster)", u.Type) + } + +} diff --git a/creds_utils.go b/creds_utils.go new file mode 100644 index 0000000..bb913dc --- /dev/null +++ b/creds_utils.go @@ -0,0 +1,203 @@ +package jwt + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/nats-io/nkeys" +) + +// DecorateJWT returns a decorated JWT that describes the kind of JWT +func DecorateJWT(jwtString string) ([]byte, error) { + gc, err := DecodeGeneric(jwtString) + if err != nil { + return nil, err + } + return formatJwt(string(gc.Type), jwtString) +} + +func formatJwt(kind string, jwtString string) ([]byte, error) { + templ := `-----BEGIN NATS %s JWT----- +%s +------END NATS %s JWT------ + +` + w := bytes.NewBuffer(nil) + kind = strings.ToUpper(kind) + _, err := fmt.Fprintf(w, templ, kind, jwtString, kind) + if err != nil { + return nil, err + } + return w.Bytes(), nil +} + +// DecorateSeed takes a seed and returns a string that wraps +// the seed in the form: +// ************************* IMPORTANT ************************* +// NKEY Seed printed below can be used sign and prove identity. +// NKEYs are sensitive and should be treated as secrets. +// +// -----BEGIN USER NKEY SEED----- +// SUAIO3FHUX5PNV2LQIIP7TZ3N4L7TX3W53MQGEIVYFIGA635OZCKEYHFLM +// ------END USER NKEY SEED------ +func DecorateSeed(seed []byte) ([]byte, error) { + w := bytes.NewBuffer(nil) + ts := bytes.TrimSpace(seed) + pre := string(ts[0:2]) + kind := "" + switch pre { + case "SU": + kind = "USER" + case "SA": + kind = "ACCOUNT" + case "SO": + kind = "OPERATOR" + default: + return nil, errors.New("seed is not an operator, account or user seed") + } + header := `************************* IMPORTANT ************************* +NKEY Seed printed below can be used to sign and prove identity. +NKEYs are sensitive and should be treated as secrets. + +-----BEGIN %s NKEY SEED----- +` + _, err := fmt.Fprintf(w, header, kind) + if err != nil { + return nil, err + } + w.Write(ts) + + footer := ` +------END %s NKEY SEED------ + +************************************************************* +` + _, err = fmt.Fprintf(w, footer, kind) + if err != nil { + return nil, err + } + return w.Bytes(), nil +} + +var userConfigRE = regexp.MustCompile(`\s*(?:(?:[-]{3,}[^\n]*[-]{3,}\n)(.+)(?:\n\s*[-]{3,}[^\n]*[-]{3,}\n))`) + +// An user config file looks like this: +// -----BEGIN NATS USER JWT----- +// eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5... +// ------END NATS USER JWT------ +// +// ************************* IMPORTANT ************************* +// NKEY Seed printed below can be used sign and prove identity. +// NKEYs are sensitive and should be treated as secrets. +// +// -----BEGIN USER NKEY SEED----- +// SUAIO3FHUX5PNV2LQIIP7TZ3N4L7TX3W53MQGEIVYFIGA635OZCKEYHFLM +// ------END USER NKEY SEED------ + +// FormatUserConfig returns a decorated file with a decorated JWT and decorated seed +func FormatUserConfig(jwtString string, seed []byte) ([]byte, error) { + gc, err := DecodeGeneric(jwtString) + if err != nil { + return nil, err + } + if gc.Type != UserClaim { + return nil, fmt.Errorf("%q cannot be serialized as a user config", string(gc.Type)) + } + + w := bytes.NewBuffer(nil) + + jd, err := formatJwt(string(gc.Type), jwtString) + if err != nil { + return nil, err + } + _, err = w.Write(jd) + if err != nil { + return nil, err + } + if !bytes.HasPrefix(bytes.TrimSpace(seed), []byte("SU")) { + return nil, fmt.Errorf("nkey seed is not an user seed") + } + + d, err := DecorateSeed(seed) + if err != nil { + return nil, err + } + _, err = w.Write(d) + if err != nil { + return nil, err + } + + return w.Bytes(), nil +} + +// ParseDecoratedJWT takes a creds file and returns the JWT portion. +func ParseDecoratedJWT(contents []byte) (string, error) { + items := userConfigRE.FindAllSubmatch(contents, -1) + if len(items) == 0 { + return string(contents), nil + } + // First result should be the user JWT. + // We copy here so that if the file contained a seed file too we wipe appropriately. + raw := items[0][1] + tmp := make([]byte, len(raw)) + copy(tmp, raw) + return string(tmp), nil +} + +// ParseDecoratedNKey takes a creds file, finds the NKey portion and creates a +// key pair from it. +func ParseDecoratedNKey(contents []byte) (nkeys.KeyPair, error) { + var seed []byte + + items := userConfigRE.FindAllSubmatch(contents, -1) + if len(items) > 1 { + seed = items[1][1] + } else { + lines := bytes.Split(contents, []byte("\n")) + for _, line := range lines { + if bytes.HasPrefix(bytes.TrimSpace(line), []byte("SO")) || + bytes.HasPrefix(bytes.TrimSpace(line), []byte("SA")) || + bytes.HasPrefix(bytes.TrimSpace(line), []byte("SU")) { + seed = line + break + } + } + } + if seed == nil { + return nil, errors.New("no nkey seed found") + } + if !bytes.HasPrefix(seed, []byte("SO")) && + !bytes.HasPrefix(seed, []byte("SA")) && + !bytes.HasPrefix(seed, []byte("SU")) { + return nil, errors.New("doesn't contain a seed nkey") + } + kp, err := nkeys.FromSeed(seed) + if err != nil { + return nil, err + } + return kp, nil +} + +// ParseDecoratedUserNKey takes a creds file, finds the NKey portion and creates a +// key pair from it. Similar to ParseDecoratedNKey but fails for non-user keys. +func ParseDecoratedUserNKey(contents []byte) (nkeys.KeyPair, error) { + nk, err := ParseDecoratedNKey(contents) + if err != nil { + return nil, err + } + seed, err := nk.Seed() + if err != nil { + return nil, err + } + if !bytes.HasPrefix(seed, []byte("SU")) { + return nil, errors.New("doesn't contain an user seed nkey") + } + kp, err := nkeys.FromSeed(seed) + if err != nil { + return nil, err + } + return kp, nil +} diff --git a/creds_utils_test.go b/creds_utils_test.go new file mode 100644 index 0000000..5075274 --- /dev/null +++ b/creds_utils_test.go @@ -0,0 +1,211 @@ +package jwt + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/nats-io/nkeys" +) + +func makeJWT(t *testing.T) (string, nkeys.KeyPair) { + akp := createAccountNKey(t) + kp := createUserNKey(t) + pk := publicKey(kp, t) + oc := NewUserClaims(pk) + token, err := oc.Encode(akp) + if err != nil { + t.Fatal(err) + } + return token, kp +} + +func Test_DecorateJwt(t *testing.T) { + token, _ := makeJWT(t) + d, err := DecorateJWT(token) + if err != nil { + t.Fatal(err) + } + s := string(d) + if !strings.Contains(s, "-BEGIN NATS USER JWT-") { + t.Fatal("doesn't contain expected header") + } + if !strings.Contains(s, "eyJ0") { + t.Fatal("doesn't contain public key") + } + if !strings.Contains(s, "-END NATS USER JWT------\n\n") { + t.Fatal("doesn't contain expected footer") + } +} + +func Test_FormatUserConfig(t *testing.T) { + token, kp := makeJWT(t) + d, err := FormatUserConfig(token, seedKey(kp, t)) + if err != nil { + t.Fatal(err) + } + s := string(d) + if !strings.Contains(s, "-BEGIN NATS USER JWT-") { + t.Fatal("doesn't contain expected header") + } + if !strings.Contains(s, "eyJ0") { + t.Fatal("doesn't contain public key") + } + if !strings.Contains(s, "-END NATS USER JWT-") { + t.Fatal("doesn't contain expected footer") + } + + validateSeed(t, d, kp) +} + +func validateSeed(t *testing.T, decorated []byte, nk nkeys.KeyPair) { + kind := "" + seed := seedKey(nk, t) + switch string(seed[0:2]) { + case "SO": + kind = "operator" + case "SA": + kind = "account" + case "SU": + kind = "user" + default: + kind = "not supported" + } + kind = strings.ToUpper(kind) + + s := string(decorated) + if !strings.Contains(s, fmt.Sprintf("\n\n-----BEGIN %s NKEY SEED-", kind)) { + t.Fatal("doesn't contain expected seed header") + } + if !strings.Contains(s, string(seed)) { + t.Fatal("doesn't contain the seed") + } + if !strings.Contains(s, fmt.Sprintf("-END %s NKEY SEED------\n\n", kind)) { + t.Fatal("doesn't contain expected seed footer") + } +} + +func Test_ParseDecoratedJWT(t *testing.T) { + token, _ := makeJWT(t) + + t2, err := ParseDecoratedJWT([]byte(token)) + if err != nil { + t.Fatal(err) + } + if token != t2 { + t.Fatal("jwt didn't match expected") + } + + decorated, err := DecorateJWT(token) + if err != nil { + t.Fatal(err) + } + + t3, err := ParseDecoratedJWT(decorated) + if err != nil { + t.Fatal(err) + } + if token != t3 { + t.Fatal("parse decorated jwt didn't match expected") + } +} + +func Test_ParseDecoratedJWTBad(t *testing.T) { + v, err := ParseDecoratedJWT([]byte("foo")) + if err != nil { + t.Fatal(err) + } + if v != "foo" { + t.Fatal("unexpected input was not returned") + } +} + +func Test_ParseDecoratedSeed(t *testing.T) { + token, ukp := makeJWT(t) + us := seedKey(ukp, t) + decorated, err := FormatUserConfig(token, us) + if err != nil { + t.Fatal(err) + } + kp, err := ParseDecoratedUserNKey(decorated) + if err != nil { + t.Fatal(err) + } + pu := seedKey(kp, t) + if !bytes.Equal(us, pu) { + t.Fatal("seeds don't match") + } +} + +func Test_ParseDecoratedBadKey(t *testing.T) { + token, ukp := makeJWT(t) + us, err := ukp.Seed() + if err != nil { + t.Fatal(err) + } + akp := createAccountNKey(t) + as := seedKey(akp, t) + + _, err = FormatUserConfig(token, as) + if err == nil { + t.Fatal("should have failed to encode with bad seed") + } + + sc, err := FormatUserConfig(token, us) + if err != nil { + t.Fatal(err) + } + bad := strings.Replace(string(sc), string(us), string(as), -1) + _, err = ParseDecoratedUserNKey([]byte(bad)) + if err == nil { + t.Fatal("parse should have failed for non user nkey") + } +} + +func Test_FailsOnNonUserJWT(t *testing.T) { + akp := createAccountNKey(t) + pk := publicKey(akp, t) + + ac := NewAccountClaims(pk) + token, err := ac.Encode(akp) + if err != nil { + t.Fatal(err) + } + ukp := createUserNKey(t) + us := seedKey(ukp, t) + _, err = FormatUserConfig(token, us) + if err == nil { + t.Fatal("should have failed with account claims") + } +} + +func Test_DecorateNKeys(t *testing.T) { + var kps []nkeys.KeyPair + kps = append(kps, createOperatorNKey(t)) + kps = append(kps, createAccountNKey(t)) + kps = append(kps, createUserNKey(t)) + + for _, kp := range kps { + seed := seedKey(kp, t) + d, err := DecorateSeed(seed) + if err != nil { + t.Fatal(err, string(seed)) + } + validateSeed(t, d, kp) + + kp2, err := ParseDecoratedNKey(d) + if err != nil { + t.Fatal(string(seed), err) + } + seed2 := seedKey(kp2, t) + if !bytes.Equal(seed, seed2) { + t.Fatalf("seeds dont match %q != %q", string(seed), string(seed2)) + } + } + + _, err := ParseDecoratedNKey([]byte("bad")) + if err == nil { + t.Fatal("required error parsing bad nkey") + } +} diff --git a/decoder_test.go b/decoder_test.go new file mode 100644 index 0000000..9206d18 --- /dev/null +++ b/decoder_test.go @@ -0,0 +1,404 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewToken(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + claims := NewGenericClaims(publicKey(createUserNKey(t), t)) + claims.Data["foo"] = "bar" + + token, err := claims.Encode(kp) + if err != nil { + t.Fatal("error encoding token", err) + } + + c, err := DecodeGeneric(token) + if err != nil { + t.Fatal(err) + } + + if claims.NotBefore != c.NotBefore { + t.Fatal("notbefore don't match") + } + + if claims.Issuer != c.Issuer { + t.Fatal("notbefore don't match") + } + + if !reflect.DeepEqual(claims.Data, c.Data) { + t.Fatal("data sections don't match") + } +} + +func TestBadType(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + h := Header{"JWS", AlgorithmNkey} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + claim, err := DecodeGeneric(token) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != fmt.Sprintf("not supported type %q", "JWS") { + t.Fatal("expected not supported type error") + } +} + +func TestBadAlgo(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + h := Header{TokenTypeJwt, "foobar"} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + claim, err := DecodeGeneric(token) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != fmt.Sprintf("unexpected %q algorithm", "foobar") { + t.Fatal("expected unexpected algorithm") + } +} + +func TestBadJWT(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + h := Header{"JWS", AlgorithmNkey} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + chunks := strings.Split(token, ".") + badToken := fmt.Sprintf("%s.%s", chunks[0], chunks[1]) + + claim, err := DecodeGeneric(badToken) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != "expected 3 chunks" { + t.Fatalf("unexpeced error: %q", err.Error()) + } +} + +func TestBadSignature(t *testing.T) { + kp := createAccountNKey(t) + + h := Header{TokenTypeJwt, AlgorithmNkey} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + token = token + "A" + + claim, err := DecodeGeneric(token) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != "claim failed signature verification" { + m := fmt.Sprintf("expected failed signature: %q", err.Error()) + t.Fatal(m) + } +} + +func TestDifferentPayload(t *testing.T) { + akp1 := createAccountNKey(t) + + c1 := NewGenericClaims(publicKey(createUserNKey(t), t)) + c1.Data["foo"] = "barz" + jwt1 := encode(c1, akp1, t) + c1t := strings.Split(jwt1, ".") + c1.Data["foo"] = "bar" + + kp2 := createAccountNKey(t) + token2 := encode(c1, kp2, t) + c2t := strings.Split(token2, ".") + + c1t[1] = c2t[1] + + claim, err := DecodeGeneric(fmt.Sprintf("%s.%s.%s", c1t[0], c1t[1], c1t[2])) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != "claim failed signature verification" { + m := fmt.Sprintf("expected failed signature: %q", err.Error()) + t.Fatal(m) + } +} + +func TestExpiredToken(t *testing.T) { + akp := createAccountNKey(t) + c := NewGenericClaims(publicKey(akp, t)) + c.Expires = time.Now().UTC().Unix() - 100 + c.Data["foo"] = "barz" + + vr := CreateValidationResults() + c.Validate(vr) + if !vr.IsBlocking(true) { + t.Fatalf("expired tokens should be blocking when time is included") + } + + if vr.IsBlocking(false) { + t.Fatalf("expired tokens should not be blocking when time is not included") + } +} + +func TestNotYetValid(t *testing.T) { + akp1, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + c := NewGenericClaims(publicKey(akp1, t)) + c.NotBefore = time.Now().Add(time.Duration(1) * time.Hour).UTC().Unix() + + vr := CreateValidationResults() + c.Validate(vr) + if !vr.IsBlocking(true) { + t.Fatalf("not yet valid tokens should be blocking when time is included") + } + + if vr.IsBlocking(false) { + t.Fatalf("not yet valid tokens should not be blocking when time is not included") + } +} + +func TestIssuedAtIsSet(t *testing.T) { + akp := createAccountNKey(t) + c := NewGenericClaims(publicKey(akp, t)) + c.Data["foo"] = "barz" + + token, err := c.Encode(akp) + if err != nil { + t.Fatal(err) + } + + claim, err := DecodeGeneric(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if claim.IssuedAt == 0 { + t.Fatalf("issued at is not set") + } +} + +func TestSample(t *testing.T) { + // Need a private key to sign the claim + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + // add a bunch of claims + claims.Data["foo"] = "bar" + + // serialize the claim to a JWT token + token, err := claims.Encode(akp) + if err != nil { + t.Fatal("error encoding token", err) + } + + // on the receiving side, decode the token + c, err := DecodeGeneric(token) + if err != nil { + t.Fatal(err) + } + + // if the token was decoded, it means that it + // validated and it wasn't tampered. the remaining and + // required test is to insure the issuer is trusted + pk, err := akp.PublicKey() + if err != nil { + t.Fatalf("unable to read public key: %v", err) + } + + if c.Issuer != string(pk) { + t.Fatalf("the public key is not trusted") + } +} + +func TestBadHeaderEncoding(t *testing.T) { + // the '=' will be illegal + _, err := parseHeaders("=hello=") + if err == nil { + t.Fatal("should have failed it is not encoded") + } +} + +func TestBadClaimsEncoding(t *testing.T) { + // the '=' will be illegal + c := GenericClaims{} + err := parseClaims("=hello=", &c) + if err == nil { + t.Fatal("should have failed it is not encoded") + } +} + +func TestBadHeaderJSON(t *testing.T) { + payload := encodeToString([]byte("{foo: bar}")) + _, err := parseHeaders(payload) + if err == nil { + t.Fatal("should have failed bad json") + } +} + +func TestBadClaimsJSON(t *testing.T) { + payload := encodeToString([]byte("{foo: bar}")) + c := GenericClaims{} + err := parseClaims(payload, &c) + if err == nil { + t.Fatal("should have failed bad json") + } +} + +func TestBadPublicKeyDecodeGeneric(t *testing.T) { + c := &GenericClaims{} + c.Issuer = "foo" + if ok := c.Verify("foo", []byte("bar")); ok { + t.Fatal("Should have failed to verify") + } +} + +func TestBadSig(t *testing.T) { + opk := createOperatorNKey(t) + kp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(kp, t)) + claims.Data["foo"] = "bar" + + // serialize the claim to a JWT token + token := encode(claims, opk, t) + + tokens := strings.Split(token, ".") + badToken := fmt.Sprintf("%s.%s.=hello=", tokens[0], tokens[1]) + _, err := DecodeGeneric(badToken) + if err == nil { + t.Fatal("should have failed to base64 decode signature") + } +} + +func TestClaimsStringIsJSON(t *testing.T) { + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + // add a bunch of claims + claims.Data["foo"] = "bar" + + claims2 := NewGenericClaims(publicKey(akp, t)) + json.Unmarshal([]byte(claims.String()), claims2) + if claims2.Data["foo"] != "bar" { + t.Fatalf("Failed to decode expected claim from String representation: %q", claims.String()) + } +} + +func TestDoEncodeNilHeader(t *testing.T) { + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + _, err := claims.doEncode(nil, nil, claims) + if err == nil { + t.Fatal("should have failed to encode") + } + if err.Error() != "header is required" { + t.Fatalf("unexpected error on encode: %v", err) + } +} + +func TestDoEncodeNilKeyPair(t *testing.T) { + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + _, err := claims.doEncode(&Header{}, nil, claims) + if err == nil { + t.Fatal("should have failed to encode") + } + if err.Error() != "keypair is required" { + t.Fatalf("unexpected error on encode: %v", err) + } +} + +// if this fails, the URL decoder was changed and JWTs will flap +func TestUsingURLDecoder(t *testing.T) { + token := "eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiJGQ1lZRjJLR0EzQTZHTlZQR0pIVjNUSExYR1VZWkFUREZLV1JTT1czUUo1T0k3QlJST0ZRIiwiaWF0IjoxNTQzOTQzNjc1LCJpc3MiOiJBQ1NKWkhOWlI0QUFUVE1KNzdUV1JONUJHVUZFWFhUS0gzWEtGTldDRkFCVzJRWldOUTRDQkhRRSIsInN1YiI6IkFEVEFHWVZYRkpPRENRM0g0VUZQQU43R1dXWk1BVU9FTTJMMkRWQkFWVFdLM01TU0xUS1JUTzVGIiwidHlwZSI6ImFjdGl2YXRpb24iLCJuYXRzIjp7InN1YmplY3QiOiJmb28iLCJ0eXBlIjoic2VydmljZSJ9fQ.HCZTCF-7wolS3Wjx3swQWMkoDhoo_4gp9EsuM5diJfZrH8s6NTpO0iT7_fKZm7dNDeEoqjwU--3ebp8j-Mm_Aw" + ac, err := DecodeActivationClaims(token) + if err != nil { + t.Fatal("shouldn't have failed to decode", err) + } + if ac == nil { + t.Fatal("should have returned activation") + } +} diff --git a/exports.go b/exports.go new file mode 100644 index 0000000..5578f98 --- /dev/null +++ b/exports.go @@ -0,0 +1,236 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "time" +) + +// ResponseType is used to store an export response type +type ResponseType string + +const ( + // ResponseTypeSingleton is used for a service that sends a single response only + ResponseTypeSingleton = "Singleton" + + // ResponseTypeStream is used for a service that will send multiple responses + ResponseTypeStream = "Stream" + + // ResponseTypeChunked is used for a service that sends a single response in chunks (so not quite a stream) + ResponseTypeChunked = "Chunked" +) + +// ServiceLatency is used when observing and exported service for +// latency measurements. +// Sampling 1-100, represents sampling rate, defaults to 100. +// Results is the subject where the latency metrics are published. +// A metric will be defined by the nats-server's ServiceLatency. Time durations +// are in nanoseconds. +// see https://github.com/nats-io/nats-server/blob/master/server/accounts.go#L524 +// e.g. +// { +// "app": "dlc22", +// "start": "2019-09-16T21:46:23.636869585-07:00", +// "svc": 219732, +// "nats": { +// "req": 320415, +// "resp": 228268, +// "sys": 0 +// }, +// "total": 768415 +// } +// +type ServiceLatency struct { + Sampling int `json:"sampling,omitempty"` + Results Subject `json:"results"` +} + +func (sl *ServiceLatency) Validate(vr *ValidationResults) { + if sl.Sampling < 1 || sl.Sampling > 100 { + vr.AddError("sampling percentage needs to be between 1-100") + } + sl.Results.Validate(vr) + if sl.Results.HasWildCards() { + vr.AddError("results subject can not contain wildcards") + } +} + +// Export represents a single export +type Export struct { + Name string `json:"name,omitempty"` + Subject Subject `json:"subject,omitempty"` + Type ExportType `json:"type,omitempty"` + TokenReq bool `json:"token_req,omitempty"` + Revocations RevocationList `json:"revocations,omitempty"` + ResponseType ResponseType `json:"response_type,omitempty"` + Latency *ServiceLatency `json:"service_latency,omitempty"` +} + +// IsService returns true if an export is for a service +func (e *Export) IsService() bool { + return e.Type == Service +} + +// IsStream returns true if an export is for a stream +func (e *Export) IsStream() bool { + return e.Type == Stream +} + +// IsSingleResponse returns true if an export has a single response +// or no resopnse type is set, also checks that the type is service +func (e *Export) IsSingleResponse() bool { + return e.Type == Service && (e.ResponseType == ResponseTypeSingleton || e.ResponseType == "") +} + +// IsChunkedResponse returns true if an export has a chunked response +func (e *Export) IsChunkedResponse() bool { + return e.Type == Service && e.ResponseType == ResponseTypeChunked +} + +// IsStreamResponse returns true if an export has a chunked response +func (e *Export) IsStreamResponse() bool { + return e.Type == Service && e.ResponseType == ResponseTypeStream +} + +// Validate appends validation issues to the passed in results list +func (e *Export) Validate(vr *ValidationResults) { + if !e.IsService() && !e.IsStream() { + vr.AddError("invalid export type: %q", e.Type) + } + if e.IsService() && !e.IsSingleResponse() && !e.IsChunkedResponse() && !e.IsStreamResponse() { + vr.AddError("invalid response type for service: %q", e.ResponseType) + } + if e.IsStream() && e.ResponseType != "" { + vr.AddError("invalid response type for stream: %q", e.ResponseType) + } + if e.Latency != nil { + if !e.IsService() { + vr.AddError("latency tracking only permitted for services") + } + e.Latency.Validate(vr) + } + e.Subject.Validate(vr) +} + +// Revoke enters a revocation by publickey using time.Now(). +func (e *Export) Revoke(pubKey string) { + e.RevokeAt(pubKey, time.Now()) +} + +// RevokeAt enters a revocation by publickey and timestamp into this export +// If there is already a revocation for this public key that is newer, it is kept. +func (e *Export) RevokeAt(pubKey string, timestamp time.Time) { + if e.Revocations == nil { + e.Revocations = RevocationList{} + } + + e.Revocations.Revoke(pubKey, timestamp) +} + +// ClearRevocation removes any revocation for the public key +func (e *Export) ClearRevocation(pubKey string) { + e.Revocations.ClearRevocation(pubKey) +} + +// IsRevokedAt checks if the public key is in the revoked list with a timestamp later than +// the one passed in. Generally this method is called with time.Now() but other time's can +// be used for testing. +func (e *Export) IsRevokedAt(pubKey string, timestamp time.Time) bool { + return e.Revocations.IsRevoked(pubKey, timestamp) +} + +// IsRevoked checks if the public key is in the revoked list with time.Now() +func (e *Export) IsRevoked(pubKey string) bool { + return e.Revocations.IsRevoked(pubKey, time.Now()) +} + +// Exports is a slice of exports +type Exports []*Export + +// Add appends exports to the list +func (e *Exports) Add(i ...*Export) { + *e = append(*e, i...) +} + +func isContainedIn(kind ExportType, subjects []Subject, vr *ValidationResults) { + m := make(map[string]string) + for i, ns := range subjects { + for j, s := range subjects { + if i == j { + continue + } + if ns.IsContainedIn(s) { + str := string(s) + _, ok := m[str] + if !ok { + m[str] = string(ns) + } + } + } + } + + if len(m) != 0 { + for k, v := range m { + var vi ValidationIssue + vi.Blocking = true + vi.Description = fmt.Sprintf("%s export subject %q already exports %q", kind, k, v) + vr.Add(&vi) + } + } +} + +// Validate calls validate on all of the exports +func (e *Exports) Validate(vr *ValidationResults) error { + var serviceSubjects []Subject + var streamSubjects []Subject + + for _, v := range *e { + if v.IsService() { + serviceSubjects = append(serviceSubjects, v.Subject) + } else { + streamSubjects = append(streamSubjects, v.Subject) + } + v.Validate(vr) + } + + isContainedIn(Service, serviceSubjects, vr) + isContainedIn(Stream, streamSubjects, vr) + + return nil +} + +// HasExportContainingSubject checks if the export list has an export with the provided subject +func (e *Exports) HasExportContainingSubject(subject Subject) bool { + for _, s := range *e { + if subject.IsContainedIn(s.Subject) { + return true + } + } + return false +} + +func (e Exports) Len() int { + return len(e) +} + +func (e Exports) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +func (e Exports) Less(i, j int) bool { + return e[i].Subject < e[j].Subject +} diff --git a/exports_test.go b/exports_test.go new file mode 100644 index 0000000..b674c90 --- /dev/null +++ b/exports_test.go @@ -0,0 +1,290 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "sort" + "testing" + "time" +) + +func TestSimpleExportValidation(t *testing.T) { + e := &Export{Subject: "foo", Type: Stream} + + vr := CreateValidationResults() + e.Validate(vr) + + if !vr.IsEmpty() { + t.Errorf("simple export should validate cleanly") + } + + e.Type = Service + vr = CreateValidationResults() + e.Validate(vr) + + if !vr.IsEmpty() { + t.Errorf("simple export should validate cleanly") + } +} + +func TestResponseTypeValidation(t *testing.T) { + e := &Export{Subject: "foo", Type: Stream, ResponseType: ResponseTypeSingleton} + + vr := CreateValidationResults() + e.Validate(vr) + + if vr.IsEmpty() { + t.Errorf("response type on stream should have an validation issue") + } + if e.IsSingleResponse() { + t.Errorf("response type should always fail for stream") + } + + e.Type = Service + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if !e.IsSingleResponse() || e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be single") + } + + e.ResponseType = ResponseTypeChunked + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if e.IsSingleResponse() || !e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be chunk") + } + + e.ResponseType = ResponseTypeStream + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if e.IsSingleResponse() || e.IsChunkedResponse() || !e.IsStreamResponse() { + t.Errorf("response type should be stream") + } + + e.ResponseType = "" + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if !e.IsSingleResponse() || e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be single") + } + + e.ResponseType = "bad" + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("response type should match available options") + } + if e.IsSingleResponse() || e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be bad") + } +} + +func TestInvalidExportType(t *testing.T) { + i := &Export{Subject: "foo", Type: Unknown} + + vr := CreateValidationResults() + i.Validate(vr) + + if vr.IsEmpty() { + t.Errorf("export with bad type should not validate cleanly") + } + + if !vr.IsBlocking(true) { + t.Errorf("invalid type is blocking") + } +} + +func TestOverlappingExports(t *testing.T) { + i := &Export{Subject: "bar.foo", Type: Stream} + i2 := &Export{Subject: "bar.*", Type: Stream} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 1 { + t.Errorf("export has overlapping subjects") + } +} + +func TestDifferentExportTypes_OverlapOK(t *testing.T) { + i := &Export{Subject: "bar.foo", Type: Service} + i2 := &Export{Subject: "bar.*", Type: Stream} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 0 { + t.Errorf("should allow overlaps on different export kind") + } +} + +func TestDifferentExportTypes_SameSubjectOK(t *testing.T) { + i := &Export{Subject: "bar", Type: Service} + i2 := &Export{Subject: "bar", Type: Stream} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 0 { + t.Errorf("should allow overlaps on different export kind") + } +} + +func TestSameExportType_SameSubject(t *testing.T) { + i := &Export{Subject: "bar", Type: Service} + i2 := &Export{Subject: "bar", Type: Service} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 1 { + t.Errorf("should not allow same subject on same export kind") + } +} + +func TestExportRevocation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + account := NewAccountClaims(apk) + e := &Export{Subject: "foo", Type: Stream} + + account.Exports.Add(e) + + pubKey := "bar" + now := time.Now() + + // test that clear is safe before we add any + e.ClearRevocation(pubKey) + + if e.IsRevokedAt(pubKey, now) { + t.Errorf("no revocation was added so is revoked should be false") + } + + e.RevokeAt(pubKey, now.Add(time.Second*100)) + + if !e.IsRevokedAt(pubKey, now) { + t.Errorf("revocation should hold when timestamp is in the future") + } + + if e.IsRevokedAt(pubKey, now.Add(time.Second*150)) { + t.Errorf("revocation should time out") + } + + e.RevokeAt(pubKey, now.Add(time.Second*50)) // shouldn't change the revocation, you can't move it in + + if !e.IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should hold, 100 > 50") + } + + encoded, _ := account.Encode(akp) + decoded, _ := DecodeAccountClaims(encoded) + + if !decoded.Exports[0].IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should last across encoding") + } + + e.ClearRevocation(pubKey) + + if e.IsRevokedAt(pubKey, now) { + t.Errorf("revocations should be cleared") + } + + e.RevokeAt(pubKey, now.Add(time.Second*1000)) + + if !e.IsRevoked(pubKey) { + t.Errorf("revocation be true we revoked in the future") + } +} + +func TestExportTrackLatency(t *testing.T) { + e := &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 100, Results: "results"} + vr := CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("Expected to validate with simple tracking") + } + + e = &Export{Subject: "foo", Type: Stream} + e.Latency = &ServiceLatency{Sampling: 100, Results: "results"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("adding latency tracking to a stream should have an validation issue") + } + + e = &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 0, Results: "results"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("Sampling <1 should have a validation issue") + } + + e = &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 122, Results: "results"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("Sampling >100 should have a validation issue") + } + + e = &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 22, Results: "results.*"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("Results subject needs to be valid publish subject") + } +} + +func TestExport_Sorting(t *testing.T) { + var exports Exports + exports.Add(&Export{Subject: "x", Type: Service}) + exports.Add(&Export{Subject: "z", Type: Service}) + exports.Add(&Export{Subject: "y", Type: Service}) + if exports[0].Subject != "x" { + t.Fatal("added export not in expected order") + } + sort.Sort(exports) + if exports[0].Subject != "x" && exports[1].Subject != "y" && exports[2].Subject != "z" { + t.Fatal("exports not sorted") + } +} diff --git a/genericclaims_test.go b/genericclaims_test.go new file mode 100644 index 0000000..fed632a --- /dev/null +++ b/genericclaims_test.go @@ -0,0 +1,60 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "testing" + "time" +) + +func TestNewGenericClaims(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + uc := NewGenericClaims(apk) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + uc.Name = "alberto" + uc.Audience = "everyone" + uc.NotBefore = time.Now().UTC().Unix() + uc.Tags.Add("one") + uc.Tags.Add("one") + uc.Tags.Add("one") + uc.Tags.Add("TWO") // should become lower case + uc.Tags.Add("three") + + uJwt := encode(uc, akp, t) + + uc2, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + AssertEquals(uc.Name, uc2.Name, t) + AssertEquals(uc.Audience, uc2.Audience, t) + AssertEquals(uc.Expires, uc2.Expires, t) + AssertEquals(uc.NotBefore, uc2.NotBefore, t) + AssertEquals(uc.Subject, uc2.Subject, t) + + AssertEquals(3, len(uc2.Tags), t) + AssertEquals(true, uc2.Tags.Contains("two"), t) + AssertEquals("one", uc2.Tags[0], t) + AssertEquals("two", uc2.Tags[1], t) + AssertEquals("three", uc2.Tags[2], t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} diff --git a/genericlaims.go b/genericlaims.go new file mode 100644 index 0000000..94cd86e --- /dev/null +++ b/genericlaims.go @@ -0,0 +1,73 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import "github.com/nats-io/nkeys" + +// GenericClaims can be used to read a JWT as a map for any non-generic fields +type GenericClaims struct { + ClaimsData + Data map[string]interface{} `json:"nats,omitempty"` +} + +// NewGenericClaims creates a map-based Claims +func NewGenericClaims(subject string) *GenericClaims { + if subject == "" { + return nil + } + c := GenericClaims{} + c.Subject = subject + c.Data = make(map[string]interface{}) + return &c +} + +// DecodeGeneric takes a JWT string and decodes it into a ClaimsData and map +func DecodeGeneric(token string) (*GenericClaims, error) { + v := GenericClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +// Claims returns the standard part of the generic claim +func (gc *GenericClaims) Claims() *ClaimsData { + return &gc.ClaimsData +} + +// Payload returns the custom part of the claims data +func (gc *GenericClaims) Payload() interface{} { + return &gc.Data +} + +// Encode takes a generic claims and creates a JWT string +func (gc *GenericClaims) Encode(pair nkeys.KeyPair) (string, error) { + return gc.ClaimsData.Encode(pair, gc) +} + +// Validate checks the generic part of the claims data +func (gc *GenericClaims) Validate(vr *ValidationResults) { + gc.ClaimsData.Validate(vr) +} + +func (gc *GenericClaims) String() string { + return gc.ClaimsData.String(gc) +} + +// ExpectedPrefixes returns the types allowed to encode a generic JWT, which is nil for all +func (gc *GenericClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..778d12c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/nats-io/jwt + +require github.com/nats-io/nkeys v0.1.3 + +go 1.13 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9baf67f --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/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= diff --git a/header.go b/header.go new file mode 100644 index 0000000..27c6581 --- /dev/null +++ b/header.go @@ -0,0 +1,71 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + // Version is semantic version. + Version = "0.3.2" + + // TokenTypeJwt is the JWT token type supported JWT tokens + // encoded and decoded by this library + TokenTypeJwt = "jwt" + + // AlgorithmNkey is the algorithm supported by JWT tokens + // encoded and decoded by this library + AlgorithmNkey = "ed25519" +) + +// Header is a JWT Jose Header +type Header struct { + Type string `json:"typ"` + Algorithm string `json:"alg"` +} + +// Parses a header JWT token +func parseHeaders(s string) (*Header, error) { + h, err := decodeString(s) + if err != nil { + return nil, err + } + header := Header{} + if err := json.Unmarshal(h, &header); err != nil { + return nil, err + } + + if err := header.Valid(); err != nil { + return nil, err + } + return &header, nil +} + +// Valid validates the Header. It returns nil if the Header is +// a JWT header, and the algorithm used is the NKEY algorithm. +func (h *Header) Valid() error { + if TokenTypeJwt != strings.ToLower(h.Type) { + return fmt.Errorf("not supported type %q", h.Type) + } + + if AlgorithmNkey != strings.ToLower(h.Algorithm) { + return fmt.Errorf("unexpected %q algorithm", h.Algorithm) + } + return nil +} diff --git a/imports.go b/imports.go new file mode 100644 index 0000000..8cd9747 --- /dev/null +++ b/imports.go @@ -0,0 +1,151 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "io/ioutil" + "net/http" + "net/url" + "time" +) + +// Import describes a mapping from another account into this one +type Import struct { + Name string `json:"name,omitempty"` + // Subject field in an import is always from the perspective of the + // initial publisher - in the case of a stream it is the account owning + // the stream (the exporter), and in the case of a service it is the + // account making the request (the importer). + Subject Subject `json:"subject,omitempty"` + Account string `json:"account,omitempty"` + Token string `json:"token,omitempty"` + // To field in an import is always from the perspective of the subscriber + // in the case of a stream it is the client of the stream (the importer), + // from the perspective of a service, it is the subscription waiting for + // requests (the exporter). If the field is empty, it will default to the + // value in the Subject field. + To Subject `json:"to,omitempty"` + Type ExportType `json:"type,omitempty"` +} + +// IsService returns true if the import is of type service +func (i *Import) IsService() bool { + return i.Type == Service +} + +// IsStream returns true if the import is of type stream +func (i *Import) IsStream() bool { + return i.Type == Stream +} + +// Validate checks if an import is valid for the wrapping account +func (i *Import) Validate(actPubKey string, vr *ValidationResults) { + if !i.IsService() && !i.IsStream() { + vr.AddError("invalid import type: %q", i.Type) + } + + if i.Account == "" { + vr.AddWarning("account to import from is not specified") + } + + i.Subject.Validate(vr) + + if i.IsService() && i.Subject.HasWildCards() { + vr.AddError("services cannot have wildcard subject: %q", i.Subject) + } + if i.IsStream() && i.To.HasWildCards() { + vr.AddError("streams cannot have wildcard to subject: %q", i.Subject) + } + + var act *ActivationClaims + + if i.Token != "" { + // Check to see if its an embedded JWT or a URL. + if url, err := url.Parse(i.Token); err == nil && url.Scheme != "" { + c := &http.Client{Timeout: 5 * time.Second} + resp, err := c.Get(url.String()) + if err != nil { + vr.AddWarning("import %s contains an unreachable token URL %q", i.Subject, i.Token) + } + + if resp != nil { + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + vr.AddWarning("import %s contains an unreadable token URL %q", i.Subject, i.Token) + } else { + act, err = DecodeActivationClaims(string(body)) + if err != nil { + vr.AddWarning("import %s contains a url %q with an invalid activation token", i.Subject, i.Token) + } + } + } + } else { + var err error + act, err = DecodeActivationClaims(i.Token) + if err != nil { + vr.AddWarning("import %q contains an invalid activation token", i.Subject) + } + } + } + + if act != nil { + if act.Issuer != i.Account { + vr.AddWarning("activation token doesn't match account for import %q", i.Subject) + } + + if act.ClaimsData.Subject != actPubKey { + vr.AddWarning("activation token doesn't match account it is being included in, %q", i.Subject) + } + } else { + vr.AddWarning("no activation provided for import %s", i.Subject) + } + +} + +// Imports is a list of import structs +type Imports []*Import + +// Validate checks if an import is valid for the wrapping account +func (i *Imports) Validate(acctPubKey string, vr *ValidationResults) { + toSet := make(map[Subject]bool, len(*i)) + for _, v := range *i { + if v.Type == Service { + if _, ok := toSet[v.To]; ok { + vr.AddError("Duplicate To subjects for %q", v.To) + } + toSet[v.To] = true + } + v.Validate(acctPubKey, vr) + } +} + +// Add is a simple way to add imports +func (i *Imports) Add(a ...*Import) { + *i = append(*i, a...) +} + +func (i Imports) Len() int { + return len(i) +} + +func (i Imports) Swap(j, k int) { + i[j], i[k] = i[k], i[j] +} + +func (i Imports) Less(j, k int) bool { + return i[j].Subject < i[k].Subject +} diff --git a/imports_test.go b/imports_test.go new file mode 100644 index 0000000..4405d7b --- /dev/null +++ b/imports_test.go @@ -0,0 +1,410 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sort" + "testing" + "time" +) + +func TestImportValidation(t *testing.T) { + ak := createAccountNKey(t) + ak2 := createAccountNKey(t) + akp := publicKey(ak, t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "test", Account: akp2, To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + i.Type = Service + vr = CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + + activation.ImportSubject = "test" + activation.ImportType = Stream + actJWT := encode(activation, ak2, t) + + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with token should be valid") + } +} + +func TestInvalidImportType(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, To: "bar", Type: Unknown} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if !vr.IsBlocking(true) { + t.Errorf("invalid type is blocking") + } +} + +func TestInvalidImportToken(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, Token: "bad token", To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports with a bad token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("invalid type shouldnt be blocking") + } +} + +func TestInvalidImportURL(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, Token: "foo://bad token url", To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports with a bad token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("invalid type shouldnt be blocking") + } +} + +func TestInvalidImportTokenValuesValidation(t *testing.T) { + ak := createAccountNKey(t) + ak2 := createAccountNKey(t) + akp := publicKey(ak, t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "test", Account: akp2, To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + i.Type = Service + vr = CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + + activation.ImportSubject = "test" + activation.ImportType = Stream + actJWT := encode(activation, ak2, t) + + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with token should be valid") + } + + actJWT = encode(activation, ak, t) // wrong issuer + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with wrong issuer") + } + + activation.Subject = akp2 // wrong subject + actJWT = encode(activation, ak2, t) // right issuer + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with wrong issuer") + } +} +func TestMissingAccountInImport(t *testing.T) { + i := &Import{Subject: "foo", To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if len(vr.Issues) != 2 { + t.Errorf("imports without token or url should warn the caller, as should missing account") + } + + if vr.IsBlocking(true) { + t.Errorf("Missing Account is not blocking, must import failures are warnings") + } +} + +func TestServiceImportWithWildcard(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo.*", Account: akp, To: "bar", Type: Service} + + vr := CreateValidationResults() + i.Validate("", vr) + + if len(vr.Issues) != 2 { + t.Errorf("imports without token or url should warn the caller, as should wildcard service") + } + + if !vr.IsBlocking(true) { + t.Errorf("expected service import with a wildcard subject to be a blocking error") + } +} + +func TestStreamImportWithWildcardPrefix(t *testing.T) { + i := &Import{Subject: "foo", To: "bar.>", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if len(vr.Issues) != 3 { + t.Errorf("should have registered 3 issues with this import, got %d", len(vr.Issues)) + } + + if !vr.IsBlocking(true) { + t.Fatalf("expected stream import prefix with a wildcard to produce a blocking error") + } +} + +func TestImportsValidation(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, To: "bar", Type: Stream} + i2 := &Import{Subject: "foo.*", Account: akp, To: "bar", Type: Service} + + imports := &Imports{} + imports.Add(i, i2) + + vr := CreateValidationResults() + imports.Validate("", vr) + + if len(vr.Issues) != 3 { + t.Errorf("imports without token or url should warn the caller x2, wildcard service as well") + } + + if !vr.IsBlocking(true) { + t.Errorf("expected service import with a wildcard subject to be a blocking error") + } +} + +func TestTokenURLImportValidation(t *testing.T) { + ak := createAccountNKey(t) + ak2 := createAccountNKey(t) + akp := publicKey(ak, t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "test", Account: akp2, To: "bar", Type: Stream} + + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.ImportSubject = "test" + activation.ImportType = Stream + + actJWT := encode(activation, ak2, t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(actJWT)) + })) + defer ts.Close() + + i.Token = ts.URL + vr := CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + fmt.Printf("vr is %+v\n", vr) + t.Errorf("imports with token url should be valid") + } + + i.Token = "http://Bad URL" + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with bad token url should be valid") + } + + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("bad jwt")) + })) + defer ts.Close() + + i.Token = ts.URL + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with token url pointing to bad JWT") + } + + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer ts.Close() + + i.Token = ts.URL + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with token url pointing to bad url") + } +} + +func TestImportSubjectValidation(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.ImportSubject = "one.*" + activation.ImportType = Stream + + ak2 := createAccountNKey(t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "one.two", Account: akp2, To: "bar", Type: Stream} + + actJWT := encode(activation, ak2, t) + i.Token = actJWT + vr := CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Log(vr.Issues[0].Description) + t.Errorf("imports with valid contains subject should be valid") + } + + activation.ImportSubject = "two" + activation.ImportType = Stream + actJWT = encode(activation, ak2, t) + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with non-contains subject should be not valid") + } + + activation.ImportSubject = ">" + activation.ImportType = Stream + actJWT = encode(activation, ak2, t) + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with valid contains subject should be valid") + } +} + +func TestImportServiceDoubleToSubjectsValidation(t *testing.T) { + akp := createAccountNKey(t) + akp2 := createAccountNKey(t) + apk := publicKey(akp, t) + apk2 := publicKey(akp2, t) + + account := NewAccountClaims(apk) + + i := &Import{Subject: "one.two", Account: apk2, To: "foo.bar", Type: Service} + account.Imports.Add(i) + + vr := CreateValidationResults() + account.Validate(vr) + + if vr.IsBlocking(true) { + t.Fatalf("Expected no blocking validation errors") + } + + i2 := &Import{Subject: "two.three", Account: apk2, To: "foo.bar", Type: Service} + account.Imports.Add(i2) + + vr = CreateValidationResults() + account.Validate(vr) + + if !vr.IsBlocking(true) { + t.Fatalf("Expected multiple import 'to' subjects to produce an error") + } +} + +func TestImport_Sorting(t *testing.T) { + var imports Imports + pk := publicKey(createAccountNKey(t), t) + imports.Add(&Import{Subject: "x", Type: Service, Account: pk}) + imports.Add(&Import{Subject: "z", Type: Service, Account: pk}) + imports.Add(&Import{Subject: "y", Type: Service, Account: pk}) + if imports[0].Subject != "x" { + t.Fatal("added import not in expected order") + } + sort.Sort(imports) + if imports[0].Subject != "x" && imports[1].Subject != "y" && imports[2].Subject != "z" { + t.Fatal("imports not sorted") + } +} diff --git a/operator_claims.go b/operator_claims.go new file mode 100644 index 0000000..6a99597 --- /dev/null +++ b/operator_claims.go @@ -0,0 +1,204 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nats-io/nkeys" +) + +// Operator specific claims +type Operator struct { + // Slice of real identies (like websites) that can be used to identify the operator. + Identities []Identity `json:"identity,omitempty"` + // Slice of other operator NKeys that can be used to sign on behalf of the main + // operator identity. + SigningKeys StringList `json:"signing_keys,omitempty"` + // AccountServerURL is a partial URL like "https://host.domain.org:/jwt/v1" + // tools will use the prefix and build queries by appending /accounts/ + // or /operator to the path provided. Note this assumes that the account server + // can handle requests in a nats-account-server compatible way. See + // https://github.com/nats-io/nats-account-server. + AccountServerURL string `json:"account_server_url,omitempty"` + // A list of NATS urls (tls://host:port) where tools can connect to the server + // using proper credentials. + OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` +} + +// Validate checks the validity of the operators contents +func (o *Operator) Validate(vr *ValidationResults) { + if err := o.validateAccountServerURL(); err != nil { + vr.AddError(err.Error()) + } + + for _, v := range o.validateOperatorServiceURLs() { + if v != nil { + vr.AddError(v.Error()) + } + } + + for _, i := range o.Identities { + i.Validate(vr) + } + + for _, k := range o.SigningKeys { + if !nkeys.IsValidPublicOperatorKey(k) { + vr.AddError("%s is not an operator public key", k) + } + } +} + +func (o *Operator) validateAccountServerURL() error { + if o.AccountServerURL != "" { + // We don't care what kind of URL it is so long as it parses + // and has a protocol. The account server may impose additional + // constraints on the type of URLs that it is able to notify to + u, err := url.Parse(o.AccountServerURL) + if err != nil { + return fmt.Errorf("error parsing account server url: %v", err) + } + if u.Scheme == "" { + return fmt.Errorf("account server url %q requires a protocol", o.AccountServerURL) + } + } + return nil +} + +// ValidateOperatorServiceURL returns an error if the URL is not a valid NATS or TLS url. +func ValidateOperatorServiceURL(v string) error { + // should be possible for the service url to not be expressed + if v == "" { + return nil + } + u, err := url.Parse(v) + if err != nil { + return fmt.Errorf("error parsing operator service url %q: %v", v, err) + } + + if u.User != nil { + return fmt.Errorf("operator service url %q - credentials are not supported", v) + } + + if u.Path != "" { + return fmt.Errorf("operator service url %q - paths are not supported", v) + } + + lcs := strings.ToLower(u.Scheme) + switch lcs { + case "nats": + return nil + case "tls": + return nil + default: + return fmt.Errorf("operator service url %q - protocol not supported (only 'nats' or 'tls' only)", v) + } +} + +func (o *Operator) validateOperatorServiceURLs() []error { + var errors []error + for _, v := range o.OperatorServiceURLs { + if v != "" { + if err := ValidateOperatorServiceURL(v); err != nil { + errors = append(errors, err) + } + } + } + return errors +} + +// OperatorClaims define the data for an operator JWT +type OperatorClaims struct { + ClaimsData + Operator `json:"nats,omitempty"` +} + +// NewOperatorClaims creates a new operator claim with the specified subject, which should be an operator public key +func NewOperatorClaims(subject string) *OperatorClaims { + if subject == "" { + return nil + } + c := &OperatorClaims{} + c.Subject = subject + return c +} + +// DidSign checks the claims against the operator's public key and its signing keys +func (oc *OperatorClaims) DidSign(op Claims) bool { + if op == nil { + return false + } + issuer := op.Claims().Issuer + if issuer == oc.Subject { + return true + } + return oc.SigningKeys.Contains(issuer) +} + +// Deprecated: AddSigningKey, use claim.SigningKeys.Add() +func (oc *OperatorClaims) AddSigningKey(pk string) { + oc.SigningKeys.Add(pk) +} + +// Encode the claims into a JWT string +func (oc *OperatorClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicOperatorKey(oc.Subject) { + return "", errors.New("expected subject to be an operator public key") + } + err := oc.validateAccountServerURL() + if err != nil { + return "", err + } + oc.ClaimsData.Type = OperatorClaim + return oc.ClaimsData.Encode(pair, oc) +} + +// DecodeOperatorClaims tries to create an operator claims from a JWt string +func DecodeOperatorClaims(token string) (*OperatorClaims, error) { + v := OperatorClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (oc *OperatorClaims) String() string { + return oc.ClaimsData.String(oc) +} + +// Payload returns the operator specific data for an operator JWT +func (oc *OperatorClaims) Payload() interface{} { + return &oc.Operator +} + +// Validate the contents of the claims +func (oc *OperatorClaims) Validate(vr *ValidationResults) { + oc.ClaimsData.Validate(vr) + oc.Operator.Validate(vr) +} + +// ExpectedPrefixes defines the nkey types that can sign operator claims, operator +func (oc *OperatorClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteOperator} +} + +// Claims returns the generic claims data +func (oc *OperatorClaims) Claims() *ClaimsData { + return &oc.ClaimsData +} diff --git a/operator_claims_test.go b/operator_claims_test.go new file mode 100644 index 0000000..73cae23 --- /dev/null +++ b/operator_claims_test.go @@ -0,0 +1,354 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewOperatorClaims(t *testing.T) { + ckp := createOperatorNKey(t) + + uc := NewOperatorClaims(publicKey(ckp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeOperatorClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestOperatorSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewOperatorClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode server with with %q subject", i.name) + t.Fail() + } + } +} + +func TestInvalidOperatorClaimIssuer(t *testing.T) { + akp := createOperatorNKey(t) + ac := NewOperatorClaims(publicKey(akp, t)) + ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + aJwt := encode(ac, akp, t) + + temp, err := DecodeGeneric(aJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeOperatorClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode account signed by %q", i.name) + t.Fail() + } + } +} + +func TestNewNilOperatorClaims(t *testing.T) { + v := NewOperatorClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestOperatorType(t *testing.T) { + c := NewOperatorClaims(publicKey(createOperatorNKey(t), t)) + s := encode(c, createOperatorNKey(t), t) + u, err := DecodeOperatorClaims(s) + if err != nil { + t.Fatalf("failed to decode operator claim: %v", err) + } + + if OperatorClaim != u.Type { + t.Fatalf("type is unexpected %q (wanted operator)", u.Type) + } + +} + +func TestSigningKeyValidation(t *testing.T) { + ckp := createOperatorNKey(t) + ckp2 := createOperatorNKey(t) + + uc := NewOperatorClaims(publicKey(ckp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.AddSigningKey(publicKey(ckp2, t)) + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeOperatorClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(len(uc2.SigningKeys), 1, t) + AssertEquals(uc2.SigningKeys[0] == publicKey(ckp2, t), true, t) + + vr := &ValidationResults{} + uc.Validate(vr) + + if len(vr.Issues) != 0 { + t.Fatal("valid operator key should have no validation issues") + } + + uc.AddSigningKey("") // add an invalid one + + vr = &ValidationResults{} + uc.Validate(vr) + if len(vr.Issues) != 0 { + t.Fatal("should not be able to add empty values") + } +} + +func TestSignedBy(t *testing.T) { + ckp := createOperatorNKey(t) + ckp2 := createOperatorNKey(t) + + uc := NewOperatorClaims(publicKey(ckp, t)) + uc2 := NewOperatorClaims(publicKey(ckp2, t)) + + akp := createAccountNKey(t) + ac := NewAccountClaims(publicKey(akp, t)) + enc, err := ac.Encode(ckp) // sign with the operator key + if err != nil { + t.Fatal("failed to encode", err) + } + ac, err = DecodeAccountClaims(enc) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.DidSign(ac), true, t) + AssertEquals(uc2.DidSign(ac), false, t) + + enc, err = ac.Encode(ckp2) // sign with the other operator key + if err != nil { + t.Fatal("failed to encode", err) + } + ac, err = DecodeAccountClaims(enc) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.DidSign(ac), false, t) // no signing key + AssertEquals(uc2.DidSign(ac), true, t) // actual key + uc.AddSigningKey(publicKey(ckp2, t)) + AssertEquals(uc.DidSign(ac), true, t) // signing key + + clusterKey := createClusterNKey(t) + clusterClaims := NewClusterClaims(publicKey(clusterKey, t)) + enc, err = clusterClaims.Encode(ckp2) // sign with the operator key + if err != nil { + t.Fatal("failed to encode", err) + } + clusterClaims, err = DecodeClusterClaims(enc) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.DidSign(clusterClaims), true, t) // signing key + AssertEquals(uc2.DidSign(clusterClaims), true, t) // actual key +} + +func testAccountWithAccountServerURL(t *testing.T, u string) error { + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + oc.AccountServerURL = u + + s, err := oc.Encode(kp) + if err != nil { + return err + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + AssertEquals(oc.AccountServerURL, u, t) + vr := ValidationResults{} + oc.Validate(&vr) + if !vr.IsEmpty() { + errs := vr.Errors() + return errs[0] + } + return nil +} + +func Test_AccountServerURL(t *testing.T) { + var asuTests = []struct { + u string + shouldFail bool + }{ + {"", false}, + {"HTTP://foo.bar.com", false}, + {"http://foo.bar.com/foo/bar", false}, + {"http://user:pass@foo.bar.com/foo/bar", false}, + {"https://foo.bar.com", false}, + {"nats://foo.bar.com", false}, + {"/hello", true}, + } + + for i, tt := range asuTests { + err := testAccountWithAccountServerURL(t, tt.u) + if err != nil && tt.shouldFail == false { + t.Fatalf("expected not to fail: %v", err) + } else if err == nil && tt.shouldFail { + t.Fatalf("test %s expected to fail but didn't", asuTests[i].u) + } + } +} + +func testOperatorWithOperatorServiceURL(t *testing.T, u string) error { + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + oc.OperatorServiceURLs.Add(u) + + s, err := oc.Encode(kp) + if err != nil { + return err + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + if u != "" { + AssertEquals(oc.OperatorServiceURLs[0], u, t) + } + vr := ValidationResults{} + oc.Validate(&vr) + if !vr.IsEmpty() { + errs := vr.Errors() + return errs[0] + } + return nil +} + +func Test_OperatorServiceURL(t *testing.T) { + var asuTests = []struct { + u string + shouldFail bool + }{ + {"", false}, + {"HTTP://foo.bar.com", true}, + {"http://foo.bar.com/foo/bar", true}, + {"nats://user:pass@foo.bar.com", true}, + {"NATS://user:pass@foo.bar.com", true}, + {"NATS://user@foo.bar.com", true}, + {"nats://foo.bar.com/path", true}, + {"tls://foo.bar.com/path", true}, + {"/hello", true}, + {"NATS://foo.bar.com", false}, + {"TLS://foo.bar.com", false}, + {"nats://foo.bar.com", false}, + {"tls://foo.bar.com", false}, + } + + for i, tt := range asuTests { + err := testOperatorWithOperatorServiceURL(t, tt.u) + if err != nil && tt.shouldFail == false { + t.Fatalf("expected not to fail: %v", err) + } else if err == nil && tt.shouldFail { + t.Fatalf("test %s expected to fail but didn't", asuTests[i].u) + } + } + + // now test all of them in a single jwt + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + + encoded := 0 + shouldFail := 0 + for _, v := range asuTests { + oc.OperatorServiceURLs.Add(v.u) + // list won't encode empty strings + if v.u != "" { + encoded++ + } + if v.shouldFail { + shouldFail++ + } + } + + s, err := oc.Encode(kp) + if err != nil { + t.Fatal(err) + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + + AssertEquals(len(oc.OperatorServiceURLs), encoded, t) + + vr := ValidationResults{} + oc.Validate(&vr) + if vr.IsEmpty() { + t.Fatal("should have had errors") + } + + errs := vr.Errors() + AssertEquals(len(errs), shouldFail, t) +} diff --git a/revocation_list.go b/revocation_list.go new file mode 100644 index 0000000..fb1d836 --- /dev/null +++ b/revocation_list.go @@ -0,0 +1,32 @@ +package jwt + +import ( + "time" +) + +// RevocationList is used to store a mapping of public keys to unix timestamps +type RevocationList map[string]int64 + +// Revoke enters a revocation by publickey and timestamp into this export +// If there is already a revocation for this public key that is newer, it is kept. +func (r RevocationList) Revoke(pubKey string, timestamp time.Time) { + newTS := timestamp.Unix() + if ts, ok := r[pubKey]; ok && ts > newTS { + return + } + + r[pubKey] = newTS +} + +// ClearRevocation removes any revocation for the public key +func (r RevocationList) ClearRevocation(pubKey string) { + delete(r, pubKey) +} + +// IsRevoked checks if the public key is in the revoked list with a timestamp later than +// the one passed in. Generally this method is called with time.Now() but other time's can +// be used for testing. +func (r RevocationList) IsRevoked(pubKey string, timestamp time.Time) bool { + ts, ok := r[pubKey] + return ok && ts > timestamp.Unix() +} diff --git a/server_claims.go b/server_claims.go new file mode 100644 index 0000000..c18f167 --- /dev/null +++ b/server_claims.go @@ -0,0 +1,94 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + + "github.com/nats-io/nkeys" +) + +// Server defines the custom part of a server jwt +type Server struct { + Permissions + Cluster string `json:"cluster,omitempty"` +} + +// Validate checks the cluster and permissions for a server JWT +func (s *Server) Validate(vr *ValidationResults) { + if s.Cluster == "" { + vr.AddError("servers can't contain an empty cluster") + } +} + +// ServerClaims defines the data in a server JWT +type ServerClaims struct { + ClaimsData + Server `json:"nats,omitempty"` +} + +// NewServerClaims creates a new server JWT with the specified subject/public key +func NewServerClaims(subject string) *ServerClaims { + if subject == "" { + return nil + } + c := &ServerClaims{} + c.Subject = subject + return c +} + +// Encode tries to turn the server claims into a JWT string +func (s *ServerClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicServerKey(s.Subject) { + return "", errors.New("expected subject to be a server public key") + } + s.ClaimsData.Type = ServerClaim + return s.ClaimsData.Encode(pair, s) +} + +// DecodeServerClaims tries to parse server claims from a JWT string +func DecodeServerClaims(token string) (*ServerClaims, error) { + v := ServerClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (s *ServerClaims) String() string { + return s.ClaimsData.String(s) +} + +// Payload returns the server specific data +func (s *ServerClaims) Payload() interface{} { + return &s.Server +} + +// Validate checks the generic and server data in the server claims +func (s *ServerClaims) Validate(vr *ValidationResults) { + s.ClaimsData.Validate(vr) + s.Server.Validate(vr) +} + +// ExpectedPrefixes defines the types that can encode a server JWT, operator or cluster +func (s *ServerClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} +} + +// Claims returns the generic data +func (s *ServerClaims) Claims() *ClaimsData { + return &s.ClaimsData +} diff --git a/server_claims_test.go b/server_claims_test.go new file mode 100644 index 0000000..70fc3d5 --- /dev/null +++ b/server_claims_test.go @@ -0,0 +1,132 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewServerClaims(t *testing.T) { + ckp := createClusterNKey(t) + skp := createServerNKey(t) + + uc := NewServerClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeServerClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestServerClaimsIssuer(t *testing.T) { + ckp := createClusterNKey(t) + skp := createServerNKey(t) + + uc := NewServerClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + temp, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), true}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeServerClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode server signed by %q", i.name) + t.Fail() + } + } +} + +func TestServerSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), true}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewServerClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode server with with %q subject", i.name) + t.Fail() + } + } +} + +func TestNewNilServerClaims(t *testing.T) { + v := NewServerClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestServerType(t *testing.T) { + c := NewServerClaims(publicKey(createServerNKey(t), t)) + s := encode(c, createClusterNKey(t), t) + u, err := DecodeServerClaims(s) + if err != nil { + t.Fatalf("failed to decode server claim: %v", err) + } + + if ServerClaim != u.Type { + t.Fatalf("type is unexpected %q (wanted server)", u.Type) + } + +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..a1f09fd --- /dev/null +++ b/types.go @@ -0,0 +1,334 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "encoding/json" + "fmt" + "net" + "strings" + "time" +) + +// ExportType defines the type of import/export. +type ExportType int + +const ( + // Unknown is used if we don't know the type + Unknown ExportType = iota + // Stream defines the type field value for a stream "stream" + Stream + // Service defines the type field value for a service "service" + Service +) + +func (t ExportType) String() string { + switch t { + case Stream: + return "stream" + case Service: + return "service" + } + return "unknown" +} + +// MarshalJSON marshals the enum as a quoted json string +func (t *ExportType) MarshalJSON() ([]byte, error) { + switch *t { + case Stream: + return []byte("\"stream\""), nil + case Service: + return []byte("\"service\""), nil + } + return nil, fmt.Errorf("unknown export type") +} + +// UnmarshalJSON unmashals a quoted json string to the enum value +func (t *ExportType) UnmarshalJSON(b []byte) error { + var j string + err := json.Unmarshal(b, &j) + if err != nil { + return err + } + switch j { + case "stream": + *t = Stream + return nil + case "service": + *t = Service + return nil + } + return fmt.Errorf("unknown export type") +} + +// Subject is a string that represents a NATS subject +type Subject string + +// Validate checks that a subject string is valid, ie not empty and without spaces +func (s Subject) Validate(vr *ValidationResults) { + v := string(s) + if v == "" { + vr.AddError("subject cannot be empty") + } + if strings.Contains(v, " ") { + vr.AddError("subject %q cannot have spaces", v) + } +} + +// HasWildCards is used to check if a subject contains a > or * +func (s Subject) HasWildCards() bool { + v := string(s) + return strings.HasSuffix(v, ".>") || + strings.Contains(v, ".*.") || + strings.HasSuffix(v, ".*") || + strings.HasPrefix(v, "*.") || + v == "*" || + v == ">" +} + +// IsContainedIn does a simple test to see if the subject is contained in another subject +func (s Subject) IsContainedIn(other Subject) bool { + otherArray := strings.Split(string(other), ".") + myArray := strings.Split(string(s), ".") + + if len(myArray) > len(otherArray) && otherArray[len(otherArray)-1] != ">" { + return false + } + + if len(myArray) < len(otherArray) { + return false + } + + for ind, tok := range otherArray { + myTok := myArray[ind] + + if ind == len(otherArray)-1 && tok == ">" { + return true + } + + if tok != myTok && tok != "*" { + return false + } + } + + return true +} + +// NamedSubject is the combination of a subject and a name for it +type NamedSubject struct { + Name string `json:"name,omitempty"` + Subject Subject `json:"subject,omitempty"` +} + +// Validate checks the subject +func (ns *NamedSubject) Validate(vr *ValidationResults) { + ns.Subject.Validate(vr) +} + +// TimeRange is used to represent a start and end time +type TimeRange struct { + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` +} + +// Validate checks the values in a time range struct +func (tr *TimeRange) Validate(vr *ValidationResults) { + format := "15:04:05" + + if tr.Start == "" { + vr.AddError("time ranges start must contain a start") + } else { + _, err := time.Parse(format, tr.Start) + if err != nil { + vr.AddError("start in time range is invalid %q", tr.Start) + } + } + + if tr.End == "" { + vr.AddError("time ranges end must contain an end") + } else { + _, err := time.Parse(format, tr.End) + if err != nil { + vr.AddError("end in time range is invalid %q", tr.End) + } + } +} + +// Limits are used to control acccess for users and importing accounts +// Src is a comma separated list of CIDR specifications +type Limits struct { + Max int64 `json:"max,omitempty"` + Payload int64 `json:"payload,omitempty"` + Src string `json:"src,omitempty"` + Times []TimeRange `json:"times,omitempty"` +} + +// Validate checks the values in a limit struct +func (l *Limits) Validate(vr *ValidationResults) { + if l.Max < 0 { + vr.AddError("limits cannot contain a negative maximum, %d", l.Max) + } + if l.Payload < 0 { + vr.AddError("limits cannot contain a negative payload, %d", l.Payload) + } + + if l.Src != "" { + elements := strings.Split(l.Src, ",") + + for _, cidr := range elements { + cidr = strings.TrimSpace(cidr) + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil || ipNet == nil { + vr.AddError("invalid cidr %q in user src limits", cidr) + } + } + } + + if l.Times != nil && len(l.Times) > 0 { + for _, t := range l.Times { + t.Validate(vr) + } + } +} + +// Permission defines allow/deny subjects +type Permission struct { + Allow StringList `json:"allow,omitempty"` + Deny StringList `json:"deny,omitempty"` +} + +// Validate the allow, deny elements of a permission +func (p *Permission) Validate(vr *ValidationResults) { + for _, subj := range p.Allow { + Subject(subj).Validate(vr) + } + for _, subj := range p.Deny { + Subject(subj).Validate(vr) + } +} + +// ResponsePermission can be used to allow responses to any reply subject +// that is received on a valid subscription. +type ResponsePermission struct { + MaxMsgs int `json:"max"` + Expires time.Duration `json:"ttl"` +} + +// Validate the response permission. +func (p *ResponsePermission) Validate(vr *ValidationResults) { + // Any values can be valid for now. +} + +// Permissions are used to restrict subject access, either on a user or for everyone on a server by default +type Permissions struct { + Pub Permission `json:"pub,omitempty"` + Sub Permission `json:"sub,omitempty"` + Resp *ResponsePermission `json:"resp,omitempty"` +} + +// Validate the pub and sub fields in the permissions list +func (p *Permissions) Validate(vr *ValidationResults) { + p.Pub.Validate(vr) + p.Sub.Validate(vr) + if p.Resp != nil { + p.Resp.Validate(vr) + } +} + +// StringList is a wrapper for an array of strings +type StringList []string + +// Contains returns true if the list contains the string +func (u *StringList) Contains(p string) bool { + for _, t := range *u { + if t == p { + return true + } + } + return false +} + +// Add appends 1 or more strings to a list +func (u *StringList) Add(p ...string) { + for _, v := range p { + if !u.Contains(v) && v != "" { + *u = append(*u, v) + } + } +} + +// Remove removes 1 or more strings from a list +func (u *StringList) Remove(p ...string) { + for _, v := range p { + for i, t := range *u { + if t == v { + a := *u + *u = append(a[:i], a[i+1:]...) + break + } + } + } +} + +// TagList is a unique array of lower case strings +// All tag list methods lower case the strings in the arguments +type TagList []string + +// Contains returns true if the list contains the tags +func (u *TagList) Contains(p string) bool { + p = strings.ToLower(p) + for _, t := range *u { + if t == p { + return true + } + } + return false +} + +// Add appends 1 or more tags to a list +func (u *TagList) Add(p ...string) { + for _, v := range p { + v = strings.ToLower(v) + if !u.Contains(v) && v != "" { + *u = append(*u, v) + } + } +} + +// Remove removes 1 or more tags from a list +func (u *TagList) Remove(p ...string) { + for _, v := range p { + v = strings.ToLower(v) + for i, t := range *u { + if t == v { + a := *u + *u = append(a[:i], a[i+1:]...) + break + } + } + } +} + +// Identity is used to associate an account or operator with a real entity +type Identity struct { + ID string `json:"id,omitempty"` + Proof string `json:"proof,omitempty"` +} + +// Validate checks the values in an Identity +func (u *Identity) Validate(vr *ValidationResults) { + //Fixme identity validation +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..4315c16 --- /dev/null +++ b/types_test.go @@ -0,0 +1,266 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "os" + "regexp" + "strings" + "testing" +) + +func TestVersion(t *testing.T) { + // Semantic versioning + verRe := regexp.MustCompile(`\d+.\d+.\d+(-\S+)?`) + if !verRe.MatchString(Version) { + t.Fatalf("Version not compatible with semantic versioning: %q", Version) + } +} + +func TestVersionMatchesTag(t *testing.T) { + tag := os.Getenv("TRAVIS_TAG") + if tag == "" { + t.SkipNow() + } + // We expect a tag of the form vX.Y.Z. If that's not the case, + // we need someone to have a look. So fail if first letter is not + // a `v` + if tag[0] != 'v' { + t.Fatalf("Expect tag to start with `v`, tag is: %s", tag) + } + // Strip the `v` from the tag for the version comparison. + if Version != tag[1:] { + t.Fatalf("Version (%s) does not match tag (%s)", Version, tag[1:]) + } +} + +func TestTimeRangeValidation(t *testing.T) { + tr := TimeRange{ + Start: "hello", + End: "03:15:00", + } + + vr := CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad start should be invalid") + } + + if !strings.Contains(vr.Issues[0].Error(), tr.Start) { + t.Error("error should contain the faulty value") + } + + tr = TimeRange{ + Start: "15:43:22", + End: "27:11:11", + } + + vr = CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad end should be invalid") + } + + if !strings.Contains(vr.Issues[0].Error(), tr.End) { + t.Error("error should contain the faulty value") + } + + tr = TimeRange{ + Start: "", + End: "03:15:00", + } + + vr = CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad start should be invalid") + } + + tr = TimeRange{ + Start: "15:43:22", + End: "", + } + + vr = CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad end should be invalid") + } +} + +func TestTagList(t *testing.T) { + tags := TagList{} + + tags.Add("one") + + AssertEquals(true, tags.Contains("one"), t) + AssertEquals(true, tags.Contains("ONE"), t) + AssertEquals("one", tags[0], t) + + tags.Add("TWO") + + AssertEquals(true, tags.Contains("two"), t) + AssertEquals(true, tags.Contains("TWO"), t) + AssertEquals("two", tags[1], t) + + tags.Remove("ONE") + AssertEquals("two", tags[0], t) + AssertEquals(false, tags.Contains("one"), t) + AssertEquals(false, tags.Contains("ONE"), t) +} + +func TestStringList(t *testing.T) { + slist := StringList{} + + slist.Add("one") + + AssertEquals(true, slist.Contains("one"), t) + AssertEquals(false, slist.Contains("ONE"), t) + AssertEquals("one", slist[0], t) + + slist.Add("TWO") + + AssertEquals(false, slist.Contains("two"), t) + AssertEquals(true, slist.Contains("TWO"), t) + AssertEquals("TWO", slist[1], t) + + slist.Remove("ONE") + AssertEquals("one", slist[0], t) + AssertEquals(true, slist.Contains("one"), t) + AssertEquals(false, slist.Contains("ONE"), t) + + slist.Add("ONE") + AssertEquals(true, slist.Contains("one"), t) + AssertEquals(true, slist.Contains("ONE"), t) + AssertEquals(3, len(slist), t) + + slist.Remove("one") + AssertEquals("TWO", slist[0], t) + AssertEquals(false, slist.Contains("one"), t) + AssertEquals(true, slist.Contains("ONE"), t) +} + +func TestSubjectValid(t *testing.T) { + var s Subject + + vr := CreateValidationResults() + s.Validate(vr) + if !vr.IsBlocking(false) { + t.Fatalf("Empty string is not a valid subjects") + } + + s = "has spaces" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsBlocking(false) { + t.Fatalf("Subjects cannot contain spaces") + } + + s = "has.spa ces.and.tokens" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsBlocking(false) { + t.Fatalf("Subjects cannot have spaces") + } + + s = "one" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsEmpty() { + t.Fatalf("%s is a valid subject", s) + } + + s = "one.two.three" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsEmpty() { + t.Fatalf("%s is a valid subject", s) + } +} + +func TestSubjectHasWildCards(t *testing.T) { + s := Subject("one") + AssertEquals(false, s.HasWildCards(), t) + + s = "one.two.three" + AssertEquals(false, s.HasWildCards(), t) + + s = "*" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.*.three" + AssertEquals(true, s.HasWildCards(), t) + + s = "*.two.three" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.two.*" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.>" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.two.>" + AssertEquals(true, s.HasWildCards(), t) + + s = ">" + AssertEquals(true, s.HasWildCards(), t) +} + +func TestSubjectContainment(t *testing.T) { + var s Subject + var o Subject + + s = "one.two.three" + o = "one.two.three" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.two.*" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.*.three" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "*.two.three" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.two.>" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.>" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = ">" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.two" + AssertEquals(false, s.IsContainedIn(o), t) + + s = "one" + o = "one.two" + AssertEquals(false, s.IsContainedIn(o), t) +} diff --git a/user_claims.go b/user_claims.go new file mode 100644 index 0000000..78fe6a9 --- /dev/null +++ b/user_claims.go @@ -0,0 +1,106 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + + "github.com/nats-io/nkeys" +) + +// User defines the user specific data in a user JWT +type User struct { + Permissions + Limits + BearerToken bool `json:"bearer_token,omitempty"` +} + +// Validate checks the permissions and limits in a User jwt +func (u *User) Validate(vr *ValidationResults) { + u.Permissions.Validate(vr) + u.Limits.Validate(vr) + // When BearerToken is true server will ignore any nonce-signing verification +} + +// UserClaims defines a user JWT +type UserClaims struct { + ClaimsData + User `json:"nats,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` +} + +// NewUserClaims creates a user JWT with the specific subject/public key +func NewUserClaims(subject string) *UserClaims { + if subject == "" { + return nil + } + c := &UserClaims{} + c.Subject = subject + return c +} + +// Encode tries to turn the user claims into a JWT string +func (u *UserClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicUserKey(u.Subject) { + return "", errors.New("expected subject to be user public key") + } + u.ClaimsData.Type = UserClaim + return u.ClaimsData.Encode(pair, u) +} + +// DecodeUserClaims tries to parse a user claims from a JWT string +func DecodeUserClaims(token string) (*UserClaims, error) { + v := UserClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +// Validate checks the generic and specific parts of the user jwt +func (u *UserClaims) Validate(vr *ValidationResults) { + u.ClaimsData.Validate(vr) + u.User.Validate(vr) + if u.IssuerAccount != "" && !nkeys.IsValidPublicAccountKey(u.IssuerAccount) { + vr.AddError("account_id is not an account public key") + } +} + +// ExpectedPrefixes defines the types that can encode a user JWT, account +func (u *UserClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteAccount} +} + +// Claims returns the generic data from a user jwt +func (u *UserClaims) Claims() *ClaimsData { + return &u.ClaimsData +} + +// Payload returns the user specific data from a user JWT +func (u *UserClaims) Payload() interface{} { + return &u.User +} + +func (u *UserClaims) String() string { + return u.ClaimsData.String(u) +} + +// IsBearerToken returns true if nonce-signing requirements should be skipped +func (u *UserClaims) IsBearerToken() bool { + return u.BearerToken +} diff --git a/user_claims_test.go b/user_claims_test.go new file mode 100644 index 0000000..c9da7fe --- /dev/null +++ b/user_claims_test.go @@ -0,0 +1,381 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewUserClaims(t *testing.T) { + akp := createAccountNKey(t) + ukp := createUserNKey(t) + + uc := NewUserClaims(publicKey(ukp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, akp, t) + + uc2, err := DecodeUserClaims(uJwt) + if err != nil { + t.Fatal("failed to decode uc", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestUserClaimIssuer(t *testing.T) { + akp := createAccountNKey(t) + ukp := createUserNKey(t) + + uc := NewUserClaims(publicKey(ukp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, akp, t) + + temp, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeUserClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode user signed by %q", i.name) + t.Fail() + } + } +} + +func TestUserSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"user", createUserNKey(t), true}, + } + + for _, i := range inputs { + c := NewUserClaims(publicKey(i.kp, t)) + _, err := c.Encode(createAccountNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode user with with %q subject", i.name) + t.Fail() + } + } +} + +func TestNewNilUserClaim(t *testing.T) { + v := NewUserClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestUserType(t *testing.T) { + c := NewUserClaims(publicKey(createUserNKey(t), t)) + s := encode(c, createAccountNKey(t), t) + u, err := DecodeUserClaims(s) + if err != nil { + t.Fatalf("failed to decode user claim: %v", err) + } + + if UserClaim != u.Type { + t.Fatalf("user type is unexpected %q", u.Type) + } +} + +func TestSubjects(t *testing.T) { + s := StringList{} + if len(s) != 0 { + t.Fatalf("expected len 0") + } + if s.Contains("a") { + t.Fatalf("didn't expect 'a'") + } + s.Add("a") + if !s.Contains("a") { + t.Fatalf("expected 'a'") + } + s.Remove("a") + if s.Contains("a") { + t.Fatalf("didn't expect 'a' after removing") + } +} + +func TestUserValidation(t *testing.T) { + ukp := createUserNKey(t) + + uc := NewUserClaims(publicKey(ukp, t)) + uc.Permissions.Pub.Allow.Add("a") + uc.Permissions.Pub.Deny.Add("b") + uc.Permissions.Sub.Allow.Add("a") + uc.Permissions.Sub.Deny.Add("b") + uc.Permissions.Resp = &ResponsePermission{ + MaxMsgs: 10, + Expires: 50 * time.Minute, + } + uc.Limits.Max = 10 + uc.Limits.Payload = 10 + uc.Limits.Src = "192.0.2.0/24" + uc.Limits.Times = []TimeRange{ + { + Start: "01:15:00", + End: "03:15:00", + }, + { + Start: "06:15:00", + End: "09:15:00", + }, + } + + vr := CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("valid user permissions should be valid") + } + uc.Limits.Max = -1 + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Max = 10 + uc.Limits.Payload = -1 + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Payload = 10 + uc.Limits.Src = "hello world" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Payload = 10 + uc.Limits.Src = "hello world" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + tr := TimeRange{ + Start: "hello", + End: "03:15:00", + } + uc.Limits.Src = "192.0.2.0/24" + uc.Limits.Times = append(uc.Limits.Times, tr) + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Times = []TimeRange{} + uc.Permissions.Pub.Allow.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } + + uc.Permissions.Pub.Allow.Remove("bad subject") + uc.Permissions.Sub.Allow.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } + + uc.Permissions.Sub.Allow.Remove("bad subject") + uc.Permissions.Pub.Deny.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } + + uc.Permissions.Pub.Deny.Remove("bad subject") + uc.Permissions.Sub.Deny.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } +} + +func TestUserAccountID(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + a2kp := createAccountNKey(t) + ac := NewAccountClaims(apk) + ac.SigningKeys.Add(publicKey(a2kp, t)) + + token, err := ac.Encode(akp) + if err != nil { + t.Fatal(err) + } + ac, err = DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + + uc := NewUserClaims(publicKey(createUserNKey(t), t)) + uc.IssuerAccount = apk + userToken, err := uc.Encode(a2kp) + if err != nil { + t.Fatal(err) + } + + uc, err = DecodeUserClaims(userToken) + if err != nil { + t.Fatal(err) + } + + if uc.IssuerAccount != apk { + t.Fatalf("expected AccountID to be set to %s - got %s", apk, uc.IssuerAccount) + } + + signed := ac.DidSign(uc) + if !signed { + t.Fatal("expected user signed by account") + } +} + +func TestUserAccountIDValidation(t *testing.T) { + uc := NewUserClaims(publicKey(createUserNKey(t), t)) + uc.IssuerAccount = publicKey(createAccountNKey(t), t) + var vr ValidationResults + uc.Validate(&vr) + if len(vr.Issues) != 0 { + t.Fatal("expected no issues") + } + + uc.IssuerAccount = publicKey(createUserNKey(t), t) + uc.Validate(&vr) + if len(vr.Issues) != 1 { + t.Fatal("expected validation issues") + } +} + +func TestSourceNetworkValidation(t *testing.T) { + ukp := createUserNKey(t) + uc := NewUserClaims(publicKey(ukp, t)) + + uc.Limits.Src = "192.0.2.0/24" + vr := CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "192.0.2.1/1" + vr = CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "192.0.2.0/24,2001:db8:a0b:12f0::1/32" + vr = CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "192.0.2.0/24 ,\t2001:db8:a0b:12f0::1/32 , 192.168.1.1/1" + vr = CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "foo" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 { + t.Error("limits should be invalid") + } + + uc.Limits.Src = "192.0.2.0/24,foo" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 { + t.Error("limits should be invalid") + } + + uc.Limits.Src = "bloo,foo" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 2 { + t.Error("limits should be invalid") + } +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..5ccddca --- /dev/null +++ b/util_test.go @@ -0,0 +1,113 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "fmt" + "runtime" + "strings" + "testing" + + "github.com/nats-io/nkeys" +) + +func Trace(message string) string { + lines := make([]string, 0, 32) + err := errors.New(message) + msg := err.Error() + lines = append(lines, msg) + + for i := 2; true; i++ { + _, file, line, ok := runtime.Caller(i) + if !ok { + break + } + msg := fmt.Sprintf("%s:%d", file, line) + lines = append(lines, msg) + } + return strings.Join(lines, "\n") +} + +func AssertEquals(expected, v interface{}, t *testing.T) { + if expected != v { + t.Fatalf("%v", Trace(fmt.Sprintf("The expected value %v != %v", expected, v))) + } +} + +func createAccountNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("error creating account kp", err) + } + return kp +} + +func createUserNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateUser() + if err != nil { + t.Fatal("error creating account kp", err) + } + return kp +} + +func createOperatorNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateOperator() + if err != nil { + t.Fatal("error creating operator kp", err) + } + return kp +} + +func createServerNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateServer() + if err != nil { + t.Fatal("error creating server kp", err) + } + return kp +} + +func createClusterNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateCluster() + if err != nil { + t.Fatal("error creating cluster kp", err) + } + return kp +} + +func publicKey(kp nkeys.KeyPair, t *testing.T) string { + pk, err := kp.PublicKey() + if err != nil { + t.Fatal("error reading public key", err) + } + return string(pk) +} + +func seedKey(kp nkeys.KeyPair, t *testing.T) []byte { + sk, err := kp.Seed() + if err != nil { + t.Fatal("error reading seed", err) + } + return sk +} + +func encode(c Claims, kp nkeys.KeyPair, t *testing.T) string { + s, err := c.Encode(kp) + if err != nil { + t.Fatal("error encoding claim", err) + } + return s +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..c87a992 --- /dev/null +++ b/validation.go @@ -0,0 +1,107 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "fmt" +) + +// ValidationIssue represents an issue during JWT validation, it may or may not be a blocking error +type ValidationIssue struct { + Description string + Blocking bool + TimeCheck bool +} + +func (ve *ValidationIssue) Error() string { + return ve.Description +} + +// ValidationResults is a list of ValidationIssue pointers +type ValidationResults struct { + Issues []*ValidationIssue +} + +// CreateValidationResults creates an empty list of validation issues +func CreateValidationResults() *ValidationResults { + issues := []*ValidationIssue{} + return &ValidationResults{ + Issues: issues, + } +} + +//Add appends an issue to the list +func (v *ValidationResults) Add(vi *ValidationIssue) { + v.Issues = append(v.Issues, vi) +} + +// AddError creates a new validation error and adds it to the list +func (v *ValidationResults) AddError(format string, args ...interface{}) { + v.Add(&ValidationIssue{ + Description: fmt.Sprintf(format, args...), + Blocking: true, + TimeCheck: false, + }) +} + +// AddTimeCheck creates a new validation issue related to a time check and adds it to the list +func (v *ValidationResults) AddTimeCheck(format string, args ...interface{}) { + v.Add(&ValidationIssue{ + Description: fmt.Sprintf(format, args...), + Blocking: false, + TimeCheck: true, + }) +} + +// AddWarning creates a new validation warning and adds it to the list +func (v *ValidationResults) AddWarning(format string, args ...interface{}) { + v.Add(&ValidationIssue{ + Description: fmt.Sprintf(format, args...), + Blocking: false, + TimeCheck: false, + }) +} + +// IsBlocking returns true if the list contains a blocking error +func (v *ValidationResults) IsBlocking(includeTimeChecks bool) bool { + for _, i := range v.Issues { + if i.Blocking { + return true + } + + if includeTimeChecks && i.TimeCheck { + return true + } + } + return false +} + +// IsEmpty returns true if the list is empty +func (v *ValidationResults) IsEmpty() bool { + return len(v.Issues) == 0 +} + +// Errors returns only blocking issues as errors +func (v *ValidationResults) Errors() []error { + var errs []error + for _, v := range v.Issues { + if v.Blocking { + errs = append(errs, errors.New(v.Description)) + } + } + return errs +} From 1476a208a3fe8a7ff668c3b8cebf483e9933e4a8 Mon Sep 17 00:00:00 2001 From: aricart Date: Tue, 4 Feb 2020 10:33:00 -0400 Subject: [PATCH 03/14] V2 Changes - V1 Deprecates cluster/server claims, as these are not used - will re-introduced when supported - V2 Removes deprecated API - Resolve JWT namespace squatting. Extra fields leaked through to the root object of the claims - NATS claims add a version number enabling the library to reject claims that it doesn't understand. - Old JWTs are automatically migrated into their v2 formats on read, so reading code is backwards compatible for non-tool clients. - V2 Removes internal use of deprecated fields JWT Namespace Squatting Changes - ClaimsData had `tags` and `type` fields for all JWT types, these are moved to the structure `nats`. - ActivationsClaims and UserClaims had `issuer_account` moved to the `nats` structure. --- .gitignore | 4 +- .travis.yml | 45 +++-- activation_claims_test.go | 2 +- claims.go | 9 +- cluster_claims.go | 6 +- operator_claims.go | 8 +- server_claims.go | 10 +- v2/Makefile | 18 ++ v2/account_claims.go | 27 ++- v2/account_claims_test.go | 21 +-- v2/activation_claims.go | 29 +++- v2/activation_claims_test.go | 10 +- v2/claims.go | 108 +++--------- v2/cluster_claims.go | 94 ----------- v2/cluster_claims_test.go | 132 --------------- v2/creds_utils.go | 12 +- v2/decoder.go | 134 +++++++++++++++ v2/decoder_account.go | 61 +++++++ v2/decoder_activation.go | 57 +++++++ v2/decoder_migration_test.go | 316 +++++++++++++++++++++++++++++++++++ v2/decoder_operator.go | 57 +++++++ v2/decoder_test.go | 2 +- v2/decoder_user.go | 61 +++++++ v2/exports.go | 2 +- v2/exports_test.go | 3 +- v2/genericclaims_test.go | 48 +++--- v2/genericlaims.go | 68 +++++++- v2/go.mod | 10 +- v2/go.sum | 12 ++ v2/imports.go | 6 +- v2/imports_test.go | 8 +- v2/operator_claims.go | 32 ++-- v2/operator_claims_test.go | 48 +++--- v2/server_claims.go | 94 ----------- v2/server_claims_test.go | 132 --------------- v2/types.go | 4 +- v2/user_claims.go | 27 ++- v2/user_claims_test.go | 4 +- v2/util_test.go | 2 +- v2/validation.go | 2 +- 40 files changed, 1030 insertions(+), 695 deletions(-) create mode 100644 v2/Makefile delete mode 100644 v2/cluster_claims.go delete mode 100644 v2/cluster_claims_test.go create mode 100644 v2/decoder.go create mode 100644 v2/decoder_account.go create mode 100644 v2/decoder_activation.go create mode 100644 v2/decoder_migration_test.go create mode 100644 v2/decoder_operator.go create mode 100644 v2/decoder_user.go delete mode 100644 v2/server_claims.go delete mode 100644 v2/server_claims_test.go diff --git a/.gitignore b/.gitignore index 7117a67..a34877a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ # IDE Files .vscode -.idea/ \ No newline at end of file +.idea/ + +coverage.out \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 50e27a6..9a35fac 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,22 +1,33 @@ +os: linux language: go -sudo: false go: -- 1.13.x -- 1.12.x - + - 1.13.x + - 1.12.x +git: + depth: false +env: + - V= + - V=v2 install: -- go get -t ./... -- go get github.com/mattn/goveralls -- go get github.com/wadey/gocovmerge -- go get -u honnef.co/go/tools/cmd/staticcheck -- go get -u github.com/client9/misspell/cmd/misspell - + - go get -t ./... + - go get -u honnef.co/go/tools/cmd/staticcheck + - go get -u github.com/client9/misspell/cmd/misspell + - go get github.com/mattn/goveralls + - go get github.com/wadey/gocovmerge before_script: -- $(exit $(go fmt ./... | wc -l)) -- go vet ./... -- misspell -error -locale US . -- staticcheck ./... - + - cd $TRAVIS_BUILD_DIR/${V} + - $(exit $(go fmt ./... | wc -l)) + - go vet ./... + - misspell -error -locale US . + - staticcheck ./... script: -- go test -v -race ./... -- if [[ "$TRAVIS_GO_VERSION" =~ 1.12 ]]; then ./scripts/cov.sh TRAVIS; fi + - cd $TRAVIS_BUILD_DIR/${V} + - go test -v -coverprofile=./coverage.out ./... +deploy: + - provider: script + script: $HOME/gopath/bin/goveralls -coverprofile=v2/coverage.out -service travis-ci + on: + tags: true + condition: ${V} = v2 && $TRAVIS_GO_VERSION =~ ^1.13. + + diff --git a/activation_claims_test.go b/activation_claims_test.go index 19532b3..ad4fceb 100644 --- a/activation_claims_test.go +++ b/activation_claims_test.go @@ -114,7 +114,7 @@ func TestInvalidActivationClaimIssuer(t *testing.T) { for _, i := range inputs { bad := encode(temp, i.kp, t) - _, err = DecodeAccountClaims(bad) + _, err = DecodeActivationClaims(bad) if i.ok && err != nil { t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) } diff --git a/claims.go b/claims.go index d402bcc..3179c91 100644 --- a/claims.go +++ b/claims.go @@ -38,12 +38,15 @@ const ( ActivationClaim = "activation" //UserClaim is the type of an user JWT UserClaim = "user" + //OperatorClaim is the type of an operator JWT + OperatorClaim = "operator" + //ServerClaim is the type of an server JWT + // Deprecated: ServerClaim is not supported ServerClaim = "server" - //ClusterClaim is the type of an cluster JWT + // ClusterClaim is the type of an cluster JWT + // Deprecated: ClusterClaim is not supported ClusterClaim = "cluster" - //OperatorClaim is the type of an operator JWT - OperatorClaim = "operator" ) // Claims is a JWT claims diff --git a/cluster_claims.go b/cluster_claims.go index bbfcf06..7924dfa 100644 --- a/cluster_claims.go +++ b/cluster_claims.go @@ -1,5 +1,5 @@ /* - * Copyright 2018 The NATS Authors + * Copyright 2018-2020 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 @@ -22,6 +22,7 @@ import ( ) // Cluster stores the cluster specific elements of a cluster JWT +// Deprecated: ClusterClaims are not supported type Cluster struct { Trust []string `json:"identity,omitempty"` Accounts []string `json:"accts,omitempty"` @@ -35,12 +36,14 @@ func (c *Cluster) Validate(vr *ValidationResults) { } // ClusterClaims defines the data in a cluster JWT +// Deprecated: ClusterClaims are not supported type ClusterClaims struct { ClaimsData Cluster `json:"nats,omitempty"` } // NewClusterClaims creates a new cluster JWT with the specified subject/public key +// Deprecated: ClusterClaims are not supported func NewClusterClaims(subject string) *ClusterClaims { if subject == "" { return nil @@ -60,6 +63,7 @@ func (c *ClusterClaims) Encode(pair nkeys.KeyPair) (string, error) { } // DecodeClusterClaims tries to parse cluster claims from a JWT string +// Deprecated: ClusterClaims are not supported func DecodeClusterClaims(token string) (*ClusterClaims, error) { v := ClusterClaims{} if err := Decode(token, &v); err != nil { diff --git a/operator_claims.go b/operator_claims.go index 6a99597..68d904c 100644 --- a/operator_claims.go +++ b/operator_claims.go @@ -26,7 +26,7 @@ import ( // Operator specific claims type Operator struct { - // Slice of real identies (like websites) that can be used to identify the operator. + // Slice of real identities (like websites) that can be used to identify the operator. Identities []Identity `json:"identity,omitempty"` // Slice of other operator NKeys that can be used to sign on behalf of the main // operator identity. @@ -112,15 +112,15 @@ func ValidateOperatorServiceURL(v string) error { } func (o *Operator) validateOperatorServiceURLs() []error { - var errors []error + var errs []error for _, v := range o.OperatorServiceURLs { if v != "" { if err := ValidateOperatorServiceURL(v); err != nil { - errors = append(errors, err) + errs = append(errs, err) } } } - return errors + return errs } // OperatorClaims define the data for an operator JWT diff --git a/server_claims.go b/server_claims.go index c18f167..587aef9 100644 --- a/server_claims.go +++ b/server_claims.go @@ -1,5 +1,5 @@ /* - * Copyright 2018 The NATS Authors + * Copyright 2018-2020 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 @@ -21,7 +21,7 @@ import ( "github.com/nats-io/nkeys" ) -// Server defines the custom part of a server jwt +// Deprecated: ServerClaims are not supported type Server struct { Permissions Cluster string `json:"cluster,omitempty"` @@ -34,13 +34,13 @@ func (s *Server) Validate(vr *ValidationResults) { } } -// ServerClaims defines the data in a server JWT +// Deprecated: ServerClaims are not supported type ServerClaims struct { ClaimsData Server `json:"nats,omitempty"` } -// NewServerClaims creates a new server JWT with the specified subject/public key +// Deprecated: ServerClaims are not supported func NewServerClaims(subject string) *ServerClaims { if subject == "" { return nil @@ -59,7 +59,7 @@ func (s *ServerClaims) Encode(pair nkeys.KeyPair) (string, error) { return s.ClaimsData.Encode(pair, s) } -// DecodeServerClaims tries to parse server claims from a JWT string +// Deprecated: ServerClaims are not supported func DecodeServerClaims(token string) (*ServerClaims, error) { v := ServerClaims{} if err := Decode(token, &v); err != nil { diff --git a/v2/Makefile b/v2/Makefile new file mode 100644 index 0000000..c805857 --- /dev/null +++ b/v2/Makefile @@ -0,0 +1,18 @@ +.PHONY: test cover + +build: + go build + +fmt: + gofmt -w -s *.go + goimports -w *.go + go mod tidy + +test: + go vet ./... + staticcheck ./... + rm -rf ./coverage.out + go test -coverprofile=./coverage.out ./... + +cover: + go tool cover -html=coverage.out diff --git a/v2/account_claims.go b/v2/account_claims.go index 945bd98..b54c8fa 100644 --- a/v2/account_claims.go +++ b/v2/account_claims.go @@ -49,7 +49,7 @@ func (o *OperatorLimits) IsUnlimited() bool { } // Validate checks that the operator limits contain valid values -func (o *OperatorLimits) Validate(vr *ValidationResults) { +func (o *OperatorLimits) Validate(_ *ValidationResults) { // negative values mean unlimited, so all numbers are valid } @@ -61,6 +61,7 @@ type Account struct { Limits OperatorLimits `json:"limits,omitempty"` SigningKeys StringList `json:"signing_keys,omitempty"` Revocations RevocationList `json:"revocations,omitempty"` + GenericFields } // Validate checks if the account is valid, based on the wrapper @@ -130,17 +131,21 @@ func (a *AccountClaims) Encode(pair nkeys.KeyPair) (string, error) { } sort.Sort(a.Exports) sort.Sort(a.Imports) - a.ClaimsData.Type = AccountClaim + a.Type = AccountClaim return a.ClaimsData.Encode(pair, a) } // DecodeAccountClaims decodes account claims from a JWT string func DecodeAccountClaims(token string) (*AccountClaims, error) { - v := AccountClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + ac, ok := claims.(*AccountClaims) + if !ok { + return nil, errors.New("not account claim") + } + return ac, nil } func (a *AccountClaims) String() string { @@ -167,6 +172,14 @@ func (a *AccountClaims) Validate(vr *ValidationResults) { } } +func (a *AccountClaims) ClaimType() ClaimType { + return a.Type +} + +func (a *AccountClaims) updateVersion() { + a.GenericFields.Version = libVersion +} + // ExpectedPrefixes defines the types that can encode an account jwt, account and operator func (a *AccountClaims) ExpectedPrefixes() []nkeys.PrefixByte { return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} @@ -189,12 +202,12 @@ func (a *AccountClaims) DidSign(op Claims) bool { return false } -// Revoke enters a revocation by publickey using time.Now(). +// Revoke enters a revocation by public key using time.Now(). func (a *AccountClaims) Revoke(pubKey string) { a.RevokeAt(pubKey, time.Now()) } -// RevokeAt enters a revocation by publickey and timestamp into this export +// RevokeAt enters a revocation by public key and timestamp into this export // If there is already a revocation for this public key that is newer, it is kept. func (a *AccountClaims) RevokeAt(pubKey string, timestamp time.Time) { if a.Revocations == nil { diff --git a/v2/account_claims_test.go b/v2/account_claims_test.go index c9fe4a2..dfc98dd 100644 --- a/v2/account_claims_test.go +++ b/v2/account_claims_test.go @@ -31,7 +31,7 @@ func TestNewAccountClaims(t *testing.T) { activation := NewActivationClaims(apk) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream actJWT := encode(activation, akp2, t) @@ -41,7 +41,7 @@ func TestNewAccountClaims(t *testing.T) { t.Fatalf("Expected unlimited operator limits") } - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).UTC().Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).UTC().Unix() account.Imports = Imports{} account.Imports.Add(&Import{Subject: "test", Name: "test import", Account: apk2, Token: actJWT, To: "my", Type: Stream}) @@ -72,7 +72,7 @@ func TestAccountCanSignOperatorLimits(t *testing.T) { // don't block encoding!!! apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 10 account.Limits.LeafNodeConn = 2 @@ -87,7 +87,7 @@ func TestAccountCanSignIdentities(t *testing.T) { // don't block encoding!!! apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Identities = []Identity{ { ID: "stephen", @@ -107,7 +107,7 @@ func TestOperatorCanSignClaims(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 1 account.Limits.LeafNodeConn = 4 @@ -139,7 +139,7 @@ func TestOperatorCanSignClaims(t *testing.T) { func TestInvalidAccountClaimIssuer(t *testing.T) { akp := createAccountNKey(t) ac := NewAccountClaims(publicKey(akp, t)) - ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + ac.Expires = time.Now().Add(time.Hour).Unix() aJwt := encode(ac, akp, t) temp, err := DecodeGeneric(aJwt) @@ -194,9 +194,6 @@ func TestInvalidAccountSubjects(t *testing.T) { var err error c := NewAccountClaims(pk) - if i.ok && err != nil { - t.Fatalf("error encoding activation: %v", err) - } _, err = c.Encode(i.kp) if i.ok && err != nil { t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) @@ -213,7 +210,7 @@ func TestAccountImports(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() actJwt := encode(account, akp, t) @@ -237,7 +234,7 @@ func TestLimitValidationInAccount(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 10 account.Limits.Imports = 10 account.Limits.Exports = 10 @@ -322,7 +319,7 @@ func TestWildcardExportLimit(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 10 account.Limits.Imports = 10 account.Limits.Exports = 10 diff --git a/v2/activation_claims.go b/v2/activation_claims.go index 99228a7..2fc78ef 100644 --- a/v2/activation_claims.go +++ b/v2/activation_claims.go @@ -28,8 +28,12 @@ import ( // Activation defines the custom parts of an activation claim type Activation struct { ImportSubject Subject `json:"subject,omitempty"` - ImportType ExportType `json:"type,omitempty"` + ImportType ExportType `json:"kind,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` Limits + GenericFields } // IsService returns true if an Activation is for a service @@ -62,9 +66,6 @@ func (a *Activation) Validate(vr *ValidationResults) { type ActivationClaims struct { ClaimsData Activation `json:"nats,omitempty"` - // IssuerAccount stores the public key for the account the issuer represents. - // When set, the claim was issued by a signing key. - IssuerAccount string `json:"issuer_account,omitempty"` } // NewActivationClaims creates a new activation claim with the provided sub @@ -82,17 +83,21 @@ func (a *ActivationClaims) Encode(pair nkeys.KeyPair) (string, error) { if !nkeys.IsValidPublicAccountKey(a.ClaimsData.Subject) { return "", errors.New("expected subject to be an account") } - a.ClaimsData.Type = ActivationClaim + a.Type = ActivationClaim return a.ClaimsData.Encode(pair, a) } // DecodeActivationClaims tries to create an activation claim from a JWT string func DecodeActivationClaims(token string) (*ActivationClaims, error) { - v := ActivationClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + ac, ok := claims.(*ActivationClaims) + if !ok { + return nil, errors.New("not activation claim") + } + return ac, nil } // Payload returns the activation specific part of the JWT @@ -109,6 +114,14 @@ func (a *ActivationClaims) Validate(vr *ValidationResults) { } } +func (a *ActivationClaims) ClaimType() ClaimType { + return a.Type +} + +func (a *ActivationClaims) updateVersion() { + a.GenericFields.Version = libVersion +} + // ExpectedPrefixes defines the types that can sign an activation jwt, account and oeprator func (a *ActivationClaims) ExpectedPrefixes() []nkeys.PrefixByte { return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} diff --git a/v2/activation_claims_test.go b/v2/activation_claims_test.go index 19532b3..e9f3c95 100644 --- a/v2/activation_claims_test.go +++ b/v2/activation_claims_test.go @@ -30,7 +30,7 @@ func TestNewActivationClaims(t *testing.T) { activation := NewActivationClaims(apk) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + activation.Expires = time.Now().Add(time.Hour).Unix() activation.Limits.Max = 10 activation.Limits.Payload = 10 activation.Limits.Src = "192.0.2.0/24" @@ -90,7 +90,7 @@ func TestInvalidActivationTargets(t *testing.T) { func TestInvalidActivationClaimIssuer(t *testing.T) { akp := createAccountNKey(t) ac := NewActivationClaims(publicKey(akp, t)) - ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + ac.Expires = time.Now().Add(time.Hour).Unix() aJwt := encode(ac, akp, t) temp, err := DecodeGeneric(aJwt) @@ -114,7 +114,7 @@ func TestInvalidActivationClaimIssuer(t *testing.T) { for _, i := range inputs { bad := encode(temp, i.kp, t) - _, err = DecodeAccountClaims(bad) + _, err = DecodeActivationClaims(bad) if i.ok && err != nil { t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) } @@ -215,7 +215,7 @@ func TestActivationValidation(t *testing.T) { activation := NewActivationClaims(apk) activation.Issuer = apk activation.Subject = apk2 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + activation.Expires = time.Now().Add(time.Hour).Unix() activation.ImportSubject = "foo" activation.Name = "Foo" @@ -371,7 +371,7 @@ func TestActivationClaimAccountIDValidation(t *testing.T) { ac := NewActivationClaims(importerPK) ac.IssuerAccount = issuerAccountPK ac.Name = "foo.bar" - ac.Activation.ImportSubject = Subject("foo.bar") + ac.Activation.ImportSubject = "foo.bar" ac.Activation.ImportType = Stream var vr ValidationResults diff --git a/v2/claims.go b/v2/claims.go index d402bcc..6da71f1 100644 --- a/v2/claims.go +++ b/v2/claims.go @@ -22,7 +22,6 @@ import ( "encoding/json" "errors" "fmt" - "strings" "time" "github.com/nats-io/nkeys" @@ -32,18 +31,14 @@ import ( type ClaimType string const ( + //OperatorClaim is the type of an operator JWT + OperatorClaim = "operator" // AccountClaim is the type of an Account JWT AccountClaim = "account" - //ActivationClaim is the type of an activation JWT - ActivationClaim = "activation" //UserClaim is the type of an user JWT UserClaim = "user" - //ServerClaim is the type of an server JWT - ServerClaim = "server" - //ClusterClaim is the type of an cluster JWT - ClusterClaim = "cluster" - //OperatorClaim is the type of an operator JWT - OperatorClaim = "operator" + //ActivationClaim is the type of an activation JWT + ActivationClaim = "activation" ) // Claims is a JWT claims @@ -55,20 +50,26 @@ type Claims interface { String() string Validate(vr *ValidationResults) Verify(payload string, sig []byte) bool + ClaimType() ClaimType + updateVersion() +} + +type GenericFields struct { + Tags TagList `json:"tags,omitempty"` + Type ClaimType `json:"type,omitempty"` + Version int `json:"version,omitempty"` } // ClaimsData is the base struct for all claims type ClaimsData struct { - Audience string `json:"aud,omitempty"` - Expires int64 `json:"exp,omitempty"` - ID string `json:"jti,omitempty"` - IssuedAt int64 `json:"iat,omitempty"` - Issuer string `json:"iss,omitempty"` - Name string `json:"name,omitempty"` - NotBefore int64 `json:"nbf,omitempty"` - Subject string `json:"sub,omitempty"` - Tags TagList `json:"tags,omitempty"` - Type ClaimType `json:"type,omitempty"` + Audience string `json:"aud,omitempty"` + Expires int64 `json:"exp,omitempty"` + ID string `json:"jti,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + Issuer string `json:"iss,omitempty"` + Name string `json:"name,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Subject string `json:"sub,omitempty"` } // Prefix holds the prefix byte for an NKey @@ -147,7 +148,7 @@ func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (s } } - c.Issuer = string(issuerBytes) + c.Issuer = issuerBytes c.IssuedAt = time.Now().UTC().Unix() c.ID, err = c.hash() @@ -155,6 +156,8 @@ func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (s return "", err } + claim.updateVersion() + payload, err := serialize(claim) if err != nil { return "", err @@ -235,68 +238,3 @@ func (c *ClaimsData) Validate(vr *ValidationResults) { func (c *ClaimsData) IsSelfSigned() bool { return c.Issuer == c.Subject } - -// Decode takes a JWT string decodes it and validates it -// and return the embedded Claims. If the token header -// doesn't match the expected algorithm, or the claim is -// not valid or verification fails an error is returned. -func Decode(token string, target Claims) error { - // must have 3 chunks - chunks := strings.Split(token, ".") - if len(chunks) != 3 { - return errors.New("expected 3 chunks") - } - - _, err := parseHeaders(chunks[0]) - if err != nil { - return err - } - - if err := parseClaims(chunks[1], target); err != nil { - return err - } - - sig, err := decodeString(chunks[2]) - if err != nil { - return err - } - - if !target.Verify(chunks[1], sig) { - return errors.New("claim failed signature verification") - } - - prefixes := target.ExpectedPrefixes() - if prefixes != nil { - ok := false - issuer := target.Claims().Issuer - for _, p := range prefixes { - switch p { - case nkeys.PrefixByteAccount: - if nkeys.IsValidPublicAccountKey(issuer) { - ok = true - } - case nkeys.PrefixByteOperator: - if nkeys.IsValidPublicOperatorKey(issuer) { - ok = true - } - case nkeys.PrefixByteServer: - if nkeys.IsValidPublicServerKey(issuer) { - ok = true - } - case nkeys.PrefixByteCluster: - if nkeys.IsValidPublicClusterKey(issuer) { - ok = true - } - case nkeys.PrefixByteUser: - if nkeys.IsValidPublicUserKey(issuer) { - ok = true - } - } - } - if !ok { - return fmt.Errorf("unable to validate expected prefixes - %v", prefixes) - } - } - - return nil -} diff --git a/v2/cluster_claims.go b/v2/cluster_claims.go deleted file mode 100644 index bbfcf06..0000000 --- a/v2/cluster_claims.go +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "errors" - - "github.com/nats-io/nkeys" -) - -// Cluster stores the cluster specific elements of a cluster JWT -type Cluster struct { - Trust []string `json:"identity,omitempty"` - Accounts []string `json:"accts,omitempty"` - AccountURL string `json:"accturl,omitempty"` - OperatorURL string `json:"opurl,omitempty"` -} - -// Validate checks the cluster and permissions for a cluster JWT -func (c *Cluster) Validate(vr *ValidationResults) { - // fixme validate cluster data -} - -// ClusterClaims defines the data in a cluster JWT -type ClusterClaims struct { - ClaimsData - Cluster `json:"nats,omitempty"` -} - -// NewClusterClaims creates a new cluster JWT with the specified subject/public key -func NewClusterClaims(subject string) *ClusterClaims { - if subject == "" { - return nil - } - c := &ClusterClaims{} - c.Subject = subject - return c -} - -// Encode tries to turn the cluster claims into a JWT string -func (c *ClusterClaims) Encode(pair nkeys.KeyPair) (string, error) { - if !nkeys.IsValidPublicClusterKey(c.Subject) { - return "", errors.New("expected subject to be a cluster public key") - } - c.ClaimsData.Type = ClusterClaim - return c.ClaimsData.Encode(pair, c) -} - -// DecodeClusterClaims tries to parse cluster claims from a JWT string -func DecodeClusterClaims(token string) (*ClusterClaims, error) { - v := ClusterClaims{} - if err := Decode(token, &v); err != nil { - return nil, err - } - return &v, nil -} - -func (c *ClusterClaims) String() string { - return c.ClaimsData.String(c) -} - -// Payload returns the cluster specific data -func (c *ClusterClaims) Payload() interface{} { - return &c.Cluster -} - -// Validate checks the generic and cluster data in the cluster claims -func (c *ClusterClaims) Validate(vr *ValidationResults) { - c.ClaimsData.Validate(vr) - c.Cluster.Validate(vr) -} - -// ExpectedPrefixes defines the types that can encode a cluster JWT, operator or cluster -func (c *ClusterClaims) ExpectedPrefixes() []nkeys.PrefixByte { - return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} -} - -// Claims returns the generic data -func (c *ClusterClaims) Claims() *ClaimsData { - return &c.ClaimsData -} diff --git a/v2/cluster_claims_test.go b/v2/cluster_claims_test.go deleted file mode 100644 index 5573c8d..0000000 --- a/v2/cluster_claims_test.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "fmt" - "testing" - "time" - - "github.com/nats-io/nkeys" -) - -func TestNewClusterClaims(t *testing.T) { - ckp := createClusterNKey(t) - skp := createClusterNKey(t) - - uc := NewClusterClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - uc2, err := DecodeClusterClaims(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - AssertEquals(uc.String(), uc2.String(), t) - - AssertEquals(uc.Claims() != nil, true, t) - AssertEquals(uc.Payload() != nil, true, t) -} - -func TestClusterClaimsIssuer(t *testing.T) { - ckp := createClusterNKey(t) - skp := createClusterNKey(t) - - uc := NewClusterClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - temp, err := DecodeGeneric(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"user", createUserNKey(t), false}, - {"operator", createOperatorNKey(t), true}, - {"server", createServerNKey(t), false}, - {"cluster", createClusterNKey(t), true}, - } - - for _, i := range inputs { - bad := encode(temp, i.kp, t) - _, err = DecodeClusterClaims(bad) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to decode cluster signed by %q", i.name) - t.Fail() - } - } -} - -func TestClusterSubjects(t *testing.T) { - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"server", createServerNKey(t), false}, - {"operator", createOperatorNKey(t), false}, - {"cluster", createClusterNKey(t), true}, - {"user", createUserNKey(t), false}, - } - - for _, i := range inputs { - c := NewClusterClaims(publicKey(i.kp, t)) - _, err := c.Encode(createOperatorNKey(t)) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to encode cluster with with %q subject", i.name) - t.Fail() - } - } -} - -func TestNewNilClusterClaims(t *testing.T) { - v := NewClusterClaims("") - if v != nil { - t.Fatal("expected nil user claim") - } -} - -func TestClusterType(t *testing.T) { - c := NewClusterClaims(publicKey(createClusterNKey(t), t)) - s := encode(c, createClusterNKey(t), t) - u, err := DecodeClusterClaims(s) - if err != nil { - t.Fatalf("failed to decode cluster claim: %v", err) - } - - if ClusterClaim != u.Type { - t.Fatalf("type is unexpected %q (wanted cluster)", u.Type) - } - -} diff --git a/v2/creds_utils.go b/v2/creds_utils.go index bb913dc..2d2cfd3 100644 --- a/v2/creds_utils.go +++ b/v2/creds_utils.go @@ -12,11 +12,11 @@ import ( // DecorateJWT returns a decorated JWT that describes the kind of JWT func DecorateJWT(jwtString string) ([]byte, error) { - gc, err := DecodeGeneric(jwtString) + gc, err := Decode(jwtString) if err != nil { return nil, err } - return formatJwt(string(gc.Type), jwtString) + return formatJwt(string(gc.ClaimType()), jwtString) } func formatJwt(kind string, jwtString string) ([]byte, error) { @@ -99,17 +99,17 @@ var userConfigRE = regexp.MustCompile(`\s*(?:(?:[-]{3,}[^\n]*[-]{3,}\n)(.+)(?:\n // FormatUserConfig returns a decorated file with a decorated JWT and decorated seed func FormatUserConfig(jwtString string, seed []byte) ([]byte, error) { - gc, err := DecodeGeneric(jwtString) + gc, err := Decode(jwtString) if err != nil { return nil, err } - if gc.Type != UserClaim { - return nil, fmt.Errorf("%q cannot be serialized as a user config", string(gc.Type)) + if gc.ClaimType() != UserClaim { + return nil, fmt.Errorf("%q cannot be serialized as a user config", string(gc.ClaimType())) } w := bytes.NewBuffer(nil) - jd, err := formatJwt(string(gc.Type), jwtString) + jd, err := formatJwt(string(gc.ClaimType()), jwtString) if err != nil { return nil, err } diff --git a/v2/decoder.go b/v2/decoder.go new file mode 100644 index 0000000..8d63af6 --- /dev/null +++ b/v2/decoder.go @@ -0,0 +1,134 @@ +package jwt + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/nats-io/nkeys" +) + +const libVersion = 2 + +type identifier struct { + Type ClaimType `json:"type,omitempty"` + GenericFields `json:"nats,omitempty"` +} + +func (i *identifier) Kind() ClaimType { + if i.Type != "" { + return i.Type + } + return i.GenericFields.Type +} + +func (i *identifier) Version() int { + if i.Type != "" { + return 1 + } + return i.GenericFields.Version +} + +type v1ClaimsDataDeletedFields struct { + Tags TagList `json:"tags,omitempty"` + Type ClaimType `json:"type,omitempty"` + IssuerAccount string `json:"issuer_account,omitempty"` +} + +// Decode takes a JWT string decodes it and validates it +// and return the embedded Claims. If the token header +// doesn't match the expected algorithm, or the claim is +// not valid or verification fails an error is returned. +func Decode(token string) (Claims, error) { + // must have 3 chunks + chunks := strings.Split(token, ".") + if len(chunks) != 3 { + return nil, errors.New("expected 3 chunks") + } + + // header + if _, err := parseHeaders(chunks[0]); err != nil { + return nil, err + } + // claim + data, err := decodeString(chunks[1]) + if err != nil { + return nil, err + } + claim, err := loadClaims(data) + if err != nil { + return nil, err + } + + // sig + sig, err := decodeString(chunks[2]) + if err != nil { + return nil, err + } + if !claim.Verify(chunks[1], sig) { + return nil, errors.New("claim failed signature verification") + } + + prefixes := claim.ExpectedPrefixes() + if prefixes != nil { + ok := false + issuer := claim.Claims().Issuer + for _, p := range prefixes { + switch p { + case nkeys.PrefixByteAccount: + if nkeys.IsValidPublicAccountKey(issuer) { + ok = true + } + case nkeys.PrefixByteOperator: + if nkeys.IsValidPublicOperatorKey(issuer) { + ok = true + } + case nkeys.PrefixByteUser: + if nkeys.IsValidPublicUserKey(issuer) { + ok = true + } + } + } + if !ok { + return nil, fmt.Errorf("unable to validate expected prefixes - %v", prefixes) + } + } + return claim, nil +} + +func loadClaims(data []byte) (Claims, error) { + var id identifier + if err := json.Unmarshal(data, &id); err != nil { + return nil, err + } + + if id.Version() > libVersion { + return nil, errors.New("JWT was generated by a newer version ") + } + + var claim Claims + var err error + switch id.Kind() { + case OperatorClaim: + claim, err = loadOperator(data, id.Version()) + case AccountClaim: + claim, err = loadAccount(data, id.Version()) + case UserClaim: + claim, err = loadUser(data, id.Version()) + case ActivationClaim: + claim, err = loadActivation(data, id.Version()) + case "cluster": + return nil, errors.New("ClusterClaims are not supported") + case "server": + return nil, errors.New("ServerClaims are not supported") + default: + var gc GenericClaims + if err := json.Unmarshal(data, &gc); err != nil { + return nil, err + } + return &gc, nil + } + + return claim, err +} diff --git a/v2/decoder_account.go b/v2/decoder_account.go new file mode 100644 index 0000000..f934177 --- /dev/null +++ b/v2/decoder_account.go @@ -0,0 +1,61 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +type v1NatsAccount struct { + Imports Imports `json:"imports,omitempty"` + Exports Exports `json:"exports,omitempty"` + Identities []Identity `json:"identity,omitempty"` + Limits OperatorLimits `json:"limits,omitempty"` + SigningKeys StringList `json:"signing_keys,omitempty"` + Revocations RevocationList `json:"revocations,omitempty"` +} + +func loadAccount(data []byte, version int) (*AccountClaims, error) { + switch version { + case 1: + var v1a v1AccountClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a AccountClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +type v1AccountClaims struct { + ClaimsData + v1ClaimsDataDeletedFields + v1NatsAccount `json:"nats,omitempty"` +} + +func (oa v1AccountClaims) Migrate() (*AccountClaims, error) { + return oa.migrateV1() +} + +func (oa v1AccountClaims) migrateV1() (*AccountClaims, error) { + var a AccountClaims + // copy the base claim + a.ClaimsData = oa.ClaimsData + // move the moved fields + a.Account.Type = oa.v1ClaimsDataDeletedFields.Type + a.Account.Tags = oa.v1ClaimsDataDeletedFields.Tags + // copy the account data + a.Account.Imports = oa.v1NatsAccount.Imports + a.Account.Exports = oa.v1NatsAccount.Exports + a.Account.Identities = oa.v1NatsAccount.Identities + a.Account.Limits = oa.v1NatsAccount.Limits + a.Account.SigningKeys = oa.v1NatsAccount.SigningKeys + a.Account.Revocations = oa.v1NatsAccount.Revocations + return &a, nil +} diff --git a/v2/decoder_activation.go b/v2/decoder_activation.go new file mode 100644 index 0000000..aba0975 --- /dev/null +++ b/v2/decoder_activation.go @@ -0,0 +1,57 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +// Migration adds GenericFields +type v1NatsActivation struct { + ImportSubject Subject `json:"subject,omitempty"` + ImportType ExportType `json:"type,omitempty"` + Limits +} + +type v1ActivationClaims struct { + ClaimsData + v1ClaimsDataDeletedFields + v1NatsActivation `json:"nats,omitempty"` +} + +func loadActivation(data []byte, version int) (*ActivationClaims, error) { + switch version { + case 1: + var v1a v1ActivationClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a ActivationClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +func (oa v1ActivationClaims) Migrate() (*ActivationClaims, error) { + return oa.migrateV1() +} + +func (oa v1ActivationClaims) migrateV1() (*ActivationClaims, error) { + var a ActivationClaims + // copy the base claim + a.ClaimsData = oa.ClaimsData + // move the moved fields + a.Activation.Type = oa.v1ClaimsDataDeletedFields.Type + a.Activation.Tags = oa.v1ClaimsDataDeletedFields.Tags + a.Activation.IssuerAccount = oa.v1ClaimsDataDeletedFields.IssuerAccount + // copy the activation data + a.ImportSubject = oa.ImportSubject + a.ImportType = oa.ImportType + a.Limits = oa.Limits + return &a, nil +} diff --git a/v2/decoder_migration_test.go b/v2/decoder_migration_test.go new file mode 100644 index 0000000..0484379 --- /dev/null +++ b/v2/decoder_migration_test.go @@ -0,0 +1,316 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "strings" + "testing" + "time" + + v1jwt "github.com/nats-io/jwt" + "github.com/nats-io/nkeys" + "github.com/stretchr/testify/require" +) + +func createExport(sub string) *v1jwt.Export { + var e v1jwt.Export + e.Type = v1jwt.Service + e.Subject = v1jwt.Subject(sub) + e.Name = "foo" + e.TokenReq = true + e.ResponseType = v1jwt.ResponseTypeSingleton + return &e +} + +func createImport(t *testing.T, e *v1jwt.Export, target string, signer nkeys.KeyPair) *v1jwt.Import { + var i v1jwt.Import + i.Account = target + i.Subject = e.Subject + i.Type = e.Type + i.Name = e.Name + if e.TokenReq { + i.Token = createActivation(t, e, target, signer) + i.To = v1jwt.Subject(e.Name) + } + return &i +} + +func createActivation(t *testing.T, e *v1jwt.Export, target string, signer nkeys.KeyPair) string { + ac := v1jwt.NewActivationClaims(target) + ac.Name = e.Name + ac.ImportType = e.Type + s := strings.Replace(string(e.Subject), "*", target, -1) + ac.ImportSubject = v1jwt.Subject(s) + tok, err := ac.Encode(signer) + require.NoError(t, err) + return tok +} + +func TestMigrateOperator(t *testing.T) { + okp, err := nkeys.CreateOperator() + require.NoError(t, err) + + opk, err := okp.PublicKey() + require.NoError(t, err) + + oc := v1jwt.NewOperatorClaims(opk) + oc.Name = "O" + oc.Audience = "Audience" + + now := time.Now() + oc.NotBefore = now.Unix() + e := now.Add(time.Hour) + oc.ClaimsData.Expires = e.Unix() + + oc.Tags.Add("a") + + oc.OperatorServiceURLs.Add("nats://localhost:4222") + oc.AccountServerURL = "http://localhost:9090/jwt/v1" + + sk, err := nkeys.CreateOperator() + require.NoError(t, err) + psk, err := sk.PublicKey() + require.NoError(t, err) + oc.Operator.SigningKeys.Add(psk) + + oc.Identities = append(oc.Identities, v1jwt.Identity{ + ID: "O", + Proof: "http://www.o.com/o", + }) + + token, err := oc.Encode(okp) + require.NoError(t, err) + + c, err := Decode(token) + require.NoError(t, err) + oc2, ok := c.(*OperatorClaims) + require.True(t, ok) + + equalOperators(t, oc, oc2) +} + +func TestMigrateAccount(t *testing.T) { + okp, err := nkeys.CreateOperator() + require.NoError(t, err) + + akp, err := nkeys.CreateAccount() + require.NoError(t, err) + apk, err := akp.PublicKey() + require.NoError(t, err) + + ac := v1jwt.NewAccountClaims(apk) + ac.Name = "A" + ac.Audience = "Audience" + + now := time.Now() + ac.NotBefore = now.Unix() + e := now.Add(time.Hour) + ac.ClaimsData.Expires = e.Unix() + ac.Tags.Add("a") + + // create an import + ea, err := nkeys.CreateAccount() + require.NoError(t, err) + hex := createExport("help") + ac.Imports.Add(createImport(t, hex, apk, ea)) + + // add an export + ac.Exports = append(ac.Exports, createExport("q")) + + // add an identity + ac.Identities = append(ac.Identities, v1jwt.Identity{ + ID: "A", + Proof: "http://www.a.com/a", + }) + + // set the limits + ac.Limits.Subs = 1 + ac.Limits.Conn = 2 + ac.Limits.LeafNodeConn = 4 + ac.Limits.Imports = 8 + ac.Limits.Exports = 16 + ac.Limits.Data = 32 + ac.Limits.Payload = 64 + ac.Limits.WildcardExports = true + + // add a signing key + sk, err := nkeys.CreateAccount() + require.NoError(t, err) + psk, err := sk.PublicKey() + require.NoError(t, err) + ac.Account.SigningKeys.Add(psk) + + // add a revocation + ukp, err := nkeys.CreateUser() + require.NoError(t, err) + upk, err := ukp.PublicKey() + require.NoError(t, err) + ac.Revocations = make(map[string]int64) + ac.Revocations.Revoke(upk, time.Now()) + + token, err := ac.Encode(okp) + require.NoError(t, err) + + c, err := Decode(token) + require.NoError(t, err) + ac2, ok := c.(*AccountClaims) + require.True(t, ok) + equalAccounts(t, ac, ac2) +} + +func TestMigrateUser(t *testing.T) { + + ukp, err := nkeys.CreateUser() + require.NoError(t, err) + upk, err := ukp.PublicKey() + require.NoError(t, err) + + uc := v1jwt.NewUserClaims(upk) + uc.Name = "U" + uc.Audience = "Audience" + + now := time.Now() + uc.NotBefore = now.Unix() + e := now.Add(time.Hour) + uc.ClaimsData.Expires = e.Unix() + uc.Tags.Add("a") + + uc.Permissions.Sub.Allow.Add("q") + uc.Permissions.Sub.Deny.Add("d") + + uc.Permissions.Pub.Allow.Add("help") + uc.Permissions.Pub.Deny.Add("pleh") + + uc.Permissions.Resp = &v1jwt.ResponsePermission{} + uc.Permissions.Resp.MaxMsgs = 100 + uc.Permissions.Resp.Expires = time.Second + + uc.BearerToken = true + + akp, err := nkeys.CreateAccount() + require.NoError(t, err) + tok, err := uc.Encode(akp) + require.NoError(t, err) + + c, err := Decode(tok) + require.NoError(t, err) + uc2, ok := c.(*UserClaims) + require.True(t, ok) + + equalUsers(t, uc, uc2) +} + +func equalClaims(t *testing.T, o *v1jwt.ClaimsData, n *ClaimsData, gf *GenericFields) { + require.Equal(t, o.Subject, n.Subject) + require.Equal(t, o.Issuer, n.Issuer) + require.Equal(t, o.Name, n.Name) + require.Equal(t, o.Audience, n.Audience) + require.Equal(t, o.NotBefore, n.NotBefore) + require.Equal(t, o.Expires, n.Expires) + require.Equal(t, string(o.Type), string(gf.Type)) + require.EqualValues(t, o.Tags, gf.Tags) +} + +func equalOperators(t *testing.T, o *v1jwt.OperatorClaims, n *OperatorClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) + for _, v := range o.OperatorServiceURLs { + require.Contains(t, n.Operator.OperatorServiceURLs, v) + } + for _, v := range o.SigningKeys { + require.Contains(t, n.Operator.SigningKeys, v) + } + + require.Equal(t, o.Identities[0].ID, n.Operator.Identities[0].ID) + require.Equal(t, o.Identities[0].Proof, n.Operator.Identities[0].Proof) +} + +func equalAccounts(t *testing.T, o *v1jwt.AccountClaims, n *AccountClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) + equalImports(t, o.Imports[0], n.Imports[0]) + equalExports(t, o.Exports[0], n.Exports[0]) + require.Equal(t, o.Identities[0].ID, n.Account.Identities[0].ID) + require.Equal(t, o.Identities[0].Proof, n.Account.Identities[0].Proof) + equalLimits(t, &o.Account.Limits, &n.Account.Limits) + for _, v := range o.SigningKeys { + require.Contains(t, n.Account.SigningKeys, v) + } +} + +func equalUsers(t *testing.T, o *v1jwt.UserClaims, n *UserClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) + for _, v := range o.Sub.Allow { + require.True(t, n.Sub.Allow.Contains(v)) + } + for _, v := range o.Pub.Allow { + require.True(t, n.Pub.Allow.Contains(v)) + } + for _, v := range o.Sub.Deny { + require.True(t, n.Sub.Deny.Contains(v)) + } + for _, v := range o.Pub.Deny { + require.True(t, n.Pub.Deny.Contains(v)) + } + if o.User.Resp == nil { + require.Nil(t, n.User.Resp) + } else { + require.Equal(t, o.User.Resp.Expires, n.User.Resp.Expires) + require.Equal(t, o.User.Resp.MaxMsgs, n.User.Resp.MaxMsgs) + } + if o.IssuerAccount != "" { + require.Equal(t, o.IssuerAccount, n.User.IssuerAccount) + } + require.Equal(t, o.User.BearerToken, n.User.BearerToken) +} + +func equalExports(t *testing.T, o *v1jwt.Export, n *Export) { + require.Equal(t, o.Name, n.Name) + require.Equal(t, string(o.Subject), string(n.Subject)) + require.EqualValues(t, o.Type, n.Type) + require.Equal(t, o.TokenReq, n.TokenReq) + require.EqualValues(t, o.ResponseType, n.ResponseType) +} + +func equalImports(t *testing.T, o *v1jwt.Import, n *Import) { + require.Equal(t, o.Name, n.Name) + require.Equal(t, string(o.Subject), string(n.Subject)) + require.Equal(t, string(o.To), string(n.To)) + require.EqualValues(t, o.Type, n.Type) + + if o.Token != "" { + ot, err := v1jwt.DecodeActivationClaims(o.Token) + require.NoError(t, err) + nt, err := DecodeActivationClaims(n.Token) + require.NoError(t, err) + equalActivation(t, ot, nt) + } +} + +func equalActivation(t *testing.T, o *v1jwt.ActivationClaims, n *ActivationClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.Activation.GenericFields) + require.Equal(t, string(o.ImportSubject), string(n.ImportSubject)) + require.EqualValues(t, o.ImportType, n.ImportType) +} + +func equalLimits(t *testing.T, o *v1jwt.OperatorLimits, n *OperatorLimits) { + require.Equal(t, o.Subs, n.Subs) + require.Equal(t, o.Conn, n.Conn) + require.Equal(t, o.LeafNodeConn, n.LeafNodeConn) + require.Equal(t, o.Imports, n.Imports) + require.Equal(t, o.Exports, n.Exports) + require.Equal(t, o.Data, n.Data) + require.Equal(t, o.Payload, n.Payload) + require.Equal(t, o.WildcardExports, n.WildcardExports) +} diff --git a/v2/decoder_operator.go b/v2/decoder_operator.go new file mode 100644 index 0000000..74a2253 --- /dev/null +++ b/v2/decoder_operator.go @@ -0,0 +1,57 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +type v1NatsOperator struct { + Identities []Identity `json:"identity,omitempty"` + SigningKeys StringList `json:"signing_keys,omitempty"` + AccountServerURL string `json:"account_server_url,omitempty"` + OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` +} + +func loadOperator(data []byte, version int) (*OperatorClaims, error) { + switch version { + case 1: + var v1a v1OperatorClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a OperatorClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +type v1OperatorClaims struct { + ClaimsData + v1ClaimsDataDeletedFields + v1NatsOperator `json:"nats,omitempty"` +} + +func (oa v1OperatorClaims) Migrate() (*OperatorClaims, error) { + return oa.migrateV1() +} + +func (oa v1OperatorClaims) migrateV1() (*OperatorClaims, error) { + var a OperatorClaims + // copy the base claim + a.ClaimsData = oa.ClaimsData + // move the moved fields + a.Operator.Type = oa.v1ClaimsDataDeletedFields.Type + a.Operator.Tags = oa.v1ClaimsDataDeletedFields.Tags + // copy the account data + a.Operator.Identities = oa.v1NatsOperator.Identities + a.Operator.SigningKeys = oa.v1NatsOperator.SigningKeys + a.Operator.AccountServerURL = oa.v1NatsOperator.AccountServerURL + a.Operator.OperatorServiceURLs = oa.v1NatsOperator.OperatorServiceURLs + return &a, nil +} diff --git a/v2/decoder_test.go b/v2/decoder_test.go index 9206d18..0fa0279 100644 --- a/v2/decoder_test.go +++ b/v2/decoder_test.go @@ -290,7 +290,7 @@ func TestSample(t *testing.T) { t.Fatalf("unable to read public key: %v", err) } - if c.Issuer != string(pk) { + if c.Issuer != pk { t.Fatalf("the public key is not trusted") } } diff --git a/v2/decoder_user.go b/v2/decoder_user.go new file mode 100644 index 0000000..0b8a533 --- /dev/null +++ b/v2/decoder_user.go @@ -0,0 +1,61 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +type v1User struct { + Permissions + Limits + BearerToken bool `json:"bearer_token,omitempty"` +} + +type v1UserClaimsDataDeletedFields struct { + v1ClaimsDataDeletedFields + IssuerAccount string `json:"issuer_account,omitempty"` +} + +type v1UserClaims struct { + ClaimsData + v1UserClaimsDataDeletedFields + v1User `json:"nats,omitempty"` +} + +func loadUser(data []byte, version int) (*UserClaims, error) { + switch version { + case 1: + var v1a v1UserClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a UserClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +func (oa v1UserClaims) Migrate() (*UserClaims, error) { + return oa.migrateV1() +} + +func (oa v1UserClaims) migrateV1() (*UserClaims, error) { + var u UserClaims + // copy the base claim + u.ClaimsData = oa.ClaimsData + // move the moved fields + u.User.Type = oa.v1ClaimsDataDeletedFields.Type + u.User.Tags = oa.v1ClaimsDataDeletedFields.Tags + u.User.IssuerAccount = oa.IssuerAccount + // copy the user data + u.User.Permissions = oa.v1User.Permissions + u.User.Limits = oa.v1User.Limits + u.User.BearerToken = oa.v1User.BearerToken + return &u, nil +} diff --git a/v2/exports.go b/v2/exports.go index 5578f98..f1f1bea 100644 --- a/v2/exports.go +++ b/v2/exports.go @@ -91,7 +91,7 @@ func (e *Export) IsStream() bool { } // IsSingleResponse returns true if an export has a single response -// or no resopnse type is set, also checks that the type is service +// or no response type is set, also checks that the type is service func (e *Export) IsSingleResponse() bool { return e.Type == Service && (e.ResponseType == ResponseTypeSingleton || e.ResponseType == "") } diff --git a/v2/exports_test.go b/v2/exports_test.go index b674c90..fdd7ed0 100644 --- a/v2/exports_test.go +++ b/v2/exports_test.go @@ -280,7 +280,8 @@ func TestExport_Sorting(t *testing.T) { exports.Add(&Export{Subject: "x", Type: Service}) exports.Add(&Export{Subject: "z", Type: Service}) exports.Add(&Export{Subject: "y", Type: Service}) - if exports[0].Subject != "x" { + + if exports[0] == nil || exports[0].Subject != "x" { t.Fatal("added export not in expected order") } sort.Sort(exports) diff --git a/v2/genericclaims_test.go b/v2/genericclaims_test.go index fed632a..797481d 100644 --- a/v2/genericclaims_test.go +++ b/v2/genericclaims_test.go @@ -18,43 +18,37 @@ package jwt import ( "testing" "time" + + "github.com/stretchr/testify/require" ) func TestNewGenericClaims(t *testing.T) { akp := createAccountNKey(t) apk := publicKey(akp, t) - uc := NewGenericClaims(apk) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() - uc.Name = "alberto" - uc.Audience = "everyone" - uc.NotBefore = time.Now().UTC().Unix() - uc.Tags.Add("one") - uc.Tags.Add("one") - uc.Tags.Add("one") - uc.Tags.Add("TWO") // should become lower case - uc.Tags.Add("three") + gc := NewGenericClaims(apk) + gc.Expires = time.Now().Add(time.Hour).UTC().Unix() + gc.Name = "alberto" + gc.Audience = "everyone" + gc.NotBefore = time.Now().UTC().Unix() + gc.Data["test"] = true - uJwt := encode(uc, akp, t) + gcJwt := encode(gc, akp, t) - uc2, err := DecodeGeneric(uJwt) + uc2, err := DecodeGeneric(gcJwt) if err != nil { t.Fatal("failed to decode", err) } - AssertEquals(uc.String(), uc2.String(), t) - AssertEquals(uc.Name, uc2.Name, t) - AssertEquals(uc.Audience, uc2.Audience, t) - AssertEquals(uc.Expires, uc2.Expires, t) - AssertEquals(uc.NotBefore, uc2.NotBefore, t) - AssertEquals(uc.Subject, uc2.Subject, t) - - AssertEquals(3, len(uc2.Tags), t) - AssertEquals(true, uc2.Tags.Contains("two"), t) - AssertEquals("one", uc2.Tags[0], t) - AssertEquals("two", uc2.Tags[1], t) - AssertEquals("three", uc2.Tags[2], t) - - AssertEquals(uc.Claims() != nil, true, t) - AssertEquals(uc.Payload() != nil, true, t) + require.Equal(t, gc.String(), uc2.String()) + require.Equal(t, gc.Name, uc2.Name) + require.Equal(t, gc.Audience, uc2.Audience) + require.Equal(t, gc.Expires, uc2.Expires) + require.Equal(t, gc.NotBefore, uc2.NotBefore) + require.Equal(t, gc.Subject, uc2.Subject) + require.Contains(t, gc.Data, "test") + require.Equal(t, gc.Data["test"], true) + + AssertEquals(gc.Claims() != nil, true, t) + AssertEquals(gc.Payload() != nil, true, t) } diff --git a/v2/genericlaims.go b/v2/genericlaims.go index 94cd86e..230a38f 100644 --- a/v2/genericlaims.go +++ b/v2/genericlaims.go @@ -15,7 +15,13 @@ package jwt -import "github.com/nats-io/nkeys" +import ( + "encoding/json" + "errors" + "strings" + + "github.com/nats-io/nkeys" +) // GenericClaims can be used to read a JWT as a map for any non-generic fields type GenericClaims struct { @@ -36,11 +42,37 @@ func NewGenericClaims(subject string) *GenericClaims { // DecodeGeneric takes a JWT string and decodes it into a ClaimsData and map func DecodeGeneric(token string) (*GenericClaims, error) { - v := GenericClaims{} - if err := Decode(token, &v); err != nil { + // must have 3 chunks + chunks := strings.Split(token, ".") + if len(chunks) != 3 { + return nil, errors.New("expected 3 chunks") + } + + // header + if _, err := parseHeaders(chunks[0]); err != nil { + return nil, err + } + // claim + data, err := decodeString(chunks[1]) + if err != nil { + return nil, err + } + + var gc GenericClaims + if err := json.Unmarshal(data, &gc); err != nil { + return nil, err + } + + // sig + sig, err := decodeString(chunks[2]) + if err != nil { return nil, err } - return &v, nil + if !gc.Verify(chunks[1], sig) { + return nil, errors.New("claim failed signature verification") + } + + return &gc, nil } // Claims returns the standard part of the generic claim @@ -71,3 +103,31 @@ func (gc *GenericClaims) String() string { func (gc *GenericClaims) ExpectedPrefixes() []nkeys.PrefixByte { return nil } + +func (gc *GenericClaims) ClaimType() ClaimType { + v, ok := gc.Data["type"] + if !ok { + v, ok = gc.Data["nats"] + if ok { + m, ok := v.(map[string]interface{}) + if ok { + v = m["type"] + } + } + } + ct, ctok := v.(ClaimType) + if ctok { + return ct + } + return "" +} + +func (gc *GenericClaims) updateVersion() { + v, ok := gc.Data["nats"] + if ok { + m, ok := v.(map[string]interface{}) + if ok { + m["version"] = libVersion + } + } +} diff --git a/v2/go.mod b/v2/go.mod index 778d12c..b0357f0 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -1,5 +1,11 @@ -module github.com/nats-io/jwt +module github.com/nats-io/jwt/v2 -require github.com/nats-io/nkeys v0.1.3 +require ( + github.com/nats-io/jwt v0.3.2 + github.com/nats-io/nkeys v0.1.3 + github.com/stretchr/testify v1.4.0 +) + +replace github.com/nats-io/jwt v0.3.2 => ../ go 1.13 diff --git a/v2/go.sum b/v2/go.sum index 9baf67f..0d26dad 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -1,5 +1,13 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +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 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -7,3 +15,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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= +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.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/v2/imports.go b/v2/imports.go index 8cd9747..de99342 100644 --- a/v2/imports.go +++ b/v2/imports.go @@ -74,9 +74,9 @@ func (i *Import) Validate(actPubKey string, vr *ValidationResults) { if i.Token != "" { // Check to see if its an embedded JWT or a URL. - if url, err := url.Parse(i.Token); err == nil && url.Scheme != "" { + if u, err := url.Parse(i.Token); err == nil && u.Scheme != "" { c := &http.Client{Timeout: 5 * time.Second} - resp, err := c.Get(url.String()) + resp, err := c.Get(u.String()) if err != nil { vr.AddWarning("import %s contains an unreachable token URL %q", i.Subject, i.Token) } @@ -89,7 +89,7 @@ func (i *Import) Validate(actPubKey string, vr *ValidationResults) { } else { act, err = DecodeActivationClaims(string(body)) if err != nil { - vr.AddWarning("import %s contains a url %q with an invalid activation token", i.Subject, i.Token) + vr.AddWarning("import %s contains a URL %q with an invalid activation token", i.Subject, i.Token) } } } diff --git a/v2/imports_test.go b/v2/imports_test.go index 4405d7b..3362cb3 100644 --- a/v2/imports_test.go +++ b/v2/imports_test.go @@ -56,7 +56,7 @@ func TestImportValidation(t *testing.T) { activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream @@ -154,7 +154,7 @@ func TestInvalidImportTokenValuesValidation(t *testing.T) { activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream @@ -264,7 +264,7 @@ func TestTokenURLImportValidation(t *testing.T) { activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream @@ -324,7 +324,7 @@ func TestImportSubjectValidation(t *testing.T) { akp := publicKey(ak, t) activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "one.*" activation.ImportType = Stream diff --git a/v2/operator_claims.go b/v2/operator_claims.go index 6a99597..fba7ca4 100644 --- a/v2/operator_claims.go +++ b/v2/operator_claims.go @@ -40,6 +40,7 @@ type Operator struct { // A list of NATS urls (tls://host:port) where tools can connect to the server // using proper credentials. OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` + GenericFields } // Validate checks the validity of the operators contents @@ -112,15 +113,15 @@ func ValidateOperatorServiceURL(v string) error { } func (o *Operator) validateOperatorServiceURLs() []error { - var errors []error + var errs []error for _, v := range o.OperatorServiceURLs { if v != "" { if err := ValidateOperatorServiceURL(v); err != nil { - errors = append(errors, err) + errs = append(errs, err) } } } - return errors + return errs } // OperatorClaims define the data for an operator JWT @@ -151,11 +152,6 @@ func (oc *OperatorClaims) DidSign(op Claims) bool { return oc.SigningKeys.Contains(issuer) } -// Deprecated: AddSigningKey, use claim.SigningKeys.Add() -func (oc *OperatorClaims) AddSigningKey(pk string) { - oc.SigningKeys.Add(pk) -} - // Encode the claims into a JWT string func (oc *OperatorClaims) Encode(pair nkeys.KeyPair) (string, error) { if !nkeys.IsValidPublicOperatorKey(oc.Subject) { @@ -165,17 +161,25 @@ func (oc *OperatorClaims) Encode(pair nkeys.KeyPair) (string, error) { if err != nil { return "", err } - oc.ClaimsData.Type = OperatorClaim + oc.Type = OperatorClaim return oc.ClaimsData.Encode(pair, oc) } +func (oc *OperatorClaims) ClaimType() ClaimType { + return oc.Type +} + // DecodeOperatorClaims tries to create an operator claims from a JWt string func DecodeOperatorClaims(token string) (*OperatorClaims, error) { - v := OperatorClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + oc, ok := claims.(*OperatorClaims) + if !ok { + return nil, errors.New("not operator claim") + } + return oc, nil } func (oc *OperatorClaims) String() string { @@ -202,3 +206,7 @@ func (oc *OperatorClaims) ExpectedPrefixes() []nkeys.PrefixByte { func (oc *OperatorClaims) Claims() *ClaimsData { return &oc.ClaimsData } + +func (oc *OperatorClaims) updateVersion() { + oc.GenericFields.Version = libVersion +} diff --git a/v2/operator_claims_test.go b/v2/operator_claims_test.go index 73cae23..e3a7655 100644 --- a/v2/operator_claims_test.go +++ b/v2/operator_claims_test.go @@ -20,6 +20,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/nats-io/nkeys" ) @@ -27,7 +29,7 @@ func TestNewOperatorClaims(t *testing.T) { ckp := createOperatorNKey(t) uc := NewOperatorClaims(publicKey(ckp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.Expires = time.Now().Add(time.Hour).Unix() uJwt := encode(uc, ckp, t) uc2, err := DecodeOperatorClaims(uJwt) @@ -72,7 +74,7 @@ func TestOperatorSubjects(t *testing.T) { func TestInvalidOperatorClaimIssuer(t *testing.T) { akp := createOperatorNKey(t) ac := NewOperatorClaims(publicKey(akp, t)) - ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + ac.Expires = time.Now().Add(time.Hour).Unix() aJwt := encode(ac, akp, t) temp, err := DecodeGeneric(aJwt) @@ -133,8 +135,8 @@ func TestSigningKeyValidation(t *testing.T) { ckp2 := createOperatorNKey(t) uc := NewOperatorClaims(publicKey(ckp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uc.AddSigningKey(publicKey(ckp2, t)) + uc.Expires = time.Now().Add(time.Hour).Unix() + uc.SigningKeys.Add(publicKey(ckp2, t)) uJwt := encode(uc, ckp, t) uc2, err := DecodeOperatorClaims(uJwt) @@ -152,7 +154,7 @@ func TestSigningKeyValidation(t *testing.T) { t.Fatal("valid operator key should have no validation issues") } - uc.AddSigningKey("") // add an invalid one + uc.SigningKeys.Add("") // add an invalid one vr = &ValidationResults{} uc.Validate(vr) @@ -193,22 +195,8 @@ func TestSignedBy(t *testing.T) { AssertEquals(uc.DidSign(ac), false, t) // no signing key AssertEquals(uc2.DidSign(ac), true, t) // actual key - uc.AddSigningKey(publicKey(ckp2, t)) + uc.SigningKeys.Add(publicKey(ckp2, t)) AssertEquals(uc.DidSign(ac), true, t) // signing key - - clusterKey := createClusterNKey(t) - clusterClaims := NewClusterClaims(publicKey(clusterKey, t)) - enc, err = clusterClaims.Encode(ckp2) // sign with the operator key - if err != nil { - t.Fatal("failed to encode", err) - } - clusterClaims, err = DecodeClusterClaims(enc) - if err != nil { - t.Fatal("failed to decode", err) - } - - AssertEquals(uc.DidSign(clusterClaims), true, t) // signing key - AssertEquals(uc2.DidSign(clusterClaims), true, t) // actual key } func testAccountWithAccountServerURL(t *testing.T, u string) error { @@ -352,3 +340,23 @@ func Test_OperatorServiceURL(t *testing.T) { errs := vr.Errors() AssertEquals(len(errs), shouldFail, t) } + +func TestTags(t *testing.T) { + okp := createOperatorNKey(t) + opk := publicKey(okp, t) + + oc := NewOperatorClaims(opk) + oc.Tags.Add("one") + oc.Tags.Add("one") // duplicated tags should be ignored + oc.Tags.Add("TWO") // should become lower case + oc.Tags.Add("three") + + oJwt := encode(oc, okp, t) + + oc2, err := DecodeOperatorClaims(oJwt) + require.NoError(t, err) + require.Len(t, oc2.GenericFields.Tags, 3) + require.Contains(t, oc.GenericFields.Tags, "one") + require.Contains(t, oc.GenericFields.Tags, "two") + require.Contains(t, oc.GenericFields.Tags, "three") +} diff --git a/v2/server_claims.go b/v2/server_claims.go deleted file mode 100644 index c18f167..0000000 --- a/v2/server_claims.go +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "errors" - - "github.com/nats-io/nkeys" -) - -// Server defines the custom part of a server jwt -type Server struct { - Permissions - Cluster string `json:"cluster,omitempty"` -} - -// Validate checks the cluster and permissions for a server JWT -func (s *Server) Validate(vr *ValidationResults) { - if s.Cluster == "" { - vr.AddError("servers can't contain an empty cluster") - } -} - -// ServerClaims defines the data in a server JWT -type ServerClaims struct { - ClaimsData - Server `json:"nats,omitempty"` -} - -// NewServerClaims creates a new server JWT with the specified subject/public key -func NewServerClaims(subject string) *ServerClaims { - if subject == "" { - return nil - } - c := &ServerClaims{} - c.Subject = subject - return c -} - -// Encode tries to turn the server claims into a JWT string -func (s *ServerClaims) Encode(pair nkeys.KeyPair) (string, error) { - if !nkeys.IsValidPublicServerKey(s.Subject) { - return "", errors.New("expected subject to be a server public key") - } - s.ClaimsData.Type = ServerClaim - return s.ClaimsData.Encode(pair, s) -} - -// DecodeServerClaims tries to parse server claims from a JWT string -func DecodeServerClaims(token string) (*ServerClaims, error) { - v := ServerClaims{} - if err := Decode(token, &v); err != nil { - return nil, err - } - return &v, nil -} - -func (s *ServerClaims) String() string { - return s.ClaimsData.String(s) -} - -// Payload returns the server specific data -func (s *ServerClaims) Payload() interface{} { - return &s.Server -} - -// Validate checks the generic and server data in the server claims -func (s *ServerClaims) Validate(vr *ValidationResults) { - s.ClaimsData.Validate(vr) - s.Server.Validate(vr) -} - -// ExpectedPrefixes defines the types that can encode a server JWT, operator or cluster -func (s *ServerClaims) ExpectedPrefixes() []nkeys.PrefixByte { - return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} -} - -// Claims returns the generic data -func (s *ServerClaims) Claims() *ClaimsData { - return &s.ClaimsData -} diff --git a/v2/server_claims_test.go b/v2/server_claims_test.go deleted file mode 100644 index 70fc3d5..0000000 --- a/v2/server_claims_test.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "fmt" - "testing" - "time" - - "github.com/nats-io/nkeys" -) - -func TestNewServerClaims(t *testing.T) { - ckp := createClusterNKey(t) - skp := createServerNKey(t) - - uc := NewServerClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - uc2, err := DecodeServerClaims(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - AssertEquals(uc.String(), uc2.String(), t) - - AssertEquals(uc.Claims() != nil, true, t) - AssertEquals(uc.Payload() != nil, true, t) -} - -func TestServerClaimsIssuer(t *testing.T) { - ckp := createClusterNKey(t) - skp := createServerNKey(t) - - uc := NewServerClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - temp, err := DecodeGeneric(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"user", createUserNKey(t), false}, - {"operator", createOperatorNKey(t), true}, - {"server", createServerNKey(t), false}, - {"cluster", createClusterNKey(t), true}, - } - - for _, i := range inputs { - bad := encode(temp, i.kp, t) - _, err = DecodeServerClaims(bad) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to decode server signed by %q", i.name) - t.Fail() - } - } -} - -func TestServerSubjects(t *testing.T) { - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"cluster", createClusterNKey(t), false}, - {"operator", createOperatorNKey(t), false}, - {"server", createServerNKey(t), true}, - {"user", createUserNKey(t), false}, - } - - for _, i := range inputs { - c := NewServerClaims(publicKey(i.kp, t)) - _, err := c.Encode(createOperatorNKey(t)) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to encode server with with %q subject", i.name) - t.Fail() - } - } -} - -func TestNewNilServerClaims(t *testing.T) { - v := NewServerClaims("") - if v != nil { - t.Fatal("expected nil user claim") - } -} - -func TestServerType(t *testing.T) { - c := NewServerClaims(publicKey(createServerNKey(t), t)) - s := encode(c, createClusterNKey(t), t) - u, err := DecodeServerClaims(s) - if err != nil { - t.Fatalf("failed to decode server claim: %v", err) - } - - if ServerClaim != u.Type { - t.Fatalf("type is unexpected %q (wanted server)", u.Type) - } - -} diff --git a/v2/types.go b/v2/types.go index a1f09fd..53a66c4 100644 --- a/v2/types.go +++ b/v2/types.go @@ -228,7 +228,7 @@ type ResponsePermission struct { } // Validate the response permission. -func (p *ResponsePermission) Validate(vr *ValidationResults) { +func (p *ResponsePermission) Validate(_ *ValidationResults) { // Any values can be valid for now. } @@ -329,6 +329,6 @@ type Identity struct { } // Validate checks the values in an Identity -func (u *Identity) Validate(vr *ValidationResults) { +func (u *Identity) Validate(_ *ValidationResults) { //Fixme identity validation } diff --git a/v2/user_claims.go b/v2/user_claims.go index 78fe6a9..83b2156 100644 --- a/v2/user_claims.go +++ b/v2/user_claims.go @@ -26,6 +26,10 @@ type User struct { Permissions Limits BearerToken bool `json:"bearer_token,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` + GenericFields } // Validate checks the permissions and limits in a User jwt @@ -39,9 +43,6 @@ func (u *User) Validate(vr *ValidationResults) { type UserClaims struct { ClaimsData User `json:"nats,omitempty"` - // IssuerAccount stores the public key for the account the issuer represents. - // When set, the claim was issued by a signing key. - IssuerAccount string `json:"issuer_account,omitempty"` } // NewUserClaims creates a user JWT with the specific subject/public key @@ -59,17 +60,25 @@ func (u *UserClaims) Encode(pair nkeys.KeyPair) (string, error) { if !nkeys.IsValidPublicUserKey(u.Subject) { return "", errors.New("expected subject to be user public key") } - u.ClaimsData.Type = UserClaim + u.Type = UserClaim return u.ClaimsData.Encode(pair, u) } // DecodeUserClaims tries to parse a user claims from a JWT string func DecodeUserClaims(token string) (*UserClaims, error) { - v := UserClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + ac, ok := claims.(*UserClaims) + if !ok { + return nil, errors.New("not user claim") + } + return ac, nil +} + +func (u *UserClaims) ClaimType() ClaimType { + return u.Type } // Validate checks the generic and specific parts of the user jwt @@ -100,6 +109,10 @@ func (u *UserClaims) String() string { return u.ClaimsData.String(u) } +func (u *UserClaims) updateVersion() { + u.GenericFields.Version = libVersion +} + // IsBearerToken returns true if nonce-signing requirements should be skipped func (u *UserClaims) IsBearerToken() bool { return u.BearerToken diff --git a/v2/user_claims_test.go b/v2/user_claims_test.go index c9da7fe..349dec4 100644 --- a/v2/user_claims_test.go +++ b/v2/user_claims_test.go @@ -28,7 +28,7 @@ func TestNewUserClaims(t *testing.T) { ukp := createUserNKey(t) uc := NewUserClaims(publicKey(ukp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.Expires = time.Now().Add(time.Hour).Unix() uJwt := encode(uc, akp, t) uc2, err := DecodeUserClaims(uJwt) @@ -47,7 +47,7 @@ func TestUserClaimIssuer(t *testing.T) { ukp := createUserNKey(t) uc := NewUserClaims(publicKey(ukp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.Expires = time.Now().Add(time.Hour).Unix() uJwt := encode(uc, akp, t) temp, err := DecodeGeneric(uJwt) diff --git a/v2/util_test.go b/v2/util_test.go index 5ccddca..eda0ad9 100644 --- a/v2/util_test.go +++ b/v2/util_test.go @@ -93,7 +93,7 @@ func publicKey(kp nkeys.KeyPair, t *testing.T) string { if err != nil { t.Fatal("error reading public key", err) } - return string(pk) + return pk } func seedKey(kp nkeys.KeyPair, t *testing.T) []byte { diff --git a/v2/validation.go b/v2/validation.go index c87a992..4625efd 100644 --- a/v2/validation.go +++ b/v2/validation.go @@ -38,7 +38,7 @@ type ValidationResults struct { // CreateValidationResults creates an empty list of validation issues func CreateValidationResults() *ValidationResults { - issues := []*ValidationIssue{} + var issues []*ValidationIssue return &ValidationResults{ Issues: issues, } From deb09e7ef65fe4fb76aa6947cc3d7716cc51869a Mon Sep 17 00:00:00 2001 From: aricart Date: Fri, 21 Feb 2020 09:19:29 -0400 Subject: [PATCH 04/14] updated branch from master, propagated v1 changes to v2 --- v2/types.go | 2 -- v2/user_claims_test.go | 36 ------------------------------------ 2 files changed, 38 deletions(-) diff --git a/v2/types.go b/v2/types.go index 53a66c4..aa7dbed 100644 --- a/v2/types.go +++ b/v2/types.go @@ -241,8 +241,6 @@ type Permissions struct { // Validate the pub and sub fields in the permissions list func (p *Permissions) Validate(vr *ValidationResults) { - p.Pub.Validate(vr) - p.Sub.Validate(vr) if p.Resp != nil { p.Resp.Validate(vr) } diff --git a/v2/user_claims_test.go b/v2/user_claims_test.go index 349dec4..0ce715e 100644 --- a/v2/user_claims_test.go +++ b/v2/user_claims_test.go @@ -227,42 +227,6 @@ func TestUserValidation(t *testing.T) { if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { t.Error("bad limit should be invalid") } - - uc.Limits.Times = []TimeRange{} - uc.Permissions.Pub.Allow.Add("bad subject") - vr = CreateValidationResults() - uc.Validate(vr) - - if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { - t.Error("bad permission should be invalid") - } - - uc.Permissions.Pub.Allow.Remove("bad subject") - uc.Permissions.Sub.Allow.Add("bad subject") - vr = CreateValidationResults() - uc.Validate(vr) - - if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { - t.Error("bad permission should be invalid") - } - - uc.Permissions.Sub.Allow.Remove("bad subject") - uc.Permissions.Pub.Deny.Add("bad subject") - vr = CreateValidationResults() - uc.Validate(vr) - - if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { - t.Error("bad permission should be invalid") - } - - uc.Permissions.Pub.Deny.Remove("bad subject") - uc.Permissions.Sub.Deny.Add("bad subject") - vr = CreateValidationResults() - uc.Validate(vr) - - if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { - t.Error("bad permission should be invalid") - } } func TestUserAccountID(t *testing.T) { From 79b67ebebe7a3290c3242e484f1ee23d6f00cdf4 Mon Sep 17 00:00:00 2001 From: aricart Date: Fri, 21 Feb 2020 18:54:33 -0400 Subject: [PATCH 05/14] Merge change to master (v1) where the regex was relaxed. --- v2/Makefile | 2 +- v2/creds_utils.go | 2 +- v2/creds_utils_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/v2/Makefile b/v2/Makefile index c805857..53585ce 100644 --- a/v2/Makefile +++ b/v2/Makefile @@ -12,7 +12,7 @@ test: go vet ./... staticcheck ./... rm -rf ./coverage.out - go test -coverprofile=./coverage.out ./... + go test -v -coverprofile=./coverage.out ./... cover: go tool cover -html=coverage.out diff --git a/v2/creds_utils.go b/v2/creds_utils.go index 2d2cfd3..32e650d 100644 --- a/v2/creds_utils.go +++ b/v2/creds_utils.go @@ -82,7 +82,7 @@ NKEYs are sensitive and should be treated as secrets. return w.Bytes(), nil } -var userConfigRE = regexp.MustCompile(`\s*(?:(?:[-]{3,}[^\n]*[-]{3,}\n)(.+)(?:\n\s*[-]{3,}[^\n]*[-]{3,}\n))`) +var userConfigRE = regexp.MustCompile(`\s*(?:(?:[-]{3,}.*[-]{3,}\r?\n)([\w\-.=]+)(?:\r?\n[-]{3,}.*[-]{3,}\r?\n))`) // An user config file looks like this: // -----BEGIN NATS USER JWT----- diff --git a/v2/creds_utils_test.go b/v2/creds_utils_test.go index 5075274..436f379 100644 --- a/v2/creds_utils_test.go +++ b/v2/creds_utils_test.go @@ -209,3 +209,66 @@ func Test_DecorateNKeys(t *testing.T) { t.Fatal("required error parsing bad nkey") } } + +func Test_ParseCreds(t *testing.T) { + token, kp := makeJWT(t) + d, err := FormatUserConfig(token, seedKey(kp, t)) + if err != nil { + t.Fatal(err) + } + pk, err := kp.PublicKey() + if err != nil { + t.Fatal(err) + } + + token2, err := ParseDecoratedJWT(d) + if err != nil { + t.Fatal(err) + } + if token != token2 { + t.Fatal("expected jwts to match") + } + kp2, err := ParseDecoratedUserNKey(d) + if err != nil { + t.Fatal(err) + } + pk2, err := kp2.PublicKey() + if err != nil { + t.Fatal(err) + } + if pk != pk2 { + t.Fatal("expected keys to match") + } +} + +func Test_ParseCredsWithCrLfs(t *testing.T) { + token, kp := makeJWT(t) + d, err := FormatUserConfig(token, seedKey(kp, t)) + if err != nil { + t.Fatal(err) + } + pk, err := kp.PublicKey() + if err != nil { + t.Fatal(err) + } + d = bytes.ReplaceAll(d, []byte{'\n'}, []byte{'\r', '\n'}) + + token2, err := ParseDecoratedJWT(d) + if err != nil { + t.Fatal(err) + } + if token != token2 { + t.Fatal("expected jwts to match") + } + kp2, err := ParseDecoratedUserNKey(d) + if err != nil { + t.Fatal(err) + } + pk2, err := kp2.PublicKey() + if err != nil { + t.Fatal(err) + } + if pk != pk2 { + t.Fatal("expected keys to match") + } +} From db2142050d5039322ae8b7d6e038c5896a132af7 Mon Sep 17 00:00:00 2001 From: aricart Date: Tue, 4 Feb 2020 09:48:04 -0400 Subject: [PATCH 06/14] v2 move with history --- account_claims.go => v2/account_claims.go | 0 account_claims_test.go => v2/account_claims_test.go | 0 activation_claims.go => v2/activation_claims.go | 0 activation_claims_test.go => v2/activation_claims_test.go | 0 claims.go => v2/claims.go | 0 cluster_claims.go => v2/cluster_claims.go | 0 cluster_claims_test.go => v2/cluster_claims_test.go | 0 creds_utils.go => v2/creds_utils.go | 0 creds_utils_test.go => v2/creds_utils_test.go | 0 decoder_test.go => v2/decoder_test.go | 0 exports.go => v2/exports.go | 0 exports_test.go => v2/exports_test.go | 0 genericclaims_test.go => v2/genericclaims_test.go | 0 genericlaims.go => v2/genericlaims.go | 0 go.mod => v2/go.mod | 0 go.sum => v2/go.sum | 0 header.go => v2/header.go | 0 imports.go => v2/imports.go | 0 imports_test.go => v2/imports_test.go | 0 operator_claims.go => v2/operator_claims.go | 0 operator_claims_test.go => v2/operator_claims_test.go | 0 revocation_list.go => v2/revocation_list.go | 0 server_claims.go => v2/server_claims.go | 0 server_claims_test.go => v2/server_claims_test.go | 0 types.go => v2/types.go | 0 types_test.go => v2/types_test.go | 0 user_claims.go => v2/user_claims.go | 0 user_claims_test.go => v2/user_claims_test.go | 0 util_test.go => v2/util_test.go | 0 validation.go => v2/validation.go | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename account_claims.go => v2/account_claims.go (100%) rename account_claims_test.go => v2/account_claims_test.go (100%) rename activation_claims.go => v2/activation_claims.go (100%) rename activation_claims_test.go => v2/activation_claims_test.go (100%) rename claims.go => v2/claims.go (100%) rename cluster_claims.go => v2/cluster_claims.go (100%) rename cluster_claims_test.go => v2/cluster_claims_test.go (100%) rename creds_utils.go => v2/creds_utils.go (100%) rename creds_utils_test.go => v2/creds_utils_test.go (100%) rename decoder_test.go => v2/decoder_test.go (100%) rename exports.go => v2/exports.go (100%) rename exports_test.go => v2/exports_test.go (100%) rename genericclaims_test.go => v2/genericclaims_test.go (100%) rename genericlaims.go => v2/genericlaims.go (100%) rename go.mod => v2/go.mod (100%) rename go.sum => v2/go.sum (100%) rename header.go => v2/header.go (100%) rename imports.go => v2/imports.go (100%) rename imports_test.go => v2/imports_test.go (100%) rename operator_claims.go => v2/operator_claims.go (100%) rename operator_claims_test.go => v2/operator_claims_test.go (100%) rename revocation_list.go => v2/revocation_list.go (100%) rename server_claims.go => v2/server_claims.go (100%) rename server_claims_test.go => v2/server_claims_test.go (100%) rename types.go => v2/types.go (100%) rename types_test.go => v2/types_test.go (100%) rename user_claims.go => v2/user_claims.go (100%) rename user_claims_test.go => v2/user_claims_test.go (100%) rename util_test.go => v2/util_test.go (100%) rename validation.go => v2/validation.go (100%) diff --git a/account_claims.go b/v2/account_claims.go similarity index 100% rename from account_claims.go rename to v2/account_claims.go diff --git a/account_claims_test.go b/v2/account_claims_test.go similarity index 100% rename from account_claims_test.go rename to v2/account_claims_test.go diff --git a/activation_claims.go b/v2/activation_claims.go similarity index 100% rename from activation_claims.go rename to v2/activation_claims.go diff --git a/activation_claims_test.go b/v2/activation_claims_test.go similarity index 100% rename from activation_claims_test.go rename to v2/activation_claims_test.go diff --git a/claims.go b/v2/claims.go similarity index 100% rename from claims.go rename to v2/claims.go diff --git a/cluster_claims.go b/v2/cluster_claims.go similarity index 100% rename from cluster_claims.go rename to v2/cluster_claims.go diff --git a/cluster_claims_test.go b/v2/cluster_claims_test.go similarity index 100% rename from cluster_claims_test.go rename to v2/cluster_claims_test.go diff --git a/creds_utils.go b/v2/creds_utils.go similarity index 100% rename from creds_utils.go rename to v2/creds_utils.go diff --git a/creds_utils_test.go b/v2/creds_utils_test.go similarity index 100% rename from creds_utils_test.go rename to v2/creds_utils_test.go diff --git a/decoder_test.go b/v2/decoder_test.go similarity index 100% rename from decoder_test.go rename to v2/decoder_test.go diff --git a/exports.go b/v2/exports.go similarity index 100% rename from exports.go rename to v2/exports.go diff --git a/exports_test.go b/v2/exports_test.go similarity index 100% rename from exports_test.go rename to v2/exports_test.go diff --git a/genericclaims_test.go b/v2/genericclaims_test.go similarity index 100% rename from genericclaims_test.go rename to v2/genericclaims_test.go diff --git a/genericlaims.go b/v2/genericlaims.go similarity index 100% rename from genericlaims.go rename to v2/genericlaims.go diff --git a/go.mod b/v2/go.mod similarity index 100% rename from go.mod rename to v2/go.mod diff --git a/go.sum b/v2/go.sum similarity index 100% rename from go.sum rename to v2/go.sum diff --git a/header.go b/v2/header.go similarity index 100% rename from header.go rename to v2/header.go diff --git a/imports.go b/v2/imports.go similarity index 100% rename from imports.go rename to v2/imports.go diff --git a/imports_test.go b/v2/imports_test.go similarity index 100% rename from imports_test.go rename to v2/imports_test.go diff --git a/operator_claims.go b/v2/operator_claims.go similarity index 100% rename from operator_claims.go rename to v2/operator_claims.go diff --git a/operator_claims_test.go b/v2/operator_claims_test.go similarity index 100% rename from operator_claims_test.go rename to v2/operator_claims_test.go diff --git a/revocation_list.go b/v2/revocation_list.go similarity index 100% rename from revocation_list.go rename to v2/revocation_list.go diff --git a/server_claims.go b/v2/server_claims.go similarity index 100% rename from server_claims.go rename to v2/server_claims.go diff --git a/server_claims_test.go b/v2/server_claims_test.go similarity index 100% rename from server_claims_test.go rename to v2/server_claims_test.go diff --git a/types.go b/v2/types.go similarity index 100% rename from types.go rename to v2/types.go diff --git a/types_test.go b/v2/types_test.go similarity index 100% rename from types_test.go rename to v2/types_test.go diff --git a/user_claims.go b/v2/user_claims.go similarity index 100% rename from user_claims.go rename to v2/user_claims.go diff --git a/user_claims_test.go b/v2/user_claims_test.go similarity index 100% rename from user_claims_test.go rename to v2/user_claims_test.go diff --git a/util_test.go b/v2/util_test.go similarity index 100% rename from util_test.go rename to v2/util_test.go diff --git a/validation.go b/v2/validation.go similarity index 100% rename from validation.go rename to v2/validation.go From b8765bc42b047386e4ba3dc96f60d543dd82685b Mon Sep 17 00:00:00 2001 From: aricart Date: Tue, 4 Feb 2020 09:56:23 -0400 Subject: [PATCH 07/14] restored original files to preserve history --- account_claims.go | 222 ++++++++++++++++ account_claims_test.go | 535 ++++++++++++++++++++++++++++++++++++++ activation_claims.go | 166 ++++++++++++ activation_claims_test.go | 426 ++++++++++++++++++++++++++++++ claims.go | 302 +++++++++++++++++++++ cluster_claims.go | 94 +++++++ cluster_claims_test.go | 132 ++++++++++ creds_utils.go | 203 +++++++++++++++ creds_utils_test.go | 211 +++++++++++++++ decoder_test.go | 404 ++++++++++++++++++++++++++++ exports.go | 236 +++++++++++++++++ exports_test.go | 290 +++++++++++++++++++++ genericclaims_test.go | 60 +++++ genericlaims.go | 73 ++++++ go.mod | 5 + go.sum | 9 + header.go | 71 +++++ imports.go | 151 +++++++++++ imports_test.go | 410 +++++++++++++++++++++++++++++ operator_claims.go | 204 +++++++++++++++ operator_claims_test.go | 354 +++++++++++++++++++++++++ revocation_list.go | 32 +++ server_claims.go | 94 +++++++ server_claims_test.go | 132 ++++++++++ types.go | 334 ++++++++++++++++++++++++ types_test.go | 266 +++++++++++++++++++ user_claims.go | 106 ++++++++ user_claims_test.go | 381 +++++++++++++++++++++++++++ util_test.go | 113 ++++++++ validation.go | 107 ++++++++ 30 files changed, 6123 insertions(+) create mode 100644 account_claims.go create mode 100644 account_claims_test.go create mode 100644 activation_claims.go create mode 100644 activation_claims_test.go create mode 100644 claims.go create mode 100644 cluster_claims.go create mode 100644 cluster_claims_test.go create mode 100644 creds_utils.go create mode 100644 creds_utils_test.go create mode 100644 decoder_test.go create mode 100644 exports.go create mode 100644 exports_test.go create mode 100644 genericclaims_test.go create mode 100644 genericlaims.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 header.go create mode 100644 imports.go create mode 100644 imports_test.go create mode 100644 operator_claims.go create mode 100644 operator_claims_test.go create mode 100644 revocation_list.go create mode 100644 server_claims.go create mode 100644 server_claims_test.go create mode 100644 types.go create mode 100644 types_test.go create mode 100644 user_claims.go create mode 100644 user_claims_test.go create mode 100644 util_test.go create mode 100644 validation.go diff --git a/account_claims.go b/account_claims.go new file mode 100644 index 0000000..945bd98 --- /dev/null +++ b/account_claims.go @@ -0,0 +1,222 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "sort" + "time" + + "github.com/nats-io/nkeys" +) + +// NoLimit is used to indicate a limit field is unlimited in value. +const NoLimit = -1 + +// OperatorLimits are used to limit access by an account +type OperatorLimits struct { + Subs int64 `json:"subs,omitempty"` // Max number of subscriptions + Conn int64 `json:"conn,omitempty"` // Max number of active connections + LeafNodeConn int64 `json:"leaf,omitempty"` // Max number of active leaf node connections + Imports int64 `json:"imports,omitempty"` // Max number of imports + Exports int64 `json:"exports,omitempty"` // Max number of exports + Data int64 `json:"data,omitempty"` // Max number of bytes + Payload int64 `json:"payload,omitempty"` // Max message payload + WildcardExports bool `json:"wildcards,omitempty"` // Are wildcards allowed in exports +} + +// IsEmpty returns true if all of the limits are 0/false. +func (o *OperatorLimits) IsEmpty() bool { + return *o == OperatorLimits{} +} + +// IsUnlimited returns true if all limits are +func (o *OperatorLimits) IsUnlimited() bool { + return *o == OperatorLimits{NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, true} +} + +// Validate checks that the operator limits contain valid values +func (o *OperatorLimits) Validate(vr *ValidationResults) { + // negative values mean unlimited, so all numbers are valid +} + +// Account holds account specific claims data +type Account struct { + Imports Imports `json:"imports,omitempty"` + Exports Exports `json:"exports,omitempty"` + Identities []Identity `json:"identity,omitempty"` + Limits OperatorLimits `json:"limits,omitempty"` + SigningKeys StringList `json:"signing_keys,omitempty"` + Revocations RevocationList `json:"revocations,omitempty"` +} + +// Validate checks if the account is valid, based on the wrapper +func (a *Account) Validate(acct *AccountClaims, vr *ValidationResults) { + a.Imports.Validate(acct.Subject, vr) + a.Exports.Validate(vr) + a.Limits.Validate(vr) + + for _, i := range a.Identities { + i.Validate(vr) + } + + if !a.Limits.IsEmpty() && a.Limits.Imports >= 0 && int64(len(a.Imports)) > a.Limits.Imports { + vr.AddError("the account contains more imports than allowed by the operator") + } + + // Check Imports and Exports for limit violations. + if a.Limits.Imports != NoLimit { + if int64(len(a.Imports)) > a.Limits.Imports { + vr.AddError("the account contains more imports than allowed by the operator") + } + } + if a.Limits.Exports != NoLimit { + if int64(len(a.Exports)) > a.Limits.Exports { + vr.AddError("the account contains more exports than allowed by the operator") + } + // Check for wildcard restrictions + if !a.Limits.WildcardExports { + for _, ex := range a.Exports { + if ex.Subject.HasWildCards() { + vr.AddError("the account contains wildcard exports that are not allowed by the operator") + } + } + } + } + + for _, k := range a.SigningKeys { + if !nkeys.IsValidPublicAccountKey(k) { + vr.AddError("%s is not an account public key", k) + } + } +} + +// AccountClaims defines the body of an account JWT +type AccountClaims struct { + ClaimsData + Account `json:"nats,omitempty"` +} + +// NewAccountClaims creates a new account JWT +func NewAccountClaims(subject string) *AccountClaims { + if subject == "" { + return nil + } + c := &AccountClaims{} + // Set to unlimited to start. We do it this way so we get compiler + // errors if we add to the OperatorLimits. + c.Limits = OperatorLimits{NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, NoLimit, true} + c.Subject = subject + return c +} + +// Encode converts account claims into a JWT string +func (a *AccountClaims) Encode(pair nkeys.KeyPair) (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.ClaimsData.Type = AccountClaim + return a.ClaimsData.Encode(pair, a) +} + +// DecodeAccountClaims decodes account claims from a JWT string +func DecodeAccountClaims(token string) (*AccountClaims, error) { + v := AccountClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (a *AccountClaims) String() string { + return a.ClaimsData.String(a) +} + +// Payload pulls the accounts specific payload out of the claims +func (a *AccountClaims) Payload() interface{} { + return &a.Account +} + +// Validate checks the accounts contents +func (a *AccountClaims) Validate(vr *ValidationResults) { + a.ClaimsData.Validate(vr) + a.Account.Validate(a, vr) + + if nkeys.IsValidPublicAccountKey(a.ClaimsData.Issuer) { + if len(a.Identities) > 0 { + vr.AddWarning("self-signed account JWTs shouldn't contain identity proofs") + } + if !a.Limits.IsEmpty() { + vr.AddWarning("self-signed account JWTs shouldn't contain operator limits") + } + } +} + +// ExpectedPrefixes defines the types that can encode an account jwt, account and operator +func (a *AccountClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} +} + +// Claims returns the accounts claims data +func (a *AccountClaims) Claims() *ClaimsData { + return &a.ClaimsData +} + +// DidSign checks the claims against the account's public key and its signing keys +func (a *AccountClaims) DidSign(op Claims) bool { + if op != nil { + issuer := op.Claims().Issuer + if issuer == a.Subject { + return true + } + return a.SigningKeys.Contains(issuer) + } + return false +} + +// Revoke enters a revocation by publickey using time.Now(). +func (a *AccountClaims) Revoke(pubKey string) { + a.RevokeAt(pubKey, time.Now()) +} + +// RevokeAt enters a revocation by publickey and timestamp into this export +// If there is already a revocation for this public key that is newer, it is kept. +func (a *AccountClaims) RevokeAt(pubKey string, timestamp time.Time) { + if a.Revocations == nil { + a.Revocations = RevocationList{} + } + + a.Revocations.Revoke(pubKey, timestamp) +} + +// ClearRevocation removes any revocation for the public key +func (a *AccountClaims) ClearRevocation(pubKey string) { + a.Revocations.ClearRevocation(pubKey) +} + +// IsRevokedAt checks if the public key is in the revoked list with a timestamp later than +// the one passed in. Generally this method is called with time.Now() but other time's can +// be used for testing. +func (a *AccountClaims) IsRevokedAt(pubKey string, timestamp time.Time) bool { + return a.Revocations.IsRevoked(pubKey, timestamp) +} + +// IsRevoked checks if the public key is in the revoked list with time.Now() +func (a *AccountClaims) IsRevoked(pubKey string) bool { + return a.Revocations.IsRevoked(pubKey, time.Now()) +} diff --git a/account_claims_test.go b/account_claims_test.go new file mode 100644 index 0000000..c9fe4a2 --- /dev/null +++ b/account_claims_test.go @@ -0,0 +1,535 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewAccountClaims(t *testing.T) { + akp := createAccountNKey(t) + akp2 := createAccountNKey(t) + apk := publicKey(akp, t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.ImportSubject = "test" + activation.ImportType = Stream + actJWT := encode(activation, akp2, t) + + account := NewAccountClaims(apk) + if !account.Limits.IsUnlimited() { + t.Fatalf("Expected unlimited operator limits") + } + + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).UTC().Unix() + + account.Imports = Imports{} + account.Imports.Add(&Import{Subject: "test", Name: "test import", Account: apk2, Token: actJWT, To: "my", Type: Stream}) + + vr := CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() { + t.Fatal("Valid account will have no validation results") + } + + actJwt := encode(account, akp, t) + + account2, err := DecodeAccountClaims(actJwt) + if err != nil { + t.Fatal("error decoding account jwt", err) + } + + AssertEquals(account.String(), account2.String(), t) + AssertEquals(account2.IsSelfSigned(), true, t) + + AssertEquals(account2.Claims() != nil, true, t) + AssertEquals(account2.Payload() != nil, true, t) +} + +func TestAccountCanSignOperatorLimits(t *testing.T) { // don't block encoding!!! + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 10 + account.Limits.LeafNodeConn = 2 + + _, err := account.Encode(akp) + if err != nil { + t.Fatal("account should not be able to encode operator limits", err) + } +} + +func TestAccountCanSignIdentities(t *testing.T) { // don't block encoding!!! + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + + _, err := account.Encode(akp) + if err != nil { + t.Fatal("account should not be able to encode identities", err) + } +} + +func TestOperatorCanSignClaims(t *testing.T) { + akp := createAccountNKey(t) + okp := createOperatorNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 1 + account.Limits.LeafNodeConn = 4 + + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + + actJwt := encode(account, okp, t) + + account2, err := DecodeAccountClaims(actJwt) + if err != nil { + t.Fatal("error decoding account jwt", err) + } + + AssertEquals(account.String(), account2.String(), t) + AssertEquals(account2.IsSelfSigned(), false, t) + + if account2.Limits.Conn != 1 { + t.Fatalf("Expected Limits.Conn == 1, got %d", account2.Limits.Conn) + } + if account2.Limits.LeafNodeConn != 4 { + t.Fatalf("Expected Limits.Conn == 4, got %d", account2.Limits.LeafNodeConn) + } +} + +func TestInvalidAccountClaimIssuer(t *testing.T) { + akp := createAccountNKey(t) + ac := NewAccountClaims(publicKey(akp, t)) + ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + aJwt := encode(ac, akp, t) + + temp, err := DecodeGeneric(aJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeAccountClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode account signed by %q", i.name) + t.Fail() + } + } +} + +func TestInvalidAccountSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + pk := publicKey(i.kp, t) + var err error + + c := NewAccountClaims(pk) + if i.ok && err != nil { + t.Fatalf("error encoding activation: %v", err) + } + _, err = c.Encode(i.kp) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode account with with %q subject", i.name) + t.Fail() + } + } +} + +func TestAccountImports(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + + actJwt := encode(account, akp, t) + + account2, err := DecodeAccountClaims(actJwt) + if err != nil { + t.Fatal("error decoding account jwt", err) + } + + AssertEquals(account.String(), account2.String(), t) +} + +func TestNewNilAccountClaim(t *testing.T) { + v := NewAccountClaims("") + if v != nil { + t.Fatal("expected nil account claim") + } +} + +func TestLimitValidationInAccount(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 10 + account.Limits.Imports = 10 + account.Limits.Exports = 10 + account.Limits.Data = 1024 + account.Limits.Payload = 1024 + account.Limits.Subs = 10 + account.Limits.WildcardExports = true + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + + vr := CreateValidationResults() + account.Validate(vr) + + if len(vr.Issues) != 0 { + t.Fatal("valid account should have no validation issues") + } + + account.Limits.Conn = -1 + account.Limits.Imports = -1 + account.Limits.Exports = -1 + account.Limits.Subs = -1 + account.Limits.Data = -1 + account.Limits.Payload = -1 + vr = CreateValidationResults() + account.Validate(vr) + + if len(vr.Issues) != 0 { + t.Fatal("valid account should have no validation issues") + } + + op := createOperatorNKey(t) + opk := publicKey(op, t) + account.Issuer = opk + + vr = CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("operator can encode limits and identity") + } + + account.Identities = nil + account.Issuer = apk + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("bad issuer for limits should have non-blocking validation results") + } + + account.Identities = []Identity{ + { + ID: "stephen", + Proof: "yougotit", + }, + } + account.Limits = OperatorLimits{} + account.Issuer = apk + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("bad issuer for identities should have non-blocking validation results") + } + + account.Identities = nil + account.Issuer = apk + vr = CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Fatal("account can encode without limits and identity") + } +} + +func TestWildcardExportLimit(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + account := NewAccountClaims(apk) + account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Limits.Conn = 10 + account.Limits.Imports = 10 + account.Limits.Exports = 10 + account.Limits.WildcardExports = true + account.Exports = Exports{ + &Export{Subject: "foo", Type: Stream}, + &Export{Subject: "bar.*", Type: Stream}, + } + + vr := CreateValidationResults() + account.Validate(vr) + + if !vr.IsEmpty() { + t.Fatal("valid account should have no validation issues") + } + + account.Limits.WildcardExports = false + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Fatal("invalid account should have validation issues") + } + + account.Limits.WildcardExports = true + account.Limits.Exports = 1 + vr = CreateValidationResults() + account.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Fatal("invalid account should have validation issues") + } +} + +func TestAccountSigningKeyValidation(t *testing.T) { + okp := createOperatorNKey(t) + + akp1 := createAccountNKey(t) + apk1 := publicKey(akp1, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + ac := NewAccountClaims(apk1) + ac.SigningKeys.Add(apk2) + + var vr ValidationResults + ac.Validate(&vr) + if len(vr.Issues) != 0 { + t.Fatal("expected no validation issues") + } + + // try encoding/decoding + token, err := ac.Encode(okp) + if err != nil { + t.Fatal(err) + } + ac2, err := DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + if len(ac2.SigningKeys) != 1 { + t.Fatal("expected claim to have a signing key") + } + if ac.SigningKeys[0] != apk2 { + t.Fatalf("expected signing key to be %s - got %s", apk2, ac.SigningKeys[0]) + } + + bkp := createUserNKey(t) + ac.SigningKeys.Add(publicKey(bkp, t)) + ac.Validate(&vr) + if len(vr.Issues) != 1 { + t.Fatal("expected 1 validation issue") + } +} + +func TestAccountSignedBy(t *testing.T) { + okp := createOperatorNKey(t) + + akp1 := createAccountNKey(t) + apk1 := publicKey(akp1, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + ac := NewAccountClaims(apk1) + ac.SigningKeys.Add(apk2) + + token, err := ac.Encode(okp) + if err != nil { + t.Fatal(err) + } + ac2, err := DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + if len(ac2.SigningKeys) != 1 { + t.Fatal("expected claim to have a signing key") + } + if ac.SigningKeys[0] != apk2 { + t.Fatalf("expected signing key to be %s - got %s", apk2, ac.SigningKeys[0]) + } + + ukp := createUserNKey(t) + upk := publicKey(ukp, t) + + // claim signed by alternate key + uc := NewUserClaims(upk) + utoken, err := uc.Encode(akp2) + if err != nil { + t.Fatal(err) + } + + uc2, err := DecodeUserClaims(utoken) + if err != nil { + t.Fatal(err) + } + if !ac2.DidSign(uc2) { + t.Fatal("failed to verify user claim") + } + + // claim signed by the account pk + uc3 := NewUserClaims(upk) + utoken2, err := uc3.Encode(akp1) + if err != nil { + t.Fatal(err) + } + uc4, err := DecodeUserClaims(utoken2) + if err != nil { + t.Fatal(err) + } + if !ac2.DidSign(uc4) { + t.Fatal("failed to verify user claim") + } +} + +func TestAddRemoveSigningKey(t *testing.T) { + akp1 := createAccountNKey(t) + apk1 := publicKey(akp1, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + akp3 := createAccountNKey(t) + apk3 := publicKey(akp3, t) + + ac := NewAccountClaims(apk1) + ac.SigningKeys.Add(apk2, apk3) + + if len(ac.SigningKeys) != 2 { + t.Fatal("expected 2 signing keys") + } + + ac.SigningKeys.Remove(publicKey(createAccountNKey(t), t)) + if len(ac.SigningKeys) != 2 { + t.Fatal("expected 2 signing keys") + } + + ac.SigningKeys.Remove(apk2) + if len(ac.SigningKeys) != 1 { + t.Fatal("expected single signing keys") + } +} + +func TestUserRevocation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + account := NewAccountClaims(apk) + + pubKey := "bar" + now := time.Now() + + // test that clear is safe before we add any + account.ClearRevocation(pubKey) + + if account.IsRevokedAt(pubKey, now) { + t.Errorf("no revocation was added so is revoked should be false") + } + + account.RevokeAt(pubKey, now.Add(time.Second*100)) + + if !account.IsRevokedAt(pubKey, now) { + t.Errorf("revocation should hold when timestamp is in the future") + } + + if account.IsRevokedAt(pubKey, now.Add(time.Second*150)) { + t.Errorf("revocation should time out") + } + + account.RevokeAt(pubKey, now.Add(time.Second*50)) // shouldn't change the revocation, you can't move it in + + if !account.IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should hold, 100 > 50") + } + + encoded, _ := account.Encode(akp) + decoded, _ := DecodeAccountClaims(encoded) + + if !decoded.IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should last across encoding") + } + + account.ClearRevocation(pubKey) + + if account.IsRevokedAt(pubKey, now) { + t.Errorf("revocations should be cleared") + } + + account.RevokeAt(pubKey, now.Add(time.Second*1000)) + + if !account.IsRevoked(pubKey) { + t.Errorf("revocation be true we revoked in the future") + } +} diff --git a/activation_claims.go b/activation_claims.go new file mode 100644 index 0000000..99228a7 --- /dev/null +++ b/activation_claims.go @@ -0,0 +1,166 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "crypto/sha256" + "encoding/base32" + "errors" + "fmt" + "strings" + + "github.com/nats-io/nkeys" +) + +// Activation defines the custom parts of an activation claim +type Activation struct { + ImportSubject Subject `json:"subject,omitempty"` + ImportType ExportType `json:"type,omitempty"` + Limits +} + +// IsService returns true if an Activation is for a service +func (a *Activation) IsService() bool { + return a.ImportType == Service +} + +// IsStream returns true if an Activation is for a stream +func (a *Activation) IsStream() bool { + return a.ImportType == Stream +} + +// Validate checks the exports and limits in an activation JWT +func (a *Activation) Validate(vr *ValidationResults) { + if !a.IsService() && !a.IsStream() { + vr.AddError("invalid export type: %q", a.ImportType) + } + + if a.IsService() { + if a.ImportSubject.HasWildCards() { + vr.AddError("services cannot have wildcard subject: %q", a.ImportSubject) + } + } + + a.ImportSubject.Validate(vr) + a.Limits.Validate(vr) +} + +// ActivationClaims holds the data specific to an activation JWT +type ActivationClaims struct { + ClaimsData + Activation `json:"nats,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` +} + +// NewActivationClaims creates a new activation claim with the provided sub +func NewActivationClaims(subject string) *ActivationClaims { + if subject == "" { + return nil + } + ac := &ActivationClaims{} + ac.Subject = subject + return ac +} + +// Encode turns an activation claim into a JWT strimg +func (a *ActivationClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicAccountKey(a.ClaimsData.Subject) { + return "", errors.New("expected subject to be an account") + } + a.ClaimsData.Type = ActivationClaim + return a.ClaimsData.Encode(pair, a) +} + +// DecodeActivationClaims tries to create an activation claim from a JWT string +func DecodeActivationClaims(token string) (*ActivationClaims, error) { + v := ActivationClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +// Payload returns the activation specific part of the JWT +func (a *ActivationClaims) Payload() interface{} { + return a.Activation +} + +// Validate checks the claims +func (a *ActivationClaims) Validate(vr *ValidationResults) { + a.ClaimsData.Validate(vr) + a.Activation.Validate(vr) + if a.IssuerAccount != "" && !nkeys.IsValidPublicAccountKey(a.IssuerAccount) { + vr.AddError("account_id is not an account public key") + } +} + +// ExpectedPrefixes defines the types that can sign an activation jwt, account and oeprator +func (a *ActivationClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} +} + +// Claims returns the generic part of the JWT +func (a *ActivationClaims) Claims() *ClaimsData { + return &a.ClaimsData +} + +func (a *ActivationClaims) String() string { + return a.ClaimsData.String(a) +} + +// HashID returns a hash of the claims that can be used to identify it. +// The hash is calculated by creating a string with +// issuerPubKey.subjectPubKey. and constructing the sha-256 hash and base32 encoding that. +// is the exported subject, minus any wildcards, so foo.* becomes foo. +// the one special case is that if the export start with "*" or is ">" the "_" +func (a *ActivationClaims) HashID() (string, error) { + + if a.Issuer == "" || a.Subject == "" || a.ImportSubject == "" { + return "", fmt.Errorf("not enough data in the activaion claims to create a hash") + } + + subject := cleanSubject(string(a.ImportSubject)) + base := fmt.Sprintf("%s.%s.%s", a.Issuer, a.Subject, subject) + h := sha256.New() + h.Write([]byte(base)) + sha := h.Sum(nil) + hash := base32.StdEncoding.EncodeToString(sha) + + return hash, nil +} + +func cleanSubject(subject string) string { + split := strings.Split(subject, ".") + cleaned := "" + + for i, tok := range split { + if tok == "*" || tok == ">" { + if i == 0 { + cleaned = "_" + break + } + + cleaned = strings.Join(split[:i], ".") + break + } + } + if cleaned == "" { + cleaned = subject + } + return cleaned +} diff --git a/activation_claims_test.go b/activation_claims_test.go new file mode 100644 index 0000000..19532b3 --- /dev/null +++ b/activation_claims_test.go @@ -0,0 +1,426 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewActivationClaims(t *testing.T) { + okp := createOperatorNKey(t) + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + activation := NewActivationClaims(apk) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + activation.Limits.Max = 10 + activation.Limits.Payload = 10 + activation.Limits.Src = "192.0.2.0/24" + + activation.ImportSubject = "foo" + activation.Name = "Foo" + activation.ImportType = Stream + + vr := CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + actJwt := encode(activation, okp, t) + + activation2, err := DecodeActivationClaims(actJwt) + if err != nil { + t.Fatal("failed to decode activation", err) + } + + AssertEquals(activation.String(), activation2.String(), t) + + AssertEquals(activation.Claims() != nil, true, t) + AssertEquals(activation.Payload() != nil, true, t) +} + +func TestInvalidActivationTargets(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewActivationClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode user with with %q subject", i.name) + t.Fail() + } + } +} + +func TestInvalidActivationClaimIssuer(t *testing.T) { + akp := createAccountNKey(t) + ac := NewActivationClaims(publicKey(akp, t)) + ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + aJwt := encode(ac, akp, t) + + temp, err := DecodeGeneric(aJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeAccountClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode account signed by %q", i.name) + t.Fail() + } + } +} + +func TestPublicIsNotValid(t *testing.T) { + c := NewActivationClaims("public") + _, err := c.Encode(createOperatorNKey(t)) + if err == nil { + t.Fatal("should not have encoded public activation anymore") + } +} + +func TestNilActivationClaim(t *testing.T) { + v := NewActivationClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestActivationImportSubjectValidation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Issuer = apk + activation.Subject = apk2 + + activation.ImportSubject = "foo" + activation.Name = "Foo" + activation.ImportType = Stream + + vr := CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportType = Service + + vr = CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "foo.*" // wildcards are bad + + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Error("wildcard service activation should not pass validation") + } + + activation.ImportType = Stream // Stream is ok with wildcards + vr = CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "" // empty strings are bad + + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Error("empty activation should not pass validation") + } + + activation.ImportSubject = "foo bar" // spaces are bad + + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || !vr.IsBlocking(true) { + t.Error("spaces in activation should not pass validation") + } +} + +func TestActivationValidation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Issuer = apk + activation.Subject = apk2 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + + activation.ImportSubject = "foo" + activation.Name = "Foo" + activation.ImportType = Stream + + activation.Limits.Max = 10 + activation.Limits.Payload = 10 + activation.Limits.Src = "192.0.2.0/24" + activation.Limits.Times = []TimeRange{ + { + Start: "01:15:00", + End: "03:15:00", + }, + { + Start: "06:15:00", + End: "09:15:00", + }, + } + + vr := CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "times.*" + activation.ImportType = Stream + activation.Name = "times" + + vr = CreateValidationResults() + activation.Validate(vr) + + if !vr.IsEmpty() || vr.IsBlocking(true) { + t.Error("valid activation should pass validation") + } + + activation.ImportSubject = "other.*" + activation.ImportType = Stream + activation.Name = "other" + + activation.Limits.Max = -1 + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + activation.Limits.Max = 10 + activation.Limits.Payload = -1 + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + activation.Limits.Payload = 10 + activation.Limits.Src = "hello world" + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + activation.Limits.Payload = 10 + activation.Limits.Src = "hello world" + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + tr := TimeRange{ + Start: "hello", + End: "03:15:00", + } + activation.Limits.Src = "192.0.2.0/24" + activation.Limits.Times = append(activation.Limits.Times, tr) + vr = CreateValidationResults() + activation.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } +} + +func TestActivationHashIDLimits(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + akp2 := createAccountNKey(t) + apk2 := publicKey(akp2, t) + + activation := NewActivationClaims(apk) + activation.Issuer = apk + activation.Subject = apk2 + + _, err := activation.HashID() + if err == nil { + t.Fatal("activation without subject should fail to hash") + } + + activation.ImportSubject = "times.*" + activation.ImportType = Stream + activation.Name = "times" + + hash, err := activation.HashID() + if err != nil { + t.Fatalf("activation with subject should hash %v", err) + } + + activation2 := NewActivationClaims(apk) + activation2.Issuer = apk + activation2.Subject = apk2 + activation2.ImportSubject = "times.*.bar" + activation2.ImportType = Stream + activation2.Name = "times" + + hash2, err := activation2.HashID() + if err != nil { + t.Fatalf("activation with subject should hash %v", err) + } + + if hash != hash2 { + t.Fatal("subjects should be stripped to create hash") + } +} + +func TestActivationClaimAccountIDValidation(t *testing.T) { + issuerAccountKP := createAccountNKey(t) + issuerAccountPK := publicKey(issuerAccountKP, t) + + issuerKP := createAccountNKey(t) + issuerPK := publicKey(issuerKP, t) + + account := NewAccountClaims(issuerAccountPK) + account.SigningKeys.Add(issuerPK) + token, err := account.Encode(issuerAccountKP) + if err != nil { + t.Fatal(err) + } + account, err = DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + + importerKP := createAccountNKey(t) + importerPK := publicKey(importerKP, t) + + ac := NewActivationClaims(importerPK) + ac.IssuerAccount = issuerAccountPK + ac.Name = "foo.bar" + ac.Activation.ImportSubject = Subject("foo.bar") + ac.Activation.ImportType = Stream + + var vr ValidationResults + ac.Validate(&vr) + if len(vr.Issues) != 0 { + t.Fatalf("expected no validation errors: %v", vr.Issues[0].Error()) + } + token, err = ac.Encode(issuerKP) + if err != nil { + t.Fatal(err) + } + ac, err = DecodeActivationClaims(token) + if err != nil { + t.Fatal(err) + } + if ac.Issuer != issuerPK { + t.Fatal("expected activation subject to be different") + } + if ac.IssuerAccount != issuerAccountPK { + t.Fatal("expected activation account id to be different") + } + + ac.IssuerAccount = publicKey(createUserNKey(t), t) + ac.Validate(&vr) + if len(vr.Issues) != 1 { + t.Fatal("expected validation error") + } + + if !account.DidSign(ac) { + t.Fatal("expected account to have signed activation") + } +} + +func TestCleanSubject(t *testing.T) { + input := [][]string{ + {"foo", "foo"}, + {"*", "_"}, + {">", "_"}, + {"foo.*", "foo"}, + {"foo.bar.>", "foo.bar"}, + {"foo.*.bar", "foo"}, + {"bam.boom.blat.*", "bam.boom.blat"}, + {"*.blam", "_"}, + } + + for _, pair := range input { + clean := cleanSubject(pair[0]) + if pair[1] != clean { + t.Errorf("Expected %s but got %s", pair[1], clean) + } + } +} diff --git a/claims.go b/claims.go new file mode 100644 index 0000000..d402bcc --- /dev/null +++ b/claims.go @@ -0,0 +1,302 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "crypto/sha512" + "encoding/base32" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nkeys" +) + +// ClaimType is used to indicate the type of JWT being stored in a Claim +type ClaimType string + +const ( + // AccountClaim is the type of an Account JWT + AccountClaim = "account" + //ActivationClaim is the type of an activation JWT + ActivationClaim = "activation" + //UserClaim is the type of an user JWT + UserClaim = "user" + //ServerClaim is the type of an server JWT + ServerClaim = "server" + //ClusterClaim is the type of an cluster JWT + ClusterClaim = "cluster" + //OperatorClaim is the type of an operator JWT + OperatorClaim = "operator" +) + +// Claims is a JWT claims +type Claims interface { + Claims() *ClaimsData + Encode(kp nkeys.KeyPair) (string, error) + ExpectedPrefixes() []nkeys.PrefixByte + Payload() interface{} + String() string + Validate(vr *ValidationResults) + Verify(payload string, sig []byte) bool +} + +// ClaimsData is the base struct for all claims +type ClaimsData struct { + Audience string `json:"aud,omitempty"` + Expires int64 `json:"exp,omitempty"` + ID string `json:"jti,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + Issuer string `json:"iss,omitempty"` + Name string `json:"name,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Subject string `json:"sub,omitempty"` + Tags TagList `json:"tags,omitempty"` + Type ClaimType `json:"type,omitempty"` +} + +// Prefix holds the prefix byte for an NKey +type Prefix struct { + nkeys.PrefixByte +} + +func encodeToString(d []byte) string { + return base64.RawURLEncoding.EncodeToString(d) +} + +func decodeString(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +func serialize(v interface{}) (string, error) { + j, err := json.Marshal(v) + if err != nil { + return "", err + } + return encodeToString(j), nil +} + +func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (string, error) { + if header == nil { + return "", errors.New("header is required") + } + + if kp == nil { + return "", errors.New("keypair is required") + } + + if c.Subject == "" { + return "", errors.New("subject is not set") + } + + h, err := serialize(header) + if err != nil { + return "", err + } + + issuerBytes, err := kp.PublicKey() + if err != nil { + return "", err + } + + prefixes := claim.ExpectedPrefixes() + if prefixes != nil { + ok := false + for _, p := range prefixes { + switch p { + case nkeys.PrefixByteAccount: + if nkeys.IsValidPublicAccountKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteOperator: + if nkeys.IsValidPublicOperatorKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteServer: + if nkeys.IsValidPublicServerKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteCluster: + if nkeys.IsValidPublicClusterKey(issuerBytes) { + ok = true + } + case nkeys.PrefixByteUser: + if nkeys.IsValidPublicUserKey(issuerBytes) { + ok = true + } + } + } + if !ok { + return "", fmt.Errorf("unable to validate expected prefixes - %v", prefixes) + } + } + + c.Issuer = string(issuerBytes) + c.IssuedAt = time.Now().UTC().Unix() + + c.ID, err = c.hash() + if err != nil { + return "", err + } + + payload, err := serialize(claim) + if err != nil { + return "", err + } + + sig, err := kp.Sign([]byte(payload)) + if err != nil { + return "", err + } + eSig := encodeToString(sig) + return fmt.Sprintf("%s.%s.%s", h, payload, eSig), nil +} + +func (c *ClaimsData) hash() (string, error) { + j, err := json.Marshal(c) + if err != nil { + return "", err + } + h := sha512.New512_256() + h.Write(j) + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(h.Sum(nil)), nil +} + +// 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) +} + +// Returns a JSON representation of the claim +func (c *ClaimsData) String(claim interface{}) string { + j, err := json.MarshalIndent(claim, "", " ") + if err != nil { + return "" + } + return string(j) +} + +func parseClaims(s string, target Claims) error { + h, err := decodeString(s) + if err != nil { + return err + } + return json.Unmarshal(h, &target) +} + +// Verify verifies that the encoded payload was signed by the +// provided public key. Verify is called automatically with +// the claims portion of the token and the public key in the claim. +// Client code need to insure that the public key in the +// claim is trusted. +func (c *ClaimsData) Verify(payload string, sig []byte) bool { + // decode the public key + kp, err := nkeys.FromPublicKey(c.Issuer) + if err != nil { + return false + } + if err := kp.Verify([]byte(payload), sig); err != nil { + return false + } + return true +} + +// Validate checks a claim to make sure it is valid. Validity checks +// include expiration and not before constraints. +func (c *ClaimsData) Validate(vr *ValidationResults) { + now := time.Now().UTC().Unix() + if c.Expires > 0 && now > c.Expires { + vr.AddTimeCheck("claim is expired") + } + + if c.NotBefore > 0 && c.NotBefore > now { + vr.AddTimeCheck("claim is not yet valid") + } +} + +// IsSelfSigned returns true if the claims issuer is the subject +func (c *ClaimsData) IsSelfSigned() bool { + return c.Issuer == c.Subject +} + +// Decode takes a JWT string decodes it and validates it +// and return the embedded Claims. If the token header +// doesn't match the expected algorithm, or the claim is +// not valid or verification fails an error is returned. +func Decode(token string, target Claims) error { + // must have 3 chunks + chunks := strings.Split(token, ".") + if len(chunks) != 3 { + return errors.New("expected 3 chunks") + } + + _, err := parseHeaders(chunks[0]) + if err != nil { + return err + } + + if err := parseClaims(chunks[1], target); err != nil { + return err + } + + sig, err := decodeString(chunks[2]) + if err != nil { + return err + } + + if !target.Verify(chunks[1], sig) { + return errors.New("claim failed signature verification") + } + + prefixes := target.ExpectedPrefixes() + if prefixes != nil { + ok := false + issuer := target.Claims().Issuer + for _, p := range prefixes { + switch p { + case nkeys.PrefixByteAccount: + if nkeys.IsValidPublicAccountKey(issuer) { + ok = true + } + case nkeys.PrefixByteOperator: + if nkeys.IsValidPublicOperatorKey(issuer) { + ok = true + } + case nkeys.PrefixByteServer: + if nkeys.IsValidPublicServerKey(issuer) { + ok = true + } + case nkeys.PrefixByteCluster: + if nkeys.IsValidPublicClusterKey(issuer) { + ok = true + } + case nkeys.PrefixByteUser: + if nkeys.IsValidPublicUserKey(issuer) { + ok = true + } + } + } + if !ok { + return fmt.Errorf("unable to validate expected prefixes - %v", prefixes) + } + } + + return nil +} diff --git a/cluster_claims.go b/cluster_claims.go new file mode 100644 index 0000000..bbfcf06 --- /dev/null +++ b/cluster_claims.go @@ -0,0 +1,94 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + + "github.com/nats-io/nkeys" +) + +// Cluster stores the cluster specific elements of a cluster JWT +type Cluster struct { + Trust []string `json:"identity,omitempty"` + Accounts []string `json:"accts,omitempty"` + AccountURL string `json:"accturl,omitempty"` + OperatorURL string `json:"opurl,omitempty"` +} + +// Validate checks the cluster and permissions for a cluster JWT +func (c *Cluster) Validate(vr *ValidationResults) { + // fixme validate cluster data +} + +// ClusterClaims defines the data in a cluster JWT +type ClusterClaims struct { + ClaimsData + Cluster `json:"nats,omitempty"` +} + +// NewClusterClaims creates a new cluster JWT with the specified subject/public key +func NewClusterClaims(subject string) *ClusterClaims { + if subject == "" { + return nil + } + c := &ClusterClaims{} + c.Subject = subject + return c +} + +// Encode tries to turn the cluster claims into a JWT string +func (c *ClusterClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicClusterKey(c.Subject) { + return "", errors.New("expected subject to be a cluster public key") + } + c.ClaimsData.Type = ClusterClaim + return c.ClaimsData.Encode(pair, c) +} + +// DecodeClusterClaims tries to parse cluster claims from a JWT string +func DecodeClusterClaims(token string) (*ClusterClaims, error) { + v := ClusterClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (c *ClusterClaims) String() string { + return c.ClaimsData.String(c) +} + +// Payload returns the cluster specific data +func (c *ClusterClaims) Payload() interface{} { + return &c.Cluster +} + +// Validate checks the generic and cluster data in the cluster claims +func (c *ClusterClaims) Validate(vr *ValidationResults) { + c.ClaimsData.Validate(vr) + c.Cluster.Validate(vr) +} + +// ExpectedPrefixes defines the types that can encode a cluster JWT, operator or cluster +func (c *ClusterClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} +} + +// Claims returns the generic data +func (c *ClusterClaims) Claims() *ClaimsData { + return &c.ClaimsData +} diff --git a/cluster_claims_test.go b/cluster_claims_test.go new file mode 100644 index 0000000..5573c8d --- /dev/null +++ b/cluster_claims_test.go @@ -0,0 +1,132 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewClusterClaims(t *testing.T) { + ckp := createClusterNKey(t) + skp := createClusterNKey(t) + + uc := NewClusterClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeClusterClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestClusterClaimsIssuer(t *testing.T) { + ckp := createClusterNKey(t) + skp := createClusterNKey(t) + + uc := NewClusterClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + temp, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), true}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeClusterClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode cluster signed by %q", i.name) + t.Fail() + } + } +} + +func TestClusterSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"server", createServerNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"cluster", createClusterNKey(t), true}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewClusterClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode cluster with with %q subject", i.name) + t.Fail() + } + } +} + +func TestNewNilClusterClaims(t *testing.T) { + v := NewClusterClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestClusterType(t *testing.T) { + c := NewClusterClaims(publicKey(createClusterNKey(t), t)) + s := encode(c, createClusterNKey(t), t) + u, err := DecodeClusterClaims(s) + if err != nil { + t.Fatalf("failed to decode cluster claim: %v", err) + } + + if ClusterClaim != u.Type { + t.Fatalf("type is unexpected %q (wanted cluster)", u.Type) + } + +} diff --git a/creds_utils.go b/creds_utils.go new file mode 100644 index 0000000..bb913dc --- /dev/null +++ b/creds_utils.go @@ -0,0 +1,203 @@ +package jwt + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/nats-io/nkeys" +) + +// DecorateJWT returns a decorated JWT that describes the kind of JWT +func DecorateJWT(jwtString string) ([]byte, error) { + gc, err := DecodeGeneric(jwtString) + if err != nil { + return nil, err + } + return formatJwt(string(gc.Type), jwtString) +} + +func formatJwt(kind string, jwtString string) ([]byte, error) { + templ := `-----BEGIN NATS %s JWT----- +%s +------END NATS %s JWT------ + +` + w := bytes.NewBuffer(nil) + kind = strings.ToUpper(kind) + _, err := fmt.Fprintf(w, templ, kind, jwtString, kind) + if err != nil { + return nil, err + } + return w.Bytes(), nil +} + +// DecorateSeed takes a seed and returns a string that wraps +// the seed in the form: +// ************************* IMPORTANT ************************* +// NKEY Seed printed below can be used sign and prove identity. +// NKEYs are sensitive and should be treated as secrets. +// +// -----BEGIN USER NKEY SEED----- +// SUAIO3FHUX5PNV2LQIIP7TZ3N4L7TX3W53MQGEIVYFIGA635OZCKEYHFLM +// ------END USER NKEY SEED------ +func DecorateSeed(seed []byte) ([]byte, error) { + w := bytes.NewBuffer(nil) + ts := bytes.TrimSpace(seed) + pre := string(ts[0:2]) + kind := "" + switch pre { + case "SU": + kind = "USER" + case "SA": + kind = "ACCOUNT" + case "SO": + kind = "OPERATOR" + default: + return nil, errors.New("seed is not an operator, account or user seed") + } + header := `************************* IMPORTANT ************************* +NKEY Seed printed below can be used to sign and prove identity. +NKEYs are sensitive and should be treated as secrets. + +-----BEGIN %s NKEY SEED----- +` + _, err := fmt.Fprintf(w, header, kind) + if err != nil { + return nil, err + } + w.Write(ts) + + footer := ` +------END %s NKEY SEED------ + +************************************************************* +` + _, err = fmt.Fprintf(w, footer, kind) + if err != nil { + return nil, err + } + return w.Bytes(), nil +} + +var userConfigRE = regexp.MustCompile(`\s*(?:(?:[-]{3,}[^\n]*[-]{3,}\n)(.+)(?:\n\s*[-]{3,}[^\n]*[-]{3,}\n))`) + +// An user config file looks like this: +// -----BEGIN NATS USER JWT----- +// eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5... +// ------END NATS USER JWT------ +// +// ************************* IMPORTANT ************************* +// NKEY Seed printed below can be used sign and prove identity. +// NKEYs are sensitive and should be treated as secrets. +// +// -----BEGIN USER NKEY SEED----- +// SUAIO3FHUX5PNV2LQIIP7TZ3N4L7TX3W53MQGEIVYFIGA635OZCKEYHFLM +// ------END USER NKEY SEED------ + +// FormatUserConfig returns a decorated file with a decorated JWT and decorated seed +func FormatUserConfig(jwtString string, seed []byte) ([]byte, error) { + gc, err := DecodeGeneric(jwtString) + if err != nil { + return nil, err + } + if gc.Type != UserClaim { + return nil, fmt.Errorf("%q cannot be serialized as a user config", string(gc.Type)) + } + + w := bytes.NewBuffer(nil) + + jd, err := formatJwt(string(gc.Type), jwtString) + if err != nil { + return nil, err + } + _, err = w.Write(jd) + if err != nil { + return nil, err + } + if !bytes.HasPrefix(bytes.TrimSpace(seed), []byte("SU")) { + return nil, fmt.Errorf("nkey seed is not an user seed") + } + + d, err := DecorateSeed(seed) + if err != nil { + return nil, err + } + _, err = w.Write(d) + if err != nil { + return nil, err + } + + return w.Bytes(), nil +} + +// ParseDecoratedJWT takes a creds file and returns the JWT portion. +func ParseDecoratedJWT(contents []byte) (string, error) { + items := userConfigRE.FindAllSubmatch(contents, -1) + if len(items) == 0 { + return string(contents), nil + } + // First result should be the user JWT. + // We copy here so that if the file contained a seed file too we wipe appropriately. + raw := items[0][1] + tmp := make([]byte, len(raw)) + copy(tmp, raw) + return string(tmp), nil +} + +// ParseDecoratedNKey takes a creds file, finds the NKey portion and creates a +// key pair from it. +func ParseDecoratedNKey(contents []byte) (nkeys.KeyPair, error) { + var seed []byte + + items := userConfigRE.FindAllSubmatch(contents, -1) + if len(items) > 1 { + seed = items[1][1] + } else { + lines := bytes.Split(contents, []byte("\n")) + for _, line := range lines { + if bytes.HasPrefix(bytes.TrimSpace(line), []byte("SO")) || + bytes.HasPrefix(bytes.TrimSpace(line), []byte("SA")) || + bytes.HasPrefix(bytes.TrimSpace(line), []byte("SU")) { + seed = line + break + } + } + } + if seed == nil { + return nil, errors.New("no nkey seed found") + } + if !bytes.HasPrefix(seed, []byte("SO")) && + !bytes.HasPrefix(seed, []byte("SA")) && + !bytes.HasPrefix(seed, []byte("SU")) { + return nil, errors.New("doesn't contain a seed nkey") + } + kp, err := nkeys.FromSeed(seed) + if err != nil { + return nil, err + } + return kp, nil +} + +// ParseDecoratedUserNKey takes a creds file, finds the NKey portion and creates a +// key pair from it. Similar to ParseDecoratedNKey but fails for non-user keys. +func ParseDecoratedUserNKey(contents []byte) (nkeys.KeyPair, error) { + nk, err := ParseDecoratedNKey(contents) + if err != nil { + return nil, err + } + seed, err := nk.Seed() + if err != nil { + return nil, err + } + if !bytes.HasPrefix(seed, []byte("SU")) { + return nil, errors.New("doesn't contain an user seed nkey") + } + kp, err := nkeys.FromSeed(seed) + if err != nil { + return nil, err + } + return kp, nil +} diff --git a/creds_utils_test.go b/creds_utils_test.go new file mode 100644 index 0000000..5075274 --- /dev/null +++ b/creds_utils_test.go @@ -0,0 +1,211 @@ +package jwt + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/nats-io/nkeys" +) + +func makeJWT(t *testing.T) (string, nkeys.KeyPair) { + akp := createAccountNKey(t) + kp := createUserNKey(t) + pk := publicKey(kp, t) + oc := NewUserClaims(pk) + token, err := oc.Encode(akp) + if err != nil { + t.Fatal(err) + } + return token, kp +} + +func Test_DecorateJwt(t *testing.T) { + token, _ := makeJWT(t) + d, err := DecorateJWT(token) + if err != nil { + t.Fatal(err) + } + s := string(d) + if !strings.Contains(s, "-BEGIN NATS USER JWT-") { + t.Fatal("doesn't contain expected header") + } + if !strings.Contains(s, "eyJ0") { + t.Fatal("doesn't contain public key") + } + if !strings.Contains(s, "-END NATS USER JWT------\n\n") { + t.Fatal("doesn't contain expected footer") + } +} + +func Test_FormatUserConfig(t *testing.T) { + token, kp := makeJWT(t) + d, err := FormatUserConfig(token, seedKey(kp, t)) + if err != nil { + t.Fatal(err) + } + s := string(d) + if !strings.Contains(s, "-BEGIN NATS USER JWT-") { + t.Fatal("doesn't contain expected header") + } + if !strings.Contains(s, "eyJ0") { + t.Fatal("doesn't contain public key") + } + if !strings.Contains(s, "-END NATS USER JWT-") { + t.Fatal("doesn't contain expected footer") + } + + validateSeed(t, d, kp) +} + +func validateSeed(t *testing.T, decorated []byte, nk nkeys.KeyPair) { + kind := "" + seed := seedKey(nk, t) + switch string(seed[0:2]) { + case "SO": + kind = "operator" + case "SA": + kind = "account" + case "SU": + kind = "user" + default: + kind = "not supported" + } + kind = strings.ToUpper(kind) + + s := string(decorated) + if !strings.Contains(s, fmt.Sprintf("\n\n-----BEGIN %s NKEY SEED-", kind)) { + t.Fatal("doesn't contain expected seed header") + } + if !strings.Contains(s, string(seed)) { + t.Fatal("doesn't contain the seed") + } + if !strings.Contains(s, fmt.Sprintf("-END %s NKEY SEED------\n\n", kind)) { + t.Fatal("doesn't contain expected seed footer") + } +} + +func Test_ParseDecoratedJWT(t *testing.T) { + token, _ := makeJWT(t) + + t2, err := ParseDecoratedJWT([]byte(token)) + if err != nil { + t.Fatal(err) + } + if token != t2 { + t.Fatal("jwt didn't match expected") + } + + decorated, err := DecorateJWT(token) + if err != nil { + t.Fatal(err) + } + + t3, err := ParseDecoratedJWT(decorated) + if err != nil { + t.Fatal(err) + } + if token != t3 { + t.Fatal("parse decorated jwt didn't match expected") + } +} + +func Test_ParseDecoratedJWTBad(t *testing.T) { + v, err := ParseDecoratedJWT([]byte("foo")) + if err != nil { + t.Fatal(err) + } + if v != "foo" { + t.Fatal("unexpected input was not returned") + } +} + +func Test_ParseDecoratedSeed(t *testing.T) { + token, ukp := makeJWT(t) + us := seedKey(ukp, t) + decorated, err := FormatUserConfig(token, us) + if err != nil { + t.Fatal(err) + } + kp, err := ParseDecoratedUserNKey(decorated) + if err != nil { + t.Fatal(err) + } + pu := seedKey(kp, t) + if !bytes.Equal(us, pu) { + t.Fatal("seeds don't match") + } +} + +func Test_ParseDecoratedBadKey(t *testing.T) { + token, ukp := makeJWT(t) + us, err := ukp.Seed() + if err != nil { + t.Fatal(err) + } + akp := createAccountNKey(t) + as := seedKey(akp, t) + + _, err = FormatUserConfig(token, as) + if err == nil { + t.Fatal("should have failed to encode with bad seed") + } + + sc, err := FormatUserConfig(token, us) + if err != nil { + t.Fatal(err) + } + bad := strings.Replace(string(sc), string(us), string(as), -1) + _, err = ParseDecoratedUserNKey([]byte(bad)) + if err == nil { + t.Fatal("parse should have failed for non user nkey") + } +} + +func Test_FailsOnNonUserJWT(t *testing.T) { + akp := createAccountNKey(t) + pk := publicKey(akp, t) + + ac := NewAccountClaims(pk) + token, err := ac.Encode(akp) + if err != nil { + t.Fatal(err) + } + ukp := createUserNKey(t) + us := seedKey(ukp, t) + _, err = FormatUserConfig(token, us) + if err == nil { + t.Fatal("should have failed with account claims") + } +} + +func Test_DecorateNKeys(t *testing.T) { + var kps []nkeys.KeyPair + kps = append(kps, createOperatorNKey(t)) + kps = append(kps, createAccountNKey(t)) + kps = append(kps, createUserNKey(t)) + + for _, kp := range kps { + seed := seedKey(kp, t) + d, err := DecorateSeed(seed) + if err != nil { + t.Fatal(err, string(seed)) + } + validateSeed(t, d, kp) + + kp2, err := ParseDecoratedNKey(d) + if err != nil { + t.Fatal(string(seed), err) + } + seed2 := seedKey(kp2, t) + if !bytes.Equal(seed, seed2) { + t.Fatalf("seeds dont match %q != %q", string(seed), string(seed2)) + } + } + + _, err := ParseDecoratedNKey([]byte("bad")) + if err == nil { + t.Fatal("required error parsing bad nkey") + } +} diff --git a/decoder_test.go b/decoder_test.go new file mode 100644 index 0000000..9206d18 --- /dev/null +++ b/decoder_test.go @@ -0,0 +1,404 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewToken(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + claims := NewGenericClaims(publicKey(createUserNKey(t), t)) + claims.Data["foo"] = "bar" + + token, err := claims.Encode(kp) + if err != nil { + t.Fatal("error encoding token", err) + } + + c, err := DecodeGeneric(token) + if err != nil { + t.Fatal(err) + } + + if claims.NotBefore != c.NotBefore { + t.Fatal("notbefore don't match") + } + + if claims.Issuer != c.Issuer { + t.Fatal("notbefore don't match") + } + + if !reflect.DeepEqual(claims.Data, c.Data) { + t.Fatal("data sections don't match") + } +} + +func TestBadType(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + h := Header{"JWS", AlgorithmNkey} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + claim, err := DecodeGeneric(token) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != fmt.Sprintf("not supported type %q", "JWS") { + t.Fatal("expected not supported type error") + } +} + +func TestBadAlgo(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + h := Header{TokenTypeJwt, "foobar"} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + claim, err := DecodeGeneric(token) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != fmt.Sprintf("unexpected %q algorithm", "foobar") { + t.Fatal("expected unexpected algorithm") + } +} + +func TestBadJWT(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + + h := Header{"JWS", AlgorithmNkey} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + chunks := strings.Split(token, ".") + badToken := fmt.Sprintf("%s.%s", chunks[0], chunks[1]) + + claim, err := DecodeGeneric(badToken) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != "expected 3 chunks" { + t.Fatalf("unexpeced error: %q", err.Error()) + } +} + +func TestBadSignature(t *testing.T) { + kp := createAccountNKey(t) + + h := Header{TokenTypeJwt, AlgorithmNkey} + c := NewGenericClaims(publicKey(createUserNKey(t), t)) + c.Data["foo"] = "bar" + + token, err := c.doEncode(&h, kp, c) + if err != nil { + t.Fatal(err) + } + + token = token + "A" + + claim, err := DecodeGeneric(token) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != "claim failed signature verification" { + m := fmt.Sprintf("expected failed signature: %q", err.Error()) + t.Fatal(m) + } +} + +func TestDifferentPayload(t *testing.T) { + akp1 := createAccountNKey(t) + + c1 := NewGenericClaims(publicKey(createUserNKey(t), t)) + c1.Data["foo"] = "barz" + jwt1 := encode(c1, akp1, t) + c1t := strings.Split(jwt1, ".") + c1.Data["foo"] = "bar" + + kp2 := createAccountNKey(t) + token2 := encode(c1, kp2, t) + c2t := strings.Split(token2, ".") + + c1t[1] = c2t[1] + + claim, err := DecodeGeneric(fmt.Sprintf("%s.%s.%s", c1t[0], c1t[1], c1t[2])) + if claim != nil { + t.Fatal("non nil claim on bad token") + } + + if err == nil { + t.Fatal("nil error on bad token") + } + + if err.Error() != "claim failed signature verification" { + m := fmt.Sprintf("expected failed signature: %q", err.Error()) + t.Fatal(m) + } +} + +func TestExpiredToken(t *testing.T) { + akp := createAccountNKey(t) + c := NewGenericClaims(publicKey(akp, t)) + c.Expires = time.Now().UTC().Unix() - 100 + c.Data["foo"] = "barz" + + vr := CreateValidationResults() + c.Validate(vr) + if !vr.IsBlocking(true) { + t.Fatalf("expired tokens should be blocking when time is included") + } + + if vr.IsBlocking(false) { + t.Fatalf("expired tokens should not be blocking when time is not included") + } +} + +func TestNotYetValid(t *testing.T) { + akp1, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("unable to create account key", err) + } + c := NewGenericClaims(publicKey(akp1, t)) + c.NotBefore = time.Now().Add(time.Duration(1) * time.Hour).UTC().Unix() + + vr := CreateValidationResults() + c.Validate(vr) + if !vr.IsBlocking(true) { + t.Fatalf("not yet valid tokens should be blocking when time is included") + } + + if vr.IsBlocking(false) { + t.Fatalf("not yet valid tokens should not be blocking when time is not included") + } +} + +func TestIssuedAtIsSet(t *testing.T) { + akp := createAccountNKey(t) + c := NewGenericClaims(publicKey(akp, t)) + c.Data["foo"] = "barz" + + token, err := c.Encode(akp) + if err != nil { + t.Fatal(err) + } + + claim, err := DecodeGeneric(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if claim.IssuedAt == 0 { + t.Fatalf("issued at is not set") + } +} + +func TestSample(t *testing.T) { + // Need a private key to sign the claim + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + // add a bunch of claims + claims.Data["foo"] = "bar" + + // serialize the claim to a JWT token + token, err := claims.Encode(akp) + if err != nil { + t.Fatal("error encoding token", err) + } + + // on the receiving side, decode the token + c, err := DecodeGeneric(token) + if err != nil { + t.Fatal(err) + } + + // if the token was decoded, it means that it + // validated and it wasn't tampered. the remaining and + // required test is to insure the issuer is trusted + pk, err := akp.PublicKey() + if err != nil { + t.Fatalf("unable to read public key: %v", err) + } + + if c.Issuer != string(pk) { + t.Fatalf("the public key is not trusted") + } +} + +func TestBadHeaderEncoding(t *testing.T) { + // the '=' will be illegal + _, err := parseHeaders("=hello=") + if err == nil { + t.Fatal("should have failed it is not encoded") + } +} + +func TestBadClaimsEncoding(t *testing.T) { + // the '=' will be illegal + c := GenericClaims{} + err := parseClaims("=hello=", &c) + if err == nil { + t.Fatal("should have failed it is not encoded") + } +} + +func TestBadHeaderJSON(t *testing.T) { + payload := encodeToString([]byte("{foo: bar}")) + _, err := parseHeaders(payload) + if err == nil { + t.Fatal("should have failed bad json") + } +} + +func TestBadClaimsJSON(t *testing.T) { + payload := encodeToString([]byte("{foo: bar}")) + c := GenericClaims{} + err := parseClaims(payload, &c) + if err == nil { + t.Fatal("should have failed bad json") + } +} + +func TestBadPublicKeyDecodeGeneric(t *testing.T) { + c := &GenericClaims{} + c.Issuer = "foo" + if ok := c.Verify("foo", []byte("bar")); ok { + t.Fatal("Should have failed to verify") + } +} + +func TestBadSig(t *testing.T) { + opk := createOperatorNKey(t) + kp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(kp, t)) + claims.Data["foo"] = "bar" + + // serialize the claim to a JWT token + token := encode(claims, opk, t) + + tokens := strings.Split(token, ".") + badToken := fmt.Sprintf("%s.%s.=hello=", tokens[0], tokens[1]) + _, err := DecodeGeneric(badToken) + if err == nil { + t.Fatal("should have failed to base64 decode signature") + } +} + +func TestClaimsStringIsJSON(t *testing.T) { + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + // add a bunch of claims + claims.Data["foo"] = "bar" + + claims2 := NewGenericClaims(publicKey(akp, t)) + json.Unmarshal([]byte(claims.String()), claims2) + if claims2.Data["foo"] != "bar" { + t.Fatalf("Failed to decode expected claim from String representation: %q", claims.String()) + } +} + +func TestDoEncodeNilHeader(t *testing.T) { + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + _, err := claims.doEncode(nil, nil, claims) + if err == nil { + t.Fatal("should have failed to encode") + } + if err.Error() != "header is required" { + t.Fatalf("unexpected error on encode: %v", err) + } +} + +func TestDoEncodeNilKeyPair(t *testing.T) { + akp := createAccountNKey(t) + claims := NewGenericClaims(publicKey(akp, t)) + _, err := claims.doEncode(&Header{}, nil, claims) + if err == nil { + t.Fatal("should have failed to encode") + } + if err.Error() != "keypair is required" { + t.Fatalf("unexpected error on encode: %v", err) + } +} + +// if this fails, the URL decoder was changed and JWTs will flap +func TestUsingURLDecoder(t *testing.T) { + token := "eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiJGQ1lZRjJLR0EzQTZHTlZQR0pIVjNUSExYR1VZWkFUREZLV1JTT1czUUo1T0k3QlJST0ZRIiwiaWF0IjoxNTQzOTQzNjc1LCJpc3MiOiJBQ1NKWkhOWlI0QUFUVE1KNzdUV1JONUJHVUZFWFhUS0gzWEtGTldDRkFCVzJRWldOUTRDQkhRRSIsInN1YiI6IkFEVEFHWVZYRkpPRENRM0g0VUZQQU43R1dXWk1BVU9FTTJMMkRWQkFWVFdLM01TU0xUS1JUTzVGIiwidHlwZSI6ImFjdGl2YXRpb24iLCJuYXRzIjp7InN1YmplY3QiOiJmb28iLCJ0eXBlIjoic2VydmljZSJ9fQ.HCZTCF-7wolS3Wjx3swQWMkoDhoo_4gp9EsuM5diJfZrH8s6NTpO0iT7_fKZm7dNDeEoqjwU--3ebp8j-Mm_Aw" + ac, err := DecodeActivationClaims(token) + if err != nil { + t.Fatal("shouldn't have failed to decode", err) + } + if ac == nil { + t.Fatal("should have returned activation") + } +} diff --git a/exports.go b/exports.go new file mode 100644 index 0000000..5578f98 --- /dev/null +++ b/exports.go @@ -0,0 +1,236 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "time" +) + +// ResponseType is used to store an export response type +type ResponseType string + +const ( + // ResponseTypeSingleton is used for a service that sends a single response only + ResponseTypeSingleton = "Singleton" + + // ResponseTypeStream is used for a service that will send multiple responses + ResponseTypeStream = "Stream" + + // ResponseTypeChunked is used for a service that sends a single response in chunks (so not quite a stream) + ResponseTypeChunked = "Chunked" +) + +// ServiceLatency is used when observing and exported service for +// latency measurements. +// Sampling 1-100, represents sampling rate, defaults to 100. +// Results is the subject where the latency metrics are published. +// A metric will be defined by the nats-server's ServiceLatency. Time durations +// are in nanoseconds. +// see https://github.com/nats-io/nats-server/blob/master/server/accounts.go#L524 +// e.g. +// { +// "app": "dlc22", +// "start": "2019-09-16T21:46:23.636869585-07:00", +// "svc": 219732, +// "nats": { +// "req": 320415, +// "resp": 228268, +// "sys": 0 +// }, +// "total": 768415 +// } +// +type ServiceLatency struct { + Sampling int `json:"sampling,omitempty"` + Results Subject `json:"results"` +} + +func (sl *ServiceLatency) Validate(vr *ValidationResults) { + if sl.Sampling < 1 || sl.Sampling > 100 { + vr.AddError("sampling percentage needs to be between 1-100") + } + sl.Results.Validate(vr) + if sl.Results.HasWildCards() { + vr.AddError("results subject can not contain wildcards") + } +} + +// Export represents a single export +type Export struct { + Name string `json:"name,omitempty"` + Subject Subject `json:"subject,omitempty"` + Type ExportType `json:"type,omitempty"` + TokenReq bool `json:"token_req,omitempty"` + Revocations RevocationList `json:"revocations,omitempty"` + ResponseType ResponseType `json:"response_type,omitempty"` + Latency *ServiceLatency `json:"service_latency,omitempty"` +} + +// IsService returns true if an export is for a service +func (e *Export) IsService() bool { + return e.Type == Service +} + +// IsStream returns true if an export is for a stream +func (e *Export) IsStream() bool { + return e.Type == Stream +} + +// IsSingleResponse returns true if an export has a single response +// or no resopnse type is set, also checks that the type is service +func (e *Export) IsSingleResponse() bool { + return e.Type == Service && (e.ResponseType == ResponseTypeSingleton || e.ResponseType == "") +} + +// IsChunkedResponse returns true if an export has a chunked response +func (e *Export) IsChunkedResponse() bool { + return e.Type == Service && e.ResponseType == ResponseTypeChunked +} + +// IsStreamResponse returns true if an export has a chunked response +func (e *Export) IsStreamResponse() bool { + return e.Type == Service && e.ResponseType == ResponseTypeStream +} + +// Validate appends validation issues to the passed in results list +func (e *Export) Validate(vr *ValidationResults) { + if !e.IsService() && !e.IsStream() { + vr.AddError("invalid export type: %q", e.Type) + } + if e.IsService() && !e.IsSingleResponse() && !e.IsChunkedResponse() && !e.IsStreamResponse() { + vr.AddError("invalid response type for service: %q", e.ResponseType) + } + if e.IsStream() && e.ResponseType != "" { + vr.AddError("invalid response type for stream: %q", e.ResponseType) + } + if e.Latency != nil { + if !e.IsService() { + vr.AddError("latency tracking only permitted for services") + } + e.Latency.Validate(vr) + } + e.Subject.Validate(vr) +} + +// Revoke enters a revocation by publickey using time.Now(). +func (e *Export) Revoke(pubKey string) { + e.RevokeAt(pubKey, time.Now()) +} + +// RevokeAt enters a revocation by publickey and timestamp into this export +// If there is already a revocation for this public key that is newer, it is kept. +func (e *Export) RevokeAt(pubKey string, timestamp time.Time) { + if e.Revocations == nil { + e.Revocations = RevocationList{} + } + + e.Revocations.Revoke(pubKey, timestamp) +} + +// ClearRevocation removes any revocation for the public key +func (e *Export) ClearRevocation(pubKey string) { + e.Revocations.ClearRevocation(pubKey) +} + +// IsRevokedAt checks if the public key is in the revoked list with a timestamp later than +// the one passed in. Generally this method is called with time.Now() but other time's can +// be used for testing. +func (e *Export) IsRevokedAt(pubKey string, timestamp time.Time) bool { + return e.Revocations.IsRevoked(pubKey, timestamp) +} + +// IsRevoked checks if the public key is in the revoked list with time.Now() +func (e *Export) IsRevoked(pubKey string) bool { + return e.Revocations.IsRevoked(pubKey, time.Now()) +} + +// Exports is a slice of exports +type Exports []*Export + +// Add appends exports to the list +func (e *Exports) Add(i ...*Export) { + *e = append(*e, i...) +} + +func isContainedIn(kind ExportType, subjects []Subject, vr *ValidationResults) { + m := make(map[string]string) + for i, ns := range subjects { + for j, s := range subjects { + if i == j { + continue + } + if ns.IsContainedIn(s) { + str := string(s) + _, ok := m[str] + if !ok { + m[str] = string(ns) + } + } + } + } + + if len(m) != 0 { + for k, v := range m { + var vi ValidationIssue + vi.Blocking = true + vi.Description = fmt.Sprintf("%s export subject %q already exports %q", kind, k, v) + vr.Add(&vi) + } + } +} + +// Validate calls validate on all of the exports +func (e *Exports) Validate(vr *ValidationResults) error { + var serviceSubjects []Subject + var streamSubjects []Subject + + for _, v := range *e { + if v.IsService() { + serviceSubjects = append(serviceSubjects, v.Subject) + } else { + streamSubjects = append(streamSubjects, v.Subject) + } + v.Validate(vr) + } + + isContainedIn(Service, serviceSubjects, vr) + isContainedIn(Stream, streamSubjects, vr) + + return nil +} + +// HasExportContainingSubject checks if the export list has an export with the provided subject +func (e *Exports) HasExportContainingSubject(subject Subject) bool { + for _, s := range *e { + if subject.IsContainedIn(s.Subject) { + return true + } + } + return false +} + +func (e Exports) Len() int { + return len(e) +} + +func (e Exports) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +func (e Exports) Less(i, j int) bool { + return e[i].Subject < e[j].Subject +} diff --git a/exports_test.go b/exports_test.go new file mode 100644 index 0000000..b674c90 --- /dev/null +++ b/exports_test.go @@ -0,0 +1,290 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "sort" + "testing" + "time" +) + +func TestSimpleExportValidation(t *testing.T) { + e := &Export{Subject: "foo", Type: Stream} + + vr := CreateValidationResults() + e.Validate(vr) + + if !vr.IsEmpty() { + t.Errorf("simple export should validate cleanly") + } + + e.Type = Service + vr = CreateValidationResults() + e.Validate(vr) + + if !vr.IsEmpty() { + t.Errorf("simple export should validate cleanly") + } +} + +func TestResponseTypeValidation(t *testing.T) { + e := &Export{Subject: "foo", Type: Stream, ResponseType: ResponseTypeSingleton} + + vr := CreateValidationResults() + e.Validate(vr) + + if vr.IsEmpty() { + t.Errorf("response type on stream should have an validation issue") + } + if e.IsSingleResponse() { + t.Errorf("response type should always fail for stream") + } + + e.Type = Service + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if !e.IsSingleResponse() || e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be single") + } + + e.ResponseType = ResponseTypeChunked + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if e.IsSingleResponse() || !e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be chunk") + } + + e.ResponseType = ResponseTypeStream + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if e.IsSingleResponse() || e.IsChunkedResponse() || !e.IsStreamResponse() { + t.Errorf("response type should be stream") + } + + e.ResponseType = "" + vr = CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("response type on service should validate cleanly") + } + if !e.IsSingleResponse() || e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be single") + } + + e.ResponseType = "bad" + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("response type should match available options") + } + if e.IsSingleResponse() || e.IsChunkedResponse() || e.IsStreamResponse() { + t.Errorf("response type should be bad") + } +} + +func TestInvalidExportType(t *testing.T) { + i := &Export{Subject: "foo", Type: Unknown} + + vr := CreateValidationResults() + i.Validate(vr) + + if vr.IsEmpty() { + t.Errorf("export with bad type should not validate cleanly") + } + + if !vr.IsBlocking(true) { + t.Errorf("invalid type is blocking") + } +} + +func TestOverlappingExports(t *testing.T) { + i := &Export{Subject: "bar.foo", Type: Stream} + i2 := &Export{Subject: "bar.*", Type: Stream} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 1 { + t.Errorf("export has overlapping subjects") + } +} + +func TestDifferentExportTypes_OverlapOK(t *testing.T) { + i := &Export{Subject: "bar.foo", Type: Service} + i2 := &Export{Subject: "bar.*", Type: Stream} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 0 { + t.Errorf("should allow overlaps on different export kind") + } +} + +func TestDifferentExportTypes_SameSubjectOK(t *testing.T) { + i := &Export{Subject: "bar", Type: Service} + i2 := &Export{Subject: "bar", Type: Stream} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 0 { + t.Errorf("should allow overlaps on different export kind") + } +} + +func TestSameExportType_SameSubject(t *testing.T) { + i := &Export{Subject: "bar", Type: Service} + i2 := &Export{Subject: "bar", Type: Service} + + exports := &Exports{} + exports.Add(i, i2) + + vr := CreateValidationResults() + exports.Validate(vr) + + if len(vr.Issues) != 1 { + t.Errorf("should not allow same subject on same export kind") + } +} + +func TestExportRevocation(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + account := NewAccountClaims(apk) + e := &Export{Subject: "foo", Type: Stream} + + account.Exports.Add(e) + + pubKey := "bar" + now := time.Now() + + // test that clear is safe before we add any + e.ClearRevocation(pubKey) + + if e.IsRevokedAt(pubKey, now) { + t.Errorf("no revocation was added so is revoked should be false") + } + + e.RevokeAt(pubKey, now.Add(time.Second*100)) + + if !e.IsRevokedAt(pubKey, now) { + t.Errorf("revocation should hold when timestamp is in the future") + } + + if e.IsRevokedAt(pubKey, now.Add(time.Second*150)) { + t.Errorf("revocation should time out") + } + + e.RevokeAt(pubKey, now.Add(time.Second*50)) // shouldn't change the revocation, you can't move it in + + if !e.IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should hold, 100 > 50") + } + + encoded, _ := account.Encode(akp) + decoded, _ := DecodeAccountClaims(encoded) + + if !decoded.Exports[0].IsRevokedAt(pubKey, now.Add(time.Second*60)) { + t.Errorf("revocation should last across encoding") + } + + e.ClearRevocation(pubKey) + + if e.IsRevokedAt(pubKey, now) { + t.Errorf("revocations should be cleared") + } + + e.RevokeAt(pubKey, now.Add(time.Second*1000)) + + if !e.IsRevoked(pubKey) { + t.Errorf("revocation be true we revoked in the future") + } +} + +func TestExportTrackLatency(t *testing.T) { + e := &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 100, Results: "results"} + vr := CreateValidationResults() + e.Validate(vr) + if !vr.IsEmpty() { + t.Errorf("Expected to validate with simple tracking") + } + + e = &Export{Subject: "foo", Type: Stream} + e.Latency = &ServiceLatency{Sampling: 100, Results: "results"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("adding latency tracking to a stream should have an validation issue") + } + + e = &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 0, Results: "results"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("Sampling <1 should have a validation issue") + } + + e = &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 122, Results: "results"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("Sampling >100 should have a validation issue") + } + + e = &Export{Subject: "foo", Type: Service} + e.Latency = &ServiceLatency{Sampling: 22, Results: "results.*"} + vr = CreateValidationResults() + e.Validate(vr) + if vr.IsEmpty() { + t.Errorf("Results subject needs to be valid publish subject") + } +} + +func TestExport_Sorting(t *testing.T) { + var exports Exports + exports.Add(&Export{Subject: "x", Type: Service}) + exports.Add(&Export{Subject: "z", Type: Service}) + exports.Add(&Export{Subject: "y", Type: Service}) + if exports[0].Subject != "x" { + t.Fatal("added export not in expected order") + } + sort.Sort(exports) + if exports[0].Subject != "x" && exports[1].Subject != "y" && exports[2].Subject != "z" { + t.Fatal("exports not sorted") + } +} diff --git a/genericclaims_test.go b/genericclaims_test.go new file mode 100644 index 0000000..fed632a --- /dev/null +++ b/genericclaims_test.go @@ -0,0 +1,60 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "testing" + "time" +) + +func TestNewGenericClaims(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + + uc := NewGenericClaims(apk) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + uc.Name = "alberto" + uc.Audience = "everyone" + uc.NotBefore = time.Now().UTC().Unix() + uc.Tags.Add("one") + uc.Tags.Add("one") + uc.Tags.Add("one") + uc.Tags.Add("TWO") // should become lower case + uc.Tags.Add("three") + + uJwt := encode(uc, akp, t) + + uc2, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + AssertEquals(uc.Name, uc2.Name, t) + AssertEquals(uc.Audience, uc2.Audience, t) + AssertEquals(uc.Expires, uc2.Expires, t) + AssertEquals(uc.NotBefore, uc2.NotBefore, t) + AssertEquals(uc.Subject, uc2.Subject, t) + + AssertEquals(3, len(uc2.Tags), t) + AssertEquals(true, uc2.Tags.Contains("two"), t) + AssertEquals("one", uc2.Tags[0], t) + AssertEquals("two", uc2.Tags[1], t) + AssertEquals("three", uc2.Tags[2], t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} diff --git a/genericlaims.go b/genericlaims.go new file mode 100644 index 0000000..94cd86e --- /dev/null +++ b/genericlaims.go @@ -0,0 +1,73 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import "github.com/nats-io/nkeys" + +// GenericClaims can be used to read a JWT as a map for any non-generic fields +type GenericClaims struct { + ClaimsData + Data map[string]interface{} `json:"nats,omitempty"` +} + +// NewGenericClaims creates a map-based Claims +func NewGenericClaims(subject string) *GenericClaims { + if subject == "" { + return nil + } + c := GenericClaims{} + c.Subject = subject + c.Data = make(map[string]interface{}) + return &c +} + +// DecodeGeneric takes a JWT string and decodes it into a ClaimsData and map +func DecodeGeneric(token string) (*GenericClaims, error) { + v := GenericClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +// Claims returns the standard part of the generic claim +func (gc *GenericClaims) Claims() *ClaimsData { + return &gc.ClaimsData +} + +// Payload returns the custom part of the claims data +func (gc *GenericClaims) Payload() interface{} { + return &gc.Data +} + +// Encode takes a generic claims and creates a JWT string +func (gc *GenericClaims) Encode(pair nkeys.KeyPair) (string, error) { + return gc.ClaimsData.Encode(pair, gc) +} + +// Validate checks the generic part of the claims data +func (gc *GenericClaims) Validate(vr *ValidationResults) { + gc.ClaimsData.Validate(vr) +} + +func (gc *GenericClaims) String() string { + return gc.ClaimsData.String(gc) +} + +// ExpectedPrefixes returns the types allowed to encode a generic JWT, which is nil for all +func (gc *GenericClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..778d12c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/nats-io/jwt + +require github.com/nats-io/nkeys v0.1.3 + +go 1.13 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9baf67f --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/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= diff --git a/header.go b/header.go new file mode 100644 index 0000000..27c6581 --- /dev/null +++ b/header.go @@ -0,0 +1,71 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + // Version is semantic version. + Version = "0.3.2" + + // TokenTypeJwt is the JWT token type supported JWT tokens + // encoded and decoded by this library + TokenTypeJwt = "jwt" + + // AlgorithmNkey is the algorithm supported by JWT tokens + // encoded and decoded by this library + AlgorithmNkey = "ed25519" +) + +// Header is a JWT Jose Header +type Header struct { + Type string `json:"typ"` + Algorithm string `json:"alg"` +} + +// Parses a header JWT token +func parseHeaders(s string) (*Header, error) { + h, err := decodeString(s) + if err != nil { + return nil, err + } + header := Header{} + if err := json.Unmarshal(h, &header); err != nil { + return nil, err + } + + if err := header.Valid(); err != nil { + return nil, err + } + return &header, nil +} + +// Valid validates the Header. It returns nil if the Header is +// a JWT header, and the algorithm used is the NKEY algorithm. +func (h *Header) Valid() error { + if TokenTypeJwt != strings.ToLower(h.Type) { + return fmt.Errorf("not supported type %q", h.Type) + } + + if AlgorithmNkey != strings.ToLower(h.Algorithm) { + return fmt.Errorf("unexpected %q algorithm", h.Algorithm) + } + return nil +} diff --git a/imports.go b/imports.go new file mode 100644 index 0000000..8cd9747 --- /dev/null +++ b/imports.go @@ -0,0 +1,151 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "io/ioutil" + "net/http" + "net/url" + "time" +) + +// Import describes a mapping from another account into this one +type Import struct { + Name string `json:"name,omitempty"` + // Subject field in an import is always from the perspective of the + // initial publisher - in the case of a stream it is the account owning + // the stream (the exporter), and in the case of a service it is the + // account making the request (the importer). + Subject Subject `json:"subject,omitempty"` + Account string `json:"account,omitempty"` + Token string `json:"token,omitempty"` + // To field in an import is always from the perspective of the subscriber + // in the case of a stream it is the client of the stream (the importer), + // from the perspective of a service, it is the subscription waiting for + // requests (the exporter). If the field is empty, it will default to the + // value in the Subject field. + To Subject `json:"to,omitempty"` + Type ExportType `json:"type,omitempty"` +} + +// IsService returns true if the import is of type service +func (i *Import) IsService() bool { + return i.Type == Service +} + +// IsStream returns true if the import is of type stream +func (i *Import) IsStream() bool { + return i.Type == Stream +} + +// Validate checks if an import is valid for the wrapping account +func (i *Import) Validate(actPubKey string, vr *ValidationResults) { + if !i.IsService() && !i.IsStream() { + vr.AddError("invalid import type: %q", i.Type) + } + + if i.Account == "" { + vr.AddWarning("account to import from is not specified") + } + + i.Subject.Validate(vr) + + if i.IsService() && i.Subject.HasWildCards() { + vr.AddError("services cannot have wildcard subject: %q", i.Subject) + } + if i.IsStream() && i.To.HasWildCards() { + vr.AddError("streams cannot have wildcard to subject: %q", i.Subject) + } + + var act *ActivationClaims + + if i.Token != "" { + // Check to see if its an embedded JWT or a URL. + if url, err := url.Parse(i.Token); err == nil && url.Scheme != "" { + c := &http.Client{Timeout: 5 * time.Second} + resp, err := c.Get(url.String()) + if err != nil { + vr.AddWarning("import %s contains an unreachable token URL %q", i.Subject, i.Token) + } + + if resp != nil { + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + vr.AddWarning("import %s contains an unreadable token URL %q", i.Subject, i.Token) + } else { + act, err = DecodeActivationClaims(string(body)) + if err != nil { + vr.AddWarning("import %s contains a url %q with an invalid activation token", i.Subject, i.Token) + } + } + } + } else { + var err error + act, err = DecodeActivationClaims(i.Token) + if err != nil { + vr.AddWarning("import %q contains an invalid activation token", i.Subject) + } + } + } + + if act != nil { + if act.Issuer != i.Account { + vr.AddWarning("activation token doesn't match account for import %q", i.Subject) + } + + if act.ClaimsData.Subject != actPubKey { + vr.AddWarning("activation token doesn't match account it is being included in, %q", i.Subject) + } + } else { + vr.AddWarning("no activation provided for import %s", i.Subject) + } + +} + +// Imports is a list of import structs +type Imports []*Import + +// Validate checks if an import is valid for the wrapping account +func (i *Imports) Validate(acctPubKey string, vr *ValidationResults) { + toSet := make(map[Subject]bool, len(*i)) + for _, v := range *i { + if v.Type == Service { + if _, ok := toSet[v.To]; ok { + vr.AddError("Duplicate To subjects for %q", v.To) + } + toSet[v.To] = true + } + v.Validate(acctPubKey, vr) + } +} + +// Add is a simple way to add imports +func (i *Imports) Add(a ...*Import) { + *i = append(*i, a...) +} + +func (i Imports) Len() int { + return len(i) +} + +func (i Imports) Swap(j, k int) { + i[j], i[k] = i[k], i[j] +} + +func (i Imports) Less(j, k int) bool { + return i[j].Subject < i[k].Subject +} diff --git a/imports_test.go b/imports_test.go new file mode 100644 index 0000000..4405d7b --- /dev/null +++ b/imports_test.go @@ -0,0 +1,410 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sort" + "testing" + "time" +) + +func TestImportValidation(t *testing.T) { + ak := createAccountNKey(t) + ak2 := createAccountNKey(t) + akp := publicKey(ak, t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "test", Account: akp2, To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + i.Type = Service + vr = CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + + activation.ImportSubject = "test" + activation.ImportType = Stream + actJWT := encode(activation, ak2, t) + + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with token should be valid") + } +} + +func TestInvalidImportType(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, To: "bar", Type: Unknown} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if !vr.IsBlocking(true) { + t.Errorf("invalid type is blocking") + } +} + +func TestInvalidImportToken(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, Token: "bad token", To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports with a bad token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("invalid type shouldnt be blocking") + } +} + +func TestInvalidImportURL(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, Token: "foo://bad token url", To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports with a bad token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("invalid type shouldnt be blocking") + } +} + +func TestInvalidImportTokenValuesValidation(t *testing.T) { + ak := createAccountNKey(t) + ak2 := createAccountNKey(t) + akp := publicKey(ak, t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "test", Account: akp2, To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + i.Type = Service + vr = CreateValidationResults() + i.Validate("", vr) + + if vr.IsEmpty() { + t.Errorf("imports without token or url should warn the caller") + } + + if vr.IsBlocking(true) { + t.Errorf("imports without token or url should not be blocking") + } + + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + + activation.ImportSubject = "test" + activation.ImportType = Stream + actJWT := encode(activation, ak2, t) + + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with token should be valid") + } + + actJWT = encode(activation, ak, t) // wrong issuer + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with wrong issuer") + } + + activation.Subject = akp2 // wrong subject + actJWT = encode(activation, ak2, t) // right issuer + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with wrong issuer") + } +} +func TestMissingAccountInImport(t *testing.T) { + i := &Import{Subject: "foo", To: "bar", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if len(vr.Issues) != 2 { + t.Errorf("imports without token or url should warn the caller, as should missing account") + } + + if vr.IsBlocking(true) { + t.Errorf("Missing Account is not blocking, must import failures are warnings") + } +} + +func TestServiceImportWithWildcard(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo.*", Account: akp, To: "bar", Type: Service} + + vr := CreateValidationResults() + i.Validate("", vr) + + if len(vr.Issues) != 2 { + t.Errorf("imports without token or url should warn the caller, as should wildcard service") + } + + if !vr.IsBlocking(true) { + t.Errorf("expected service import with a wildcard subject to be a blocking error") + } +} + +func TestStreamImportWithWildcardPrefix(t *testing.T) { + i := &Import{Subject: "foo", To: "bar.>", Type: Stream} + + vr := CreateValidationResults() + i.Validate("", vr) + + if len(vr.Issues) != 3 { + t.Errorf("should have registered 3 issues with this import, got %d", len(vr.Issues)) + } + + if !vr.IsBlocking(true) { + t.Fatalf("expected stream import prefix with a wildcard to produce a blocking error") + } +} + +func TestImportsValidation(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + i := &Import{Subject: "foo", Account: akp, To: "bar", Type: Stream} + i2 := &Import{Subject: "foo.*", Account: akp, To: "bar", Type: Service} + + imports := &Imports{} + imports.Add(i, i2) + + vr := CreateValidationResults() + imports.Validate("", vr) + + if len(vr.Issues) != 3 { + t.Errorf("imports without token or url should warn the caller x2, wildcard service as well") + } + + if !vr.IsBlocking(true) { + t.Errorf("expected service import with a wildcard subject to be a blocking error") + } +} + +func TestTokenURLImportValidation(t *testing.T) { + ak := createAccountNKey(t) + ak2 := createAccountNKey(t) + akp := publicKey(ak, t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "test", Account: akp2, To: "bar", Type: Stream} + + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.ImportSubject = "test" + activation.ImportType = Stream + + actJWT := encode(activation, ak2, t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(actJWT)) + })) + defer ts.Close() + + i.Token = ts.URL + vr := CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + fmt.Printf("vr is %+v\n", vr) + t.Errorf("imports with token url should be valid") + } + + i.Token = "http://Bad URL" + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with bad token url should be valid") + } + + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("bad jwt")) + })) + defer ts.Close() + + i.Token = ts.URL + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with token url pointing to bad JWT") + } + + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer ts.Close() + + i.Token = ts.URL + vr = CreateValidationResults() + i.Validate(akp, vr) + + if vr.IsEmpty() { + t.Errorf("imports with token url pointing to bad url") + } +} + +func TestImportSubjectValidation(t *testing.T) { + ak := createAccountNKey(t) + akp := publicKey(ak, t) + activation := NewActivationClaims(akp) + activation.Max = 1024 * 1024 + activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.ImportSubject = "one.*" + activation.ImportType = Stream + + ak2 := createAccountNKey(t) + akp2 := publicKey(ak2, t) + i := &Import{Subject: "one.two", Account: akp2, To: "bar", Type: Stream} + + actJWT := encode(activation, ak2, t) + i.Token = actJWT + vr := CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Log(vr.Issues[0].Description) + t.Errorf("imports with valid contains subject should be valid") + } + + activation.ImportSubject = "two" + activation.ImportType = Stream + actJWT = encode(activation, ak2, t) + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with non-contains subject should be not valid") + } + + activation.ImportSubject = ">" + activation.ImportType = Stream + actJWT = encode(activation, ak2, t) + i.Token = actJWT + vr = CreateValidationResults() + i.Validate(akp, vr) + + if !vr.IsEmpty() { + t.Errorf("imports with valid contains subject should be valid") + } +} + +func TestImportServiceDoubleToSubjectsValidation(t *testing.T) { + akp := createAccountNKey(t) + akp2 := createAccountNKey(t) + apk := publicKey(akp, t) + apk2 := publicKey(akp2, t) + + account := NewAccountClaims(apk) + + i := &Import{Subject: "one.two", Account: apk2, To: "foo.bar", Type: Service} + account.Imports.Add(i) + + vr := CreateValidationResults() + account.Validate(vr) + + if vr.IsBlocking(true) { + t.Fatalf("Expected no blocking validation errors") + } + + i2 := &Import{Subject: "two.three", Account: apk2, To: "foo.bar", Type: Service} + account.Imports.Add(i2) + + vr = CreateValidationResults() + account.Validate(vr) + + if !vr.IsBlocking(true) { + t.Fatalf("Expected multiple import 'to' subjects to produce an error") + } +} + +func TestImport_Sorting(t *testing.T) { + var imports Imports + pk := publicKey(createAccountNKey(t), t) + imports.Add(&Import{Subject: "x", Type: Service, Account: pk}) + imports.Add(&Import{Subject: "z", Type: Service, Account: pk}) + imports.Add(&Import{Subject: "y", Type: Service, Account: pk}) + if imports[0].Subject != "x" { + t.Fatal("added import not in expected order") + } + sort.Sort(imports) + if imports[0].Subject != "x" && imports[1].Subject != "y" && imports[2].Subject != "z" { + t.Fatal("imports not sorted") + } +} diff --git a/operator_claims.go b/operator_claims.go new file mode 100644 index 0000000..6a99597 --- /dev/null +++ b/operator_claims.go @@ -0,0 +1,204 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nats-io/nkeys" +) + +// Operator specific claims +type Operator struct { + // Slice of real identies (like websites) that can be used to identify the operator. + Identities []Identity `json:"identity,omitempty"` + // Slice of other operator NKeys that can be used to sign on behalf of the main + // operator identity. + SigningKeys StringList `json:"signing_keys,omitempty"` + // AccountServerURL is a partial URL like "https://host.domain.org:/jwt/v1" + // tools will use the prefix and build queries by appending /accounts/ + // or /operator to the path provided. Note this assumes that the account server + // can handle requests in a nats-account-server compatible way. See + // https://github.com/nats-io/nats-account-server. + AccountServerURL string `json:"account_server_url,omitempty"` + // A list of NATS urls (tls://host:port) where tools can connect to the server + // using proper credentials. + OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` +} + +// Validate checks the validity of the operators contents +func (o *Operator) Validate(vr *ValidationResults) { + if err := o.validateAccountServerURL(); err != nil { + vr.AddError(err.Error()) + } + + for _, v := range o.validateOperatorServiceURLs() { + if v != nil { + vr.AddError(v.Error()) + } + } + + for _, i := range o.Identities { + i.Validate(vr) + } + + for _, k := range o.SigningKeys { + if !nkeys.IsValidPublicOperatorKey(k) { + vr.AddError("%s is not an operator public key", k) + } + } +} + +func (o *Operator) validateAccountServerURL() error { + if o.AccountServerURL != "" { + // We don't care what kind of URL it is so long as it parses + // and has a protocol. The account server may impose additional + // constraints on the type of URLs that it is able to notify to + u, err := url.Parse(o.AccountServerURL) + if err != nil { + return fmt.Errorf("error parsing account server url: %v", err) + } + if u.Scheme == "" { + return fmt.Errorf("account server url %q requires a protocol", o.AccountServerURL) + } + } + return nil +} + +// ValidateOperatorServiceURL returns an error if the URL is not a valid NATS or TLS url. +func ValidateOperatorServiceURL(v string) error { + // should be possible for the service url to not be expressed + if v == "" { + return nil + } + u, err := url.Parse(v) + if err != nil { + return fmt.Errorf("error parsing operator service url %q: %v", v, err) + } + + if u.User != nil { + return fmt.Errorf("operator service url %q - credentials are not supported", v) + } + + if u.Path != "" { + return fmt.Errorf("operator service url %q - paths are not supported", v) + } + + lcs := strings.ToLower(u.Scheme) + switch lcs { + case "nats": + return nil + case "tls": + return nil + default: + return fmt.Errorf("operator service url %q - protocol not supported (only 'nats' or 'tls' only)", v) + } +} + +func (o *Operator) validateOperatorServiceURLs() []error { + var errors []error + for _, v := range o.OperatorServiceURLs { + if v != "" { + if err := ValidateOperatorServiceURL(v); err != nil { + errors = append(errors, err) + } + } + } + return errors +} + +// OperatorClaims define the data for an operator JWT +type OperatorClaims struct { + ClaimsData + Operator `json:"nats,omitempty"` +} + +// NewOperatorClaims creates a new operator claim with the specified subject, which should be an operator public key +func NewOperatorClaims(subject string) *OperatorClaims { + if subject == "" { + return nil + } + c := &OperatorClaims{} + c.Subject = subject + return c +} + +// DidSign checks the claims against the operator's public key and its signing keys +func (oc *OperatorClaims) DidSign(op Claims) bool { + if op == nil { + return false + } + issuer := op.Claims().Issuer + if issuer == oc.Subject { + return true + } + return oc.SigningKeys.Contains(issuer) +} + +// Deprecated: AddSigningKey, use claim.SigningKeys.Add() +func (oc *OperatorClaims) AddSigningKey(pk string) { + oc.SigningKeys.Add(pk) +} + +// Encode the claims into a JWT string +func (oc *OperatorClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicOperatorKey(oc.Subject) { + return "", errors.New("expected subject to be an operator public key") + } + err := oc.validateAccountServerURL() + if err != nil { + return "", err + } + oc.ClaimsData.Type = OperatorClaim + return oc.ClaimsData.Encode(pair, oc) +} + +// DecodeOperatorClaims tries to create an operator claims from a JWt string +func DecodeOperatorClaims(token string) (*OperatorClaims, error) { + v := OperatorClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (oc *OperatorClaims) String() string { + return oc.ClaimsData.String(oc) +} + +// Payload returns the operator specific data for an operator JWT +func (oc *OperatorClaims) Payload() interface{} { + return &oc.Operator +} + +// Validate the contents of the claims +func (oc *OperatorClaims) Validate(vr *ValidationResults) { + oc.ClaimsData.Validate(vr) + oc.Operator.Validate(vr) +} + +// ExpectedPrefixes defines the nkey types that can sign operator claims, operator +func (oc *OperatorClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteOperator} +} + +// Claims returns the generic claims data +func (oc *OperatorClaims) Claims() *ClaimsData { + return &oc.ClaimsData +} diff --git a/operator_claims_test.go b/operator_claims_test.go new file mode 100644 index 0000000..73cae23 --- /dev/null +++ b/operator_claims_test.go @@ -0,0 +1,354 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewOperatorClaims(t *testing.T) { + ckp := createOperatorNKey(t) + + uc := NewOperatorClaims(publicKey(ckp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeOperatorClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestOperatorSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewOperatorClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode server with with %q subject", i.name) + t.Fail() + } + } +} + +func TestInvalidOperatorClaimIssuer(t *testing.T) { + akp := createOperatorNKey(t) + ac := NewOperatorClaims(publicKey(akp, t)) + ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + aJwt := encode(ac, akp, t) + + temp, err := DecodeGeneric(aJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeOperatorClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode account signed by %q", i.name) + t.Fail() + } + } +} + +func TestNewNilOperatorClaims(t *testing.T) { + v := NewOperatorClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestOperatorType(t *testing.T) { + c := NewOperatorClaims(publicKey(createOperatorNKey(t), t)) + s := encode(c, createOperatorNKey(t), t) + u, err := DecodeOperatorClaims(s) + if err != nil { + t.Fatalf("failed to decode operator claim: %v", err) + } + + if OperatorClaim != u.Type { + t.Fatalf("type is unexpected %q (wanted operator)", u.Type) + } + +} + +func TestSigningKeyValidation(t *testing.T) { + ckp := createOperatorNKey(t) + ckp2 := createOperatorNKey(t) + + uc := NewOperatorClaims(publicKey(ckp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.AddSigningKey(publicKey(ckp2, t)) + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeOperatorClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(len(uc2.SigningKeys), 1, t) + AssertEquals(uc2.SigningKeys[0] == publicKey(ckp2, t), true, t) + + vr := &ValidationResults{} + uc.Validate(vr) + + if len(vr.Issues) != 0 { + t.Fatal("valid operator key should have no validation issues") + } + + uc.AddSigningKey("") // add an invalid one + + vr = &ValidationResults{} + uc.Validate(vr) + if len(vr.Issues) != 0 { + t.Fatal("should not be able to add empty values") + } +} + +func TestSignedBy(t *testing.T) { + ckp := createOperatorNKey(t) + ckp2 := createOperatorNKey(t) + + uc := NewOperatorClaims(publicKey(ckp, t)) + uc2 := NewOperatorClaims(publicKey(ckp2, t)) + + akp := createAccountNKey(t) + ac := NewAccountClaims(publicKey(akp, t)) + enc, err := ac.Encode(ckp) // sign with the operator key + if err != nil { + t.Fatal("failed to encode", err) + } + ac, err = DecodeAccountClaims(enc) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.DidSign(ac), true, t) + AssertEquals(uc2.DidSign(ac), false, t) + + enc, err = ac.Encode(ckp2) // sign with the other operator key + if err != nil { + t.Fatal("failed to encode", err) + } + ac, err = DecodeAccountClaims(enc) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.DidSign(ac), false, t) // no signing key + AssertEquals(uc2.DidSign(ac), true, t) // actual key + uc.AddSigningKey(publicKey(ckp2, t)) + AssertEquals(uc.DidSign(ac), true, t) // signing key + + clusterKey := createClusterNKey(t) + clusterClaims := NewClusterClaims(publicKey(clusterKey, t)) + enc, err = clusterClaims.Encode(ckp2) // sign with the operator key + if err != nil { + t.Fatal("failed to encode", err) + } + clusterClaims, err = DecodeClusterClaims(enc) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.DidSign(clusterClaims), true, t) // signing key + AssertEquals(uc2.DidSign(clusterClaims), true, t) // actual key +} + +func testAccountWithAccountServerURL(t *testing.T, u string) error { + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + oc.AccountServerURL = u + + s, err := oc.Encode(kp) + if err != nil { + return err + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + AssertEquals(oc.AccountServerURL, u, t) + vr := ValidationResults{} + oc.Validate(&vr) + if !vr.IsEmpty() { + errs := vr.Errors() + return errs[0] + } + return nil +} + +func Test_AccountServerURL(t *testing.T) { + var asuTests = []struct { + u string + shouldFail bool + }{ + {"", false}, + {"HTTP://foo.bar.com", false}, + {"http://foo.bar.com/foo/bar", false}, + {"http://user:pass@foo.bar.com/foo/bar", false}, + {"https://foo.bar.com", false}, + {"nats://foo.bar.com", false}, + {"/hello", true}, + } + + for i, tt := range asuTests { + err := testAccountWithAccountServerURL(t, tt.u) + if err != nil && tt.shouldFail == false { + t.Fatalf("expected not to fail: %v", err) + } else if err == nil && tt.shouldFail { + t.Fatalf("test %s expected to fail but didn't", asuTests[i].u) + } + } +} + +func testOperatorWithOperatorServiceURL(t *testing.T, u string) error { + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + oc.OperatorServiceURLs.Add(u) + + s, err := oc.Encode(kp) + if err != nil { + return err + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + if u != "" { + AssertEquals(oc.OperatorServiceURLs[0], u, t) + } + vr := ValidationResults{} + oc.Validate(&vr) + if !vr.IsEmpty() { + errs := vr.Errors() + return errs[0] + } + return nil +} + +func Test_OperatorServiceURL(t *testing.T) { + var asuTests = []struct { + u string + shouldFail bool + }{ + {"", false}, + {"HTTP://foo.bar.com", true}, + {"http://foo.bar.com/foo/bar", true}, + {"nats://user:pass@foo.bar.com", true}, + {"NATS://user:pass@foo.bar.com", true}, + {"NATS://user@foo.bar.com", true}, + {"nats://foo.bar.com/path", true}, + {"tls://foo.bar.com/path", true}, + {"/hello", true}, + {"NATS://foo.bar.com", false}, + {"TLS://foo.bar.com", false}, + {"nats://foo.bar.com", false}, + {"tls://foo.bar.com", false}, + } + + for i, tt := range asuTests { + err := testOperatorWithOperatorServiceURL(t, tt.u) + if err != nil && tt.shouldFail == false { + t.Fatalf("expected not to fail: %v", err) + } else if err == nil && tt.shouldFail { + t.Fatalf("test %s expected to fail but didn't", asuTests[i].u) + } + } + + // now test all of them in a single jwt + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + + encoded := 0 + shouldFail := 0 + for _, v := range asuTests { + oc.OperatorServiceURLs.Add(v.u) + // list won't encode empty strings + if v.u != "" { + encoded++ + } + if v.shouldFail { + shouldFail++ + } + } + + s, err := oc.Encode(kp) + if err != nil { + t.Fatal(err) + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + + AssertEquals(len(oc.OperatorServiceURLs), encoded, t) + + vr := ValidationResults{} + oc.Validate(&vr) + if vr.IsEmpty() { + t.Fatal("should have had errors") + } + + errs := vr.Errors() + AssertEquals(len(errs), shouldFail, t) +} diff --git a/revocation_list.go b/revocation_list.go new file mode 100644 index 0000000..fb1d836 --- /dev/null +++ b/revocation_list.go @@ -0,0 +1,32 @@ +package jwt + +import ( + "time" +) + +// RevocationList is used to store a mapping of public keys to unix timestamps +type RevocationList map[string]int64 + +// Revoke enters a revocation by publickey and timestamp into this export +// If there is already a revocation for this public key that is newer, it is kept. +func (r RevocationList) Revoke(pubKey string, timestamp time.Time) { + newTS := timestamp.Unix() + if ts, ok := r[pubKey]; ok && ts > newTS { + return + } + + r[pubKey] = newTS +} + +// ClearRevocation removes any revocation for the public key +func (r RevocationList) ClearRevocation(pubKey string) { + delete(r, pubKey) +} + +// IsRevoked checks if the public key is in the revoked list with a timestamp later than +// the one passed in. Generally this method is called with time.Now() but other time's can +// be used for testing. +func (r RevocationList) IsRevoked(pubKey string, timestamp time.Time) bool { + ts, ok := r[pubKey] + return ok && ts > timestamp.Unix() +} diff --git a/server_claims.go b/server_claims.go new file mode 100644 index 0000000..c18f167 --- /dev/null +++ b/server_claims.go @@ -0,0 +1,94 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + + "github.com/nats-io/nkeys" +) + +// Server defines the custom part of a server jwt +type Server struct { + Permissions + Cluster string `json:"cluster,omitempty"` +} + +// Validate checks the cluster and permissions for a server JWT +func (s *Server) Validate(vr *ValidationResults) { + if s.Cluster == "" { + vr.AddError("servers can't contain an empty cluster") + } +} + +// ServerClaims defines the data in a server JWT +type ServerClaims struct { + ClaimsData + Server `json:"nats,omitempty"` +} + +// NewServerClaims creates a new server JWT with the specified subject/public key +func NewServerClaims(subject string) *ServerClaims { + if subject == "" { + return nil + } + c := &ServerClaims{} + c.Subject = subject + return c +} + +// Encode tries to turn the server claims into a JWT string +func (s *ServerClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicServerKey(s.Subject) { + return "", errors.New("expected subject to be a server public key") + } + s.ClaimsData.Type = ServerClaim + return s.ClaimsData.Encode(pair, s) +} + +// DecodeServerClaims tries to parse server claims from a JWT string +func DecodeServerClaims(token string) (*ServerClaims, error) { + v := ServerClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +func (s *ServerClaims) String() string { + return s.ClaimsData.String(s) +} + +// Payload returns the server specific data +func (s *ServerClaims) Payload() interface{} { + return &s.Server +} + +// Validate checks the generic and server data in the server claims +func (s *ServerClaims) Validate(vr *ValidationResults) { + s.ClaimsData.Validate(vr) + s.Server.Validate(vr) +} + +// ExpectedPrefixes defines the types that can encode a server JWT, operator or cluster +func (s *ServerClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} +} + +// Claims returns the generic data +func (s *ServerClaims) Claims() *ClaimsData { + return &s.ClaimsData +} diff --git a/server_claims_test.go b/server_claims_test.go new file mode 100644 index 0000000..70fc3d5 --- /dev/null +++ b/server_claims_test.go @@ -0,0 +1,132 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewServerClaims(t *testing.T) { + ckp := createClusterNKey(t) + skp := createServerNKey(t) + + uc := NewServerClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + uc2, err := DecodeServerClaims(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestServerClaimsIssuer(t *testing.T) { + ckp := createClusterNKey(t) + skp := createServerNKey(t) + + uc := NewServerClaims(publicKey(skp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, ckp, t) + + temp, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), true}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), true}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeServerClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode server signed by %q", i.name) + t.Fail() + } + } +} + +func TestServerSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), true}, + {"user", createUserNKey(t), false}, + } + + for _, i := range inputs { + c := NewServerClaims(publicKey(i.kp, t)) + _, err := c.Encode(createOperatorNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode server with with %q subject", i.name) + t.Fail() + } + } +} + +func TestNewNilServerClaims(t *testing.T) { + v := NewServerClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestServerType(t *testing.T) { + c := NewServerClaims(publicKey(createServerNKey(t), t)) + s := encode(c, createClusterNKey(t), t) + u, err := DecodeServerClaims(s) + if err != nil { + t.Fatalf("failed to decode server claim: %v", err) + } + + if ServerClaim != u.Type { + t.Fatalf("type is unexpected %q (wanted server)", u.Type) + } + +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..a1f09fd --- /dev/null +++ b/types.go @@ -0,0 +1,334 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "encoding/json" + "fmt" + "net" + "strings" + "time" +) + +// ExportType defines the type of import/export. +type ExportType int + +const ( + // Unknown is used if we don't know the type + Unknown ExportType = iota + // Stream defines the type field value for a stream "stream" + Stream + // Service defines the type field value for a service "service" + Service +) + +func (t ExportType) String() string { + switch t { + case Stream: + return "stream" + case Service: + return "service" + } + return "unknown" +} + +// MarshalJSON marshals the enum as a quoted json string +func (t *ExportType) MarshalJSON() ([]byte, error) { + switch *t { + case Stream: + return []byte("\"stream\""), nil + case Service: + return []byte("\"service\""), nil + } + return nil, fmt.Errorf("unknown export type") +} + +// UnmarshalJSON unmashals a quoted json string to the enum value +func (t *ExportType) UnmarshalJSON(b []byte) error { + var j string + err := json.Unmarshal(b, &j) + if err != nil { + return err + } + switch j { + case "stream": + *t = Stream + return nil + case "service": + *t = Service + return nil + } + return fmt.Errorf("unknown export type") +} + +// Subject is a string that represents a NATS subject +type Subject string + +// Validate checks that a subject string is valid, ie not empty and without spaces +func (s Subject) Validate(vr *ValidationResults) { + v := string(s) + if v == "" { + vr.AddError("subject cannot be empty") + } + if strings.Contains(v, " ") { + vr.AddError("subject %q cannot have spaces", v) + } +} + +// HasWildCards is used to check if a subject contains a > or * +func (s Subject) HasWildCards() bool { + v := string(s) + return strings.HasSuffix(v, ".>") || + strings.Contains(v, ".*.") || + strings.HasSuffix(v, ".*") || + strings.HasPrefix(v, "*.") || + v == "*" || + v == ">" +} + +// IsContainedIn does a simple test to see if the subject is contained in another subject +func (s Subject) IsContainedIn(other Subject) bool { + otherArray := strings.Split(string(other), ".") + myArray := strings.Split(string(s), ".") + + if len(myArray) > len(otherArray) && otherArray[len(otherArray)-1] != ">" { + return false + } + + if len(myArray) < len(otherArray) { + return false + } + + for ind, tok := range otherArray { + myTok := myArray[ind] + + if ind == len(otherArray)-1 && tok == ">" { + return true + } + + if tok != myTok && tok != "*" { + return false + } + } + + return true +} + +// NamedSubject is the combination of a subject and a name for it +type NamedSubject struct { + Name string `json:"name,omitempty"` + Subject Subject `json:"subject,omitempty"` +} + +// Validate checks the subject +func (ns *NamedSubject) Validate(vr *ValidationResults) { + ns.Subject.Validate(vr) +} + +// TimeRange is used to represent a start and end time +type TimeRange struct { + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` +} + +// Validate checks the values in a time range struct +func (tr *TimeRange) Validate(vr *ValidationResults) { + format := "15:04:05" + + if tr.Start == "" { + vr.AddError("time ranges start must contain a start") + } else { + _, err := time.Parse(format, tr.Start) + if err != nil { + vr.AddError("start in time range is invalid %q", tr.Start) + } + } + + if tr.End == "" { + vr.AddError("time ranges end must contain an end") + } else { + _, err := time.Parse(format, tr.End) + if err != nil { + vr.AddError("end in time range is invalid %q", tr.End) + } + } +} + +// Limits are used to control acccess for users and importing accounts +// Src is a comma separated list of CIDR specifications +type Limits struct { + Max int64 `json:"max,omitempty"` + Payload int64 `json:"payload,omitempty"` + Src string `json:"src,omitempty"` + Times []TimeRange `json:"times,omitempty"` +} + +// Validate checks the values in a limit struct +func (l *Limits) Validate(vr *ValidationResults) { + if l.Max < 0 { + vr.AddError("limits cannot contain a negative maximum, %d", l.Max) + } + if l.Payload < 0 { + vr.AddError("limits cannot contain a negative payload, %d", l.Payload) + } + + if l.Src != "" { + elements := strings.Split(l.Src, ",") + + for _, cidr := range elements { + cidr = strings.TrimSpace(cidr) + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil || ipNet == nil { + vr.AddError("invalid cidr %q in user src limits", cidr) + } + } + } + + if l.Times != nil && len(l.Times) > 0 { + for _, t := range l.Times { + t.Validate(vr) + } + } +} + +// Permission defines allow/deny subjects +type Permission struct { + Allow StringList `json:"allow,omitempty"` + Deny StringList `json:"deny,omitempty"` +} + +// Validate the allow, deny elements of a permission +func (p *Permission) Validate(vr *ValidationResults) { + for _, subj := range p.Allow { + Subject(subj).Validate(vr) + } + for _, subj := range p.Deny { + Subject(subj).Validate(vr) + } +} + +// ResponsePermission can be used to allow responses to any reply subject +// that is received on a valid subscription. +type ResponsePermission struct { + MaxMsgs int `json:"max"` + Expires time.Duration `json:"ttl"` +} + +// Validate the response permission. +func (p *ResponsePermission) Validate(vr *ValidationResults) { + // Any values can be valid for now. +} + +// Permissions are used to restrict subject access, either on a user or for everyone on a server by default +type Permissions struct { + Pub Permission `json:"pub,omitempty"` + Sub Permission `json:"sub,omitempty"` + Resp *ResponsePermission `json:"resp,omitempty"` +} + +// Validate the pub and sub fields in the permissions list +func (p *Permissions) Validate(vr *ValidationResults) { + p.Pub.Validate(vr) + p.Sub.Validate(vr) + if p.Resp != nil { + p.Resp.Validate(vr) + } +} + +// StringList is a wrapper for an array of strings +type StringList []string + +// Contains returns true if the list contains the string +func (u *StringList) Contains(p string) bool { + for _, t := range *u { + if t == p { + return true + } + } + return false +} + +// Add appends 1 or more strings to a list +func (u *StringList) Add(p ...string) { + for _, v := range p { + if !u.Contains(v) && v != "" { + *u = append(*u, v) + } + } +} + +// Remove removes 1 or more strings from a list +func (u *StringList) Remove(p ...string) { + for _, v := range p { + for i, t := range *u { + if t == v { + a := *u + *u = append(a[:i], a[i+1:]...) + break + } + } + } +} + +// TagList is a unique array of lower case strings +// All tag list methods lower case the strings in the arguments +type TagList []string + +// Contains returns true if the list contains the tags +func (u *TagList) Contains(p string) bool { + p = strings.ToLower(p) + for _, t := range *u { + if t == p { + return true + } + } + return false +} + +// Add appends 1 or more tags to a list +func (u *TagList) Add(p ...string) { + for _, v := range p { + v = strings.ToLower(v) + if !u.Contains(v) && v != "" { + *u = append(*u, v) + } + } +} + +// Remove removes 1 or more tags from a list +func (u *TagList) Remove(p ...string) { + for _, v := range p { + v = strings.ToLower(v) + for i, t := range *u { + if t == v { + a := *u + *u = append(a[:i], a[i+1:]...) + break + } + } + } +} + +// Identity is used to associate an account or operator with a real entity +type Identity struct { + ID string `json:"id,omitempty"` + Proof string `json:"proof,omitempty"` +} + +// Validate checks the values in an Identity +func (u *Identity) Validate(vr *ValidationResults) { + //Fixme identity validation +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..4315c16 --- /dev/null +++ b/types_test.go @@ -0,0 +1,266 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "os" + "regexp" + "strings" + "testing" +) + +func TestVersion(t *testing.T) { + // Semantic versioning + verRe := regexp.MustCompile(`\d+.\d+.\d+(-\S+)?`) + if !verRe.MatchString(Version) { + t.Fatalf("Version not compatible with semantic versioning: %q", Version) + } +} + +func TestVersionMatchesTag(t *testing.T) { + tag := os.Getenv("TRAVIS_TAG") + if tag == "" { + t.SkipNow() + } + // We expect a tag of the form vX.Y.Z. If that's not the case, + // we need someone to have a look. So fail if first letter is not + // a `v` + if tag[0] != 'v' { + t.Fatalf("Expect tag to start with `v`, tag is: %s", tag) + } + // Strip the `v` from the tag for the version comparison. + if Version != tag[1:] { + t.Fatalf("Version (%s) does not match tag (%s)", Version, tag[1:]) + } +} + +func TestTimeRangeValidation(t *testing.T) { + tr := TimeRange{ + Start: "hello", + End: "03:15:00", + } + + vr := CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad start should be invalid") + } + + if !strings.Contains(vr.Issues[0].Error(), tr.Start) { + t.Error("error should contain the faulty value") + } + + tr = TimeRange{ + Start: "15:43:22", + End: "27:11:11", + } + + vr = CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad end should be invalid") + } + + if !strings.Contains(vr.Issues[0].Error(), tr.End) { + t.Error("error should contain the faulty value") + } + + tr = TimeRange{ + Start: "", + End: "03:15:00", + } + + vr = CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad start should be invalid") + } + + tr = TimeRange{ + Start: "15:43:22", + End: "", + } + + vr = CreateValidationResults() + tr.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad end should be invalid") + } +} + +func TestTagList(t *testing.T) { + tags := TagList{} + + tags.Add("one") + + AssertEquals(true, tags.Contains("one"), t) + AssertEquals(true, tags.Contains("ONE"), t) + AssertEquals("one", tags[0], t) + + tags.Add("TWO") + + AssertEquals(true, tags.Contains("two"), t) + AssertEquals(true, tags.Contains("TWO"), t) + AssertEquals("two", tags[1], t) + + tags.Remove("ONE") + AssertEquals("two", tags[0], t) + AssertEquals(false, tags.Contains("one"), t) + AssertEquals(false, tags.Contains("ONE"), t) +} + +func TestStringList(t *testing.T) { + slist := StringList{} + + slist.Add("one") + + AssertEquals(true, slist.Contains("one"), t) + AssertEquals(false, slist.Contains("ONE"), t) + AssertEquals("one", slist[0], t) + + slist.Add("TWO") + + AssertEquals(false, slist.Contains("two"), t) + AssertEquals(true, slist.Contains("TWO"), t) + AssertEquals("TWO", slist[1], t) + + slist.Remove("ONE") + AssertEquals("one", slist[0], t) + AssertEquals(true, slist.Contains("one"), t) + AssertEquals(false, slist.Contains("ONE"), t) + + slist.Add("ONE") + AssertEquals(true, slist.Contains("one"), t) + AssertEquals(true, slist.Contains("ONE"), t) + AssertEquals(3, len(slist), t) + + slist.Remove("one") + AssertEquals("TWO", slist[0], t) + AssertEquals(false, slist.Contains("one"), t) + AssertEquals(true, slist.Contains("ONE"), t) +} + +func TestSubjectValid(t *testing.T) { + var s Subject + + vr := CreateValidationResults() + s.Validate(vr) + if !vr.IsBlocking(false) { + t.Fatalf("Empty string is not a valid subjects") + } + + s = "has spaces" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsBlocking(false) { + t.Fatalf("Subjects cannot contain spaces") + } + + s = "has.spa ces.and.tokens" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsBlocking(false) { + t.Fatalf("Subjects cannot have spaces") + } + + s = "one" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsEmpty() { + t.Fatalf("%s is a valid subject", s) + } + + s = "one.two.three" + vr = CreateValidationResults() + s.Validate(vr) + if !vr.IsEmpty() { + t.Fatalf("%s is a valid subject", s) + } +} + +func TestSubjectHasWildCards(t *testing.T) { + s := Subject("one") + AssertEquals(false, s.HasWildCards(), t) + + s = "one.two.three" + AssertEquals(false, s.HasWildCards(), t) + + s = "*" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.*.three" + AssertEquals(true, s.HasWildCards(), t) + + s = "*.two.three" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.two.*" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.>" + AssertEquals(true, s.HasWildCards(), t) + + s = "one.two.>" + AssertEquals(true, s.HasWildCards(), t) + + s = ">" + AssertEquals(true, s.HasWildCards(), t) +} + +func TestSubjectContainment(t *testing.T) { + var s Subject + var o Subject + + s = "one.two.three" + o = "one.two.three" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.two.*" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.*.three" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "*.two.three" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.two.>" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.>" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = ">" + AssertEquals(true, s.IsContainedIn(o), t) + + s = "one.two.three" + o = "one.two" + AssertEquals(false, s.IsContainedIn(o), t) + + s = "one" + o = "one.two" + AssertEquals(false, s.IsContainedIn(o), t) +} diff --git a/user_claims.go b/user_claims.go new file mode 100644 index 0000000..78fe6a9 --- /dev/null +++ b/user_claims.go @@ -0,0 +1,106 @@ +/* + * Copyright 2018-2019 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + + "github.com/nats-io/nkeys" +) + +// User defines the user specific data in a user JWT +type User struct { + Permissions + Limits + BearerToken bool `json:"bearer_token,omitempty"` +} + +// Validate checks the permissions and limits in a User jwt +func (u *User) Validate(vr *ValidationResults) { + u.Permissions.Validate(vr) + u.Limits.Validate(vr) + // When BearerToken is true server will ignore any nonce-signing verification +} + +// UserClaims defines a user JWT +type UserClaims struct { + ClaimsData + User `json:"nats,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` +} + +// NewUserClaims creates a user JWT with the specific subject/public key +func NewUserClaims(subject string) *UserClaims { + if subject == "" { + return nil + } + c := &UserClaims{} + c.Subject = subject + return c +} + +// Encode tries to turn the user claims into a JWT string +func (u *UserClaims) Encode(pair nkeys.KeyPair) (string, error) { + if !nkeys.IsValidPublicUserKey(u.Subject) { + return "", errors.New("expected subject to be user public key") + } + u.ClaimsData.Type = UserClaim + return u.ClaimsData.Encode(pair, u) +} + +// DecodeUserClaims tries to parse a user claims from a JWT string +func DecodeUserClaims(token string) (*UserClaims, error) { + v := UserClaims{} + if err := Decode(token, &v); err != nil { + return nil, err + } + return &v, nil +} + +// Validate checks the generic and specific parts of the user jwt +func (u *UserClaims) Validate(vr *ValidationResults) { + u.ClaimsData.Validate(vr) + u.User.Validate(vr) + if u.IssuerAccount != "" && !nkeys.IsValidPublicAccountKey(u.IssuerAccount) { + vr.AddError("account_id is not an account public key") + } +} + +// ExpectedPrefixes defines the types that can encode a user JWT, account +func (u *UserClaims) ExpectedPrefixes() []nkeys.PrefixByte { + return []nkeys.PrefixByte{nkeys.PrefixByteAccount} +} + +// Claims returns the generic data from a user jwt +func (u *UserClaims) Claims() *ClaimsData { + return &u.ClaimsData +} + +// Payload returns the user specific data from a user JWT +func (u *UserClaims) Payload() interface{} { + return &u.User +} + +func (u *UserClaims) String() string { + return u.ClaimsData.String(u) +} + +// IsBearerToken returns true if nonce-signing requirements should be skipped +func (u *UserClaims) IsBearerToken() bool { + return u.BearerToken +} diff --git a/user_claims_test.go b/user_claims_test.go new file mode 100644 index 0000000..c9da7fe --- /dev/null +++ b/user_claims_test.go @@ -0,0 +1,381 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "fmt" + "testing" + "time" + + "github.com/nats-io/nkeys" +) + +func TestNewUserClaims(t *testing.T) { + akp := createAccountNKey(t) + ukp := createUserNKey(t) + + uc := NewUserClaims(publicKey(ukp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, akp, t) + + uc2, err := DecodeUserClaims(uJwt) + if err != nil { + t.Fatal("failed to decode uc", err) + } + + AssertEquals(uc.String(), uc2.String(), t) + + AssertEquals(uc.Claims() != nil, true, t) + AssertEquals(uc.Payload() != nil, true, t) +} + +func TestUserClaimIssuer(t *testing.T) { + akp := createAccountNKey(t) + ukp := createUserNKey(t) + + uc := NewUserClaims(publicKey(ukp, t)) + uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uJwt := encode(uc, akp, t) + + temp, err := DecodeGeneric(uJwt) + if err != nil { + t.Fatal("failed to decode", err) + } + + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), true}, + {"user", createUserNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + } + + for _, i := range inputs { + bad := encode(temp, i.kp, t) + _, err = DecodeUserClaims(bad) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to decode user signed by %q", i.name) + t.Fail() + } + } +} + +func TestUserSubjects(t *testing.T) { + type kpInputs struct { + name string + kp nkeys.KeyPair + ok bool + } + + inputs := []kpInputs{ + {"account", createAccountNKey(t), false}, + {"cluster", createClusterNKey(t), false}, + {"operator", createOperatorNKey(t), false}, + {"server", createServerNKey(t), false}, + {"user", createUserNKey(t), true}, + } + + for _, i := range inputs { + c := NewUserClaims(publicKey(i.kp, t)) + _, err := c.Encode(createAccountNKey(t)) + if i.ok && err != nil { + t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) + } + if !i.ok && err == nil { + t.Logf("should have failed to encode user with with %q subject", i.name) + t.Fail() + } + } +} + +func TestNewNilUserClaim(t *testing.T) { + v := NewUserClaims("") + if v != nil { + t.Fatal("expected nil user claim") + } +} + +func TestUserType(t *testing.T) { + c := NewUserClaims(publicKey(createUserNKey(t), t)) + s := encode(c, createAccountNKey(t), t) + u, err := DecodeUserClaims(s) + if err != nil { + t.Fatalf("failed to decode user claim: %v", err) + } + + if UserClaim != u.Type { + t.Fatalf("user type is unexpected %q", u.Type) + } +} + +func TestSubjects(t *testing.T) { + s := StringList{} + if len(s) != 0 { + t.Fatalf("expected len 0") + } + if s.Contains("a") { + t.Fatalf("didn't expect 'a'") + } + s.Add("a") + if !s.Contains("a") { + t.Fatalf("expected 'a'") + } + s.Remove("a") + if s.Contains("a") { + t.Fatalf("didn't expect 'a' after removing") + } +} + +func TestUserValidation(t *testing.T) { + ukp := createUserNKey(t) + + uc := NewUserClaims(publicKey(ukp, t)) + uc.Permissions.Pub.Allow.Add("a") + uc.Permissions.Pub.Deny.Add("b") + uc.Permissions.Sub.Allow.Add("a") + uc.Permissions.Sub.Deny.Add("b") + uc.Permissions.Resp = &ResponsePermission{ + MaxMsgs: 10, + Expires: 50 * time.Minute, + } + uc.Limits.Max = 10 + uc.Limits.Payload = 10 + uc.Limits.Src = "192.0.2.0/24" + uc.Limits.Times = []TimeRange{ + { + Start: "01:15:00", + End: "03:15:00", + }, + { + Start: "06:15:00", + End: "09:15:00", + }, + } + + vr := CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("valid user permissions should be valid") + } + uc.Limits.Max = -1 + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Max = 10 + uc.Limits.Payload = -1 + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Payload = 10 + uc.Limits.Src = "hello world" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Payload = 10 + uc.Limits.Src = "hello world" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + tr := TimeRange{ + Start: "hello", + End: "03:15:00", + } + uc.Limits.Src = "192.0.2.0/24" + uc.Limits.Times = append(uc.Limits.Times, tr) + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad limit should be invalid") + } + + uc.Limits.Times = []TimeRange{} + uc.Permissions.Pub.Allow.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } + + uc.Permissions.Pub.Allow.Remove("bad subject") + uc.Permissions.Sub.Allow.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } + + uc.Permissions.Sub.Allow.Remove("bad subject") + uc.Permissions.Pub.Deny.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } + + uc.Permissions.Pub.Deny.Remove("bad subject") + uc.Permissions.Sub.Deny.Add("bad subject") + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 || !vr.IsBlocking(true) { + t.Error("bad permission should be invalid") + } +} + +func TestUserAccountID(t *testing.T) { + akp := createAccountNKey(t) + apk := publicKey(akp, t) + a2kp := createAccountNKey(t) + ac := NewAccountClaims(apk) + ac.SigningKeys.Add(publicKey(a2kp, t)) + + token, err := ac.Encode(akp) + if err != nil { + t.Fatal(err) + } + ac, err = DecodeAccountClaims(token) + if err != nil { + t.Fatal(err) + } + + uc := NewUserClaims(publicKey(createUserNKey(t), t)) + uc.IssuerAccount = apk + userToken, err := uc.Encode(a2kp) + if err != nil { + t.Fatal(err) + } + + uc, err = DecodeUserClaims(userToken) + if err != nil { + t.Fatal(err) + } + + if uc.IssuerAccount != apk { + t.Fatalf("expected AccountID to be set to %s - got %s", apk, uc.IssuerAccount) + } + + signed := ac.DidSign(uc) + if !signed { + t.Fatal("expected user signed by account") + } +} + +func TestUserAccountIDValidation(t *testing.T) { + uc := NewUserClaims(publicKey(createUserNKey(t), t)) + uc.IssuerAccount = publicKey(createAccountNKey(t), t) + var vr ValidationResults + uc.Validate(&vr) + if len(vr.Issues) != 0 { + t.Fatal("expected no issues") + } + + uc.IssuerAccount = publicKey(createUserNKey(t), t) + uc.Validate(&vr) + if len(vr.Issues) != 1 { + t.Fatal("expected validation issues") + } +} + +func TestSourceNetworkValidation(t *testing.T) { + ukp := createUserNKey(t) + uc := NewUserClaims(publicKey(ukp, t)) + + uc.Limits.Src = "192.0.2.0/24" + vr := CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "192.0.2.1/1" + vr = CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "192.0.2.0/24,2001:db8:a0b:12f0::1/32" + vr = CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "192.0.2.0/24 ,\t2001:db8:a0b:12f0::1/32 , 192.168.1.1/1" + vr = CreateValidationResults() + uc.Validate(vr) + + if !vr.IsEmpty() { + t.Error("limits should be valid") + } + + uc.Limits.Src = "foo" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 { + t.Error("limits should be invalid") + } + + uc.Limits.Src = "192.0.2.0/24,foo" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 1 { + t.Error("limits should be invalid") + } + + uc.Limits.Src = "bloo,foo" + vr = CreateValidationResults() + uc.Validate(vr) + + if vr.IsEmpty() || len(vr.Issues) != 2 { + t.Error("limits should be invalid") + } +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..5ccddca --- /dev/null +++ b/util_test.go @@ -0,0 +1,113 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "fmt" + "runtime" + "strings" + "testing" + + "github.com/nats-io/nkeys" +) + +func Trace(message string) string { + lines := make([]string, 0, 32) + err := errors.New(message) + msg := err.Error() + lines = append(lines, msg) + + for i := 2; true; i++ { + _, file, line, ok := runtime.Caller(i) + if !ok { + break + } + msg := fmt.Sprintf("%s:%d", file, line) + lines = append(lines, msg) + } + return strings.Join(lines, "\n") +} + +func AssertEquals(expected, v interface{}, t *testing.T) { + if expected != v { + t.Fatalf("%v", Trace(fmt.Sprintf("The expected value %v != %v", expected, v))) + } +} + +func createAccountNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal("error creating account kp", err) + } + return kp +} + +func createUserNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateUser() + if err != nil { + t.Fatal("error creating account kp", err) + } + return kp +} + +func createOperatorNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateOperator() + if err != nil { + t.Fatal("error creating operator kp", err) + } + return kp +} + +func createServerNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateServer() + if err != nil { + t.Fatal("error creating server kp", err) + } + return kp +} + +func createClusterNKey(t *testing.T) nkeys.KeyPair { + kp, err := nkeys.CreateCluster() + if err != nil { + t.Fatal("error creating cluster kp", err) + } + return kp +} + +func publicKey(kp nkeys.KeyPair, t *testing.T) string { + pk, err := kp.PublicKey() + if err != nil { + t.Fatal("error reading public key", err) + } + return string(pk) +} + +func seedKey(kp nkeys.KeyPair, t *testing.T) []byte { + sk, err := kp.Seed() + if err != nil { + t.Fatal("error reading seed", err) + } + return sk +} + +func encode(c Claims, kp nkeys.KeyPair, t *testing.T) string { + s, err := c.Encode(kp) + if err != nil { + t.Fatal("error encoding claim", err) + } + return s +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..c87a992 --- /dev/null +++ b/validation.go @@ -0,0 +1,107 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "errors" + "fmt" +) + +// ValidationIssue represents an issue during JWT validation, it may or may not be a blocking error +type ValidationIssue struct { + Description string + Blocking bool + TimeCheck bool +} + +func (ve *ValidationIssue) Error() string { + return ve.Description +} + +// ValidationResults is a list of ValidationIssue pointers +type ValidationResults struct { + Issues []*ValidationIssue +} + +// CreateValidationResults creates an empty list of validation issues +func CreateValidationResults() *ValidationResults { + issues := []*ValidationIssue{} + return &ValidationResults{ + Issues: issues, + } +} + +//Add appends an issue to the list +func (v *ValidationResults) Add(vi *ValidationIssue) { + v.Issues = append(v.Issues, vi) +} + +// AddError creates a new validation error and adds it to the list +func (v *ValidationResults) AddError(format string, args ...interface{}) { + v.Add(&ValidationIssue{ + Description: fmt.Sprintf(format, args...), + Blocking: true, + TimeCheck: false, + }) +} + +// AddTimeCheck creates a new validation issue related to a time check and adds it to the list +func (v *ValidationResults) AddTimeCheck(format string, args ...interface{}) { + v.Add(&ValidationIssue{ + Description: fmt.Sprintf(format, args...), + Blocking: false, + TimeCheck: true, + }) +} + +// AddWarning creates a new validation warning and adds it to the list +func (v *ValidationResults) AddWarning(format string, args ...interface{}) { + v.Add(&ValidationIssue{ + Description: fmt.Sprintf(format, args...), + Blocking: false, + TimeCheck: false, + }) +} + +// IsBlocking returns true if the list contains a blocking error +func (v *ValidationResults) IsBlocking(includeTimeChecks bool) bool { + for _, i := range v.Issues { + if i.Blocking { + return true + } + + if includeTimeChecks && i.TimeCheck { + return true + } + } + return false +} + +// IsEmpty returns true if the list is empty +func (v *ValidationResults) IsEmpty() bool { + return len(v.Issues) == 0 +} + +// Errors returns only blocking issues as errors +func (v *ValidationResults) Errors() []error { + var errs []error + for _, v := range v.Issues { + if v.Blocking { + errs = append(errs, errors.New(v.Description)) + } + } + return errs +} From eba618054e4e429280c40ce54c42fe2f48483995 Mon Sep 17 00:00:00 2001 From: aricart Date: Tue, 4 Feb 2020 10:33:00 -0400 Subject: [PATCH 08/14] V2 Changes - V1 Deprecates cluster/server claims, as these are not used - will re-introduced when supported - V2 Removes deprecated API - Resolve JWT namespace squatting. Extra fields leaked through to the root object of the claims - NATS claims add a version number enabling the library to reject claims that it doesn't understand. - Old JWTs are automatically migrated into their v2 formats on read, so reading code is backwards compatible for non-tool clients. - V2 Removes internal use of deprecated fields JWT Namespace Squatting Changes - ClaimsData had `tags` and `type` fields for all JWT types, these are moved to the structure `nats`. - ActivationsClaims and UserClaims had `issuer_account` moved to the `nats` structure. --- .gitignore | 4 +- .travis.yml | 45 +++-- activation_claims_test.go | 2 +- claims.go | 9 +- cluster_claims.go | 6 +- operator_claims.go | 8 +- server_claims.go | 10 +- v2/Makefile | 18 ++ v2/account_claims.go | 27 ++- v2/account_claims_test.go | 21 +-- v2/activation_claims.go | 29 +++- v2/activation_claims_test.go | 10 +- v2/claims.go | 108 +++--------- v2/cluster_claims.go | 94 ----------- v2/cluster_claims_test.go | 132 --------------- v2/creds_utils.go | 12 +- v2/decoder.go | 134 +++++++++++++++ v2/decoder_account.go | 61 +++++++ v2/decoder_activation.go | 57 +++++++ v2/decoder_migration_test.go | 316 +++++++++++++++++++++++++++++++++++ v2/decoder_operator.go | 57 +++++++ v2/decoder_test.go | 2 +- v2/decoder_user.go | 61 +++++++ v2/exports.go | 2 +- v2/exports_test.go | 3 +- v2/genericclaims_test.go | 48 +++--- v2/genericlaims.go | 68 +++++++- v2/go.mod | 10 +- v2/go.sum | 9 - v2/imports.go | 6 +- v2/imports_test.go | 8 +- v2/operator_claims.go | 32 ++-- v2/operator_claims_test.go | 48 +++--- v2/server_claims.go | 94 ----------- v2/server_claims_test.go | 132 --------------- v2/types.go | 4 +- v2/user_claims.go | 27 ++- v2/user_claims_test.go | 4 +- v2/util_test.go | 2 +- v2/validation.go | 2 +- 40 files changed, 1018 insertions(+), 704 deletions(-) create mode 100644 v2/Makefile delete mode 100644 v2/cluster_claims.go delete mode 100644 v2/cluster_claims_test.go create mode 100644 v2/decoder.go create mode 100644 v2/decoder_account.go create mode 100644 v2/decoder_activation.go create mode 100644 v2/decoder_migration_test.go create mode 100644 v2/decoder_operator.go create mode 100644 v2/decoder_user.go delete mode 100644 v2/go.sum delete mode 100644 v2/server_claims.go delete mode 100644 v2/server_claims_test.go diff --git a/.gitignore b/.gitignore index 7117a67..a34877a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ # IDE Files .vscode -.idea/ \ No newline at end of file +.idea/ + +coverage.out \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 50e27a6..9a35fac 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,22 +1,33 @@ +os: linux language: go -sudo: false go: -- 1.13.x -- 1.12.x - + - 1.13.x + - 1.12.x +git: + depth: false +env: + - V= + - V=v2 install: -- go get -t ./... -- go get github.com/mattn/goveralls -- go get github.com/wadey/gocovmerge -- go get -u honnef.co/go/tools/cmd/staticcheck -- go get -u github.com/client9/misspell/cmd/misspell - + - go get -t ./... + - go get -u honnef.co/go/tools/cmd/staticcheck + - go get -u github.com/client9/misspell/cmd/misspell + - go get github.com/mattn/goveralls + - go get github.com/wadey/gocovmerge before_script: -- $(exit $(go fmt ./... | wc -l)) -- go vet ./... -- misspell -error -locale US . -- staticcheck ./... - + - cd $TRAVIS_BUILD_DIR/${V} + - $(exit $(go fmt ./... | wc -l)) + - go vet ./... + - misspell -error -locale US . + - staticcheck ./... script: -- go test -v -race ./... -- if [[ "$TRAVIS_GO_VERSION" =~ 1.12 ]]; then ./scripts/cov.sh TRAVIS; fi + - cd $TRAVIS_BUILD_DIR/${V} + - go test -v -coverprofile=./coverage.out ./... +deploy: + - provider: script + script: $HOME/gopath/bin/goveralls -coverprofile=v2/coverage.out -service travis-ci + on: + tags: true + condition: ${V} = v2 && $TRAVIS_GO_VERSION =~ ^1.13. + + diff --git a/activation_claims_test.go b/activation_claims_test.go index 19532b3..ad4fceb 100644 --- a/activation_claims_test.go +++ b/activation_claims_test.go @@ -114,7 +114,7 @@ func TestInvalidActivationClaimIssuer(t *testing.T) { for _, i := range inputs { bad := encode(temp, i.kp, t) - _, err = DecodeAccountClaims(bad) + _, err = DecodeActivationClaims(bad) if i.ok && err != nil { t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) } diff --git a/claims.go b/claims.go index d402bcc..3179c91 100644 --- a/claims.go +++ b/claims.go @@ -38,12 +38,15 @@ const ( ActivationClaim = "activation" //UserClaim is the type of an user JWT UserClaim = "user" + //OperatorClaim is the type of an operator JWT + OperatorClaim = "operator" + //ServerClaim is the type of an server JWT + // Deprecated: ServerClaim is not supported ServerClaim = "server" - //ClusterClaim is the type of an cluster JWT + // ClusterClaim is the type of an cluster JWT + // Deprecated: ClusterClaim is not supported ClusterClaim = "cluster" - //OperatorClaim is the type of an operator JWT - OperatorClaim = "operator" ) // Claims is a JWT claims diff --git a/cluster_claims.go b/cluster_claims.go index bbfcf06..7924dfa 100644 --- a/cluster_claims.go +++ b/cluster_claims.go @@ -1,5 +1,5 @@ /* - * Copyright 2018 The NATS Authors + * Copyright 2018-2020 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 @@ -22,6 +22,7 @@ import ( ) // Cluster stores the cluster specific elements of a cluster JWT +// Deprecated: ClusterClaims are not supported type Cluster struct { Trust []string `json:"identity,omitempty"` Accounts []string `json:"accts,omitempty"` @@ -35,12 +36,14 @@ func (c *Cluster) Validate(vr *ValidationResults) { } // ClusterClaims defines the data in a cluster JWT +// Deprecated: ClusterClaims are not supported type ClusterClaims struct { ClaimsData Cluster `json:"nats,omitempty"` } // NewClusterClaims creates a new cluster JWT with the specified subject/public key +// Deprecated: ClusterClaims are not supported func NewClusterClaims(subject string) *ClusterClaims { if subject == "" { return nil @@ -60,6 +63,7 @@ func (c *ClusterClaims) Encode(pair nkeys.KeyPair) (string, error) { } // DecodeClusterClaims tries to parse cluster claims from a JWT string +// Deprecated: ClusterClaims are not supported func DecodeClusterClaims(token string) (*ClusterClaims, error) { v := ClusterClaims{} if err := Decode(token, &v); err != nil { diff --git a/operator_claims.go b/operator_claims.go index 6a99597..68d904c 100644 --- a/operator_claims.go +++ b/operator_claims.go @@ -26,7 +26,7 @@ import ( // Operator specific claims type Operator struct { - // Slice of real identies (like websites) that can be used to identify the operator. + // Slice of real identities (like websites) that can be used to identify the operator. Identities []Identity `json:"identity,omitempty"` // Slice of other operator NKeys that can be used to sign on behalf of the main // operator identity. @@ -112,15 +112,15 @@ func ValidateOperatorServiceURL(v string) error { } func (o *Operator) validateOperatorServiceURLs() []error { - var errors []error + var errs []error for _, v := range o.OperatorServiceURLs { if v != "" { if err := ValidateOperatorServiceURL(v); err != nil { - errors = append(errors, err) + errs = append(errs, err) } } } - return errors + return errs } // OperatorClaims define the data for an operator JWT diff --git a/server_claims.go b/server_claims.go index c18f167..587aef9 100644 --- a/server_claims.go +++ b/server_claims.go @@ -1,5 +1,5 @@ /* - * Copyright 2018 The NATS Authors + * Copyright 2018-2020 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 @@ -21,7 +21,7 @@ import ( "github.com/nats-io/nkeys" ) -// Server defines the custom part of a server jwt +// Deprecated: ServerClaims are not supported type Server struct { Permissions Cluster string `json:"cluster,omitempty"` @@ -34,13 +34,13 @@ func (s *Server) Validate(vr *ValidationResults) { } } -// ServerClaims defines the data in a server JWT +// Deprecated: ServerClaims are not supported type ServerClaims struct { ClaimsData Server `json:"nats,omitempty"` } -// NewServerClaims creates a new server JWT with the specified subject/public key +// Deprecated: ServerClaims are not supported func NewServerClaims(subject string) *ServerClaims { if subject == "" { return nil @@ -59,7 +59,7 @@ func (s *ServerClaims) Encode(pair nkeys.KeyPair) (string, error) { return s.ClaimsData.Encode(pair, s) } -// DecodeServerClaims tries to parse server claims from a JWT string +// Deprecated: ServerClaims are not supported func DecodeServerClaims(token string) (*ServerClaims, error) { v := ServerClaims{} if err := Decode(token, &v); err != nil { diff --git a/v2/Makefile b/v2/Makefile new file mode 100644 index 0000000..c805857 --- /dev/null +++ b/v2/Makefile @@ -0,0 +1,18 @@ +.PHONY: test cover + +build: + go build + +fmt: + gofmt -w -s *.go + goimports -w *.go + go mod tidy + +test: + go vet ./... + staticcheck ./... + rm -rf ./coverage.out + go test -coverprofile=./coverage.out ./... + +cover: + go tool cover -html=coverage.out diff --git a/v2/account_claims.go b/v2/account_claims.go index 945bd98..b54c8fa 100644 --- a/v2/account_claims.go +++ b/v2/account_claims.go @@ -49,7 +49,7 @@ func (o *OperatorLimits) IsUnlimited() bool { } // Validate checks that the operator limits contain valid values -func (o *OperatorLimits) Validate(vr *ValidationResults) { +func (o *OperatorLimits) Validate(_ *ValidationResults) { // negative values mean unlimited, so all numbers are valid } @@ -61,6 +61,7 @@ type Account struct { Limits OperatorLimits `json:"limits,omitempty"` SigningKeys StringList `json:"signing_keys,omitempty"` Revocations RevocationList `json:"revocations,omitempty"` + GenericFields } // Validate checks if the account is valid, based on the wrapper @@ -130,17 +131,21 @@ func (a *AccountClaims) Encode(pair nkeys.KeyPair) (string, error) { } sort.Sort(a.Exports) sort.Sort(a.Imports) - a.ClaimsData.Type = AccountClaim + a.Type = AccountClaim return a.ClaimsData.Encode(pair, a) } // DecodeAccountClaims decodes account claims from a JWT string func DecodeAccountClaims(token string) (*AccountClaims, error) { - v := AccountClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + ac, ok := claims.(*AccountClaims) + if !ok { + return nil, errors.New("not account claim") + } + return ac, nil } func (a *AccountClaims) String() string { @@ -167,6 +172,14 @@ func (a *AccountClaims) Validate(vr *ValidationResults) { } } +func (a *AccountClaims) ClaimType() ClaimType { + return a.Type +} + +func (a *AccountClaims) updateVersion() { + a.GenericFields.Version = libVersion +} + // ExpectedPrefixes defines the types that can encode an account jwt, account and operator func (a *AccountClaims) ExpectedPrefixes() []nkeys.PrefixByte { return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} @@ -189,12 +202,12 @@ func (a *AccountClaims) DidSign(op Claims) bool { return false } -// Revoke enters a revocation by publickey using time.Now(). +// Revoke enters a revocation by public key using time.Now(). func (a *AccountClaims) Revoke(pubKey string) { a.RevokeAt(pubKey, time.Now()) } -// RevokeAt enters a revocation by publickey and timestamp into this export +// RevokeAt enters a revocation by public key and timestamp into this export // If there is already a revocation for this public key that is newer, it is kept. func (a *AccountClaims) RevokeAt(pubKey string, timestamp time.Time) { if a.Revocations == nil { diff --git a/v2/account_claims_test.go b/v2/account_claims_test.go index c9fe4a2..dfc98dd 100644 --- a/v2/account_claims_test.go +++ b/v2/account_claims_test.go @@ -31,7 +31,7 @@ func TestNewAccountClaims(t *testing.T) { activation := NewActivationClaims(apk) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream actJWT := encode(activation, akp2, t) @@ -41,7 +41,7 @@ func TestNewAccountClaims(t *testing.T) { t.Fatalf("Expected unlimited operator limits") } - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).UTC().Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).UTC().Unix() account.Imports = Imports{} account.Imports.Add(&Import{Subject: "test", Name: "test import", Account: apk2, Token: actJWT, To: "my", Type: Stream}) @@ -72,7 +72,7 @@ func TestAccountCanSignOperatorLimits(t *testing.T) { // don't block encoding!!! apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 10 account.Limits.LeafNodeConn = 2 @@ -87,7 +87,7 @@ func TestAccountCanSignIdentities(t *testing.T) { // don't block encoding!!! apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Identities = []Identity{ { ID: "stephen", @@ -107,7 +107,7 @@ func TestOperatorCanSignClaims(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 1 account.Limits.LeafNodeConn = 4 @@ -139,7 +139,7 @@ func TestOperatorCanSignClaims(t *testing.T) { func TestInvalidAccountClaimIssuer(t *testing.T) { akp := createAccountNKey(t) ac := NewAccountClaims(publicKey(akp, t)) - ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + ac.Expires = time.Now().Add(time.Hour).Unix() aJwt := encode(ac, akp, t) temp, err := DecodeGeneric(aJwt) @@ -194,9 +194,6 @@ func TestInvalidAccountSubjects(t *testing.T) { var err error c := NewAccountClaims(pk) - if i.ok && err != nil { - t.Fatalf("error encoding activation: %v", err) - } _, err = c.Encode(i.kp) if i.ok && err != nil { t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) @@ -213,7 +210,7 @@ func TestAccountImports(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() actJwt := encode(account, akp, t) @@ -237,7 +234,7 @@ func TestLimitValidationInAccount(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 10 account.Limits.Imports = 10 account.Limits.Exports = 10 @@ -322,7 +319,7 @@ func TestWildcardExportLimit(t *testing.T) { apk := publicKey(akp, t) account := NewAccountClaims(apk) - account.Expires = time.Now().Add(time.Duration(time.Hour * 24 * 365)).Unix() + account.Expires = time.Now().Add(time.Hour * 24 * 365).Unix() account.Limits.Conn = 10 account.Limits.Imports = 10 account.Limits.Exports = 10 diff --git a/v2/activation_claims.go b/v2/activation_claims.go index 99228a7..2fc78ef 100644 --- a/v2/activation_claims.go +++ b/v2/activation_claims.go @@ -28,8 +28,12 @@ import ( // Activation defines the custom parts of an activation claim type Activation struct { ImportSubject Subject `json:"subject,omitempty"` - ImportType ExportType `json:"type,omitempty"` + ImportType ExportType `json:"kind,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` Limits + GenericFields } // IsService returns true if an Activation is for a service @@ -62,9 +66,6 @@ func (a *Activation) Validate(vr *ValidationResults) { type ActivationClaims struct { ClaimsData Activation `json:"nats,omitempty"` - // IssuerAccount stores the public key for the account the issuer represents. - // When set, the claim was issued by a signing key. - IssuerAccount string `json:"issuer_account,omitempty"` } // NewActivationClaims creates a new activation claim with the provided sub @@ -82,17 +83,21 @@ func (a *ActivationClaims) Encode(pair nkeys.KeyPair) (string, error) { if !nkeys.IsValidPublicAccountKey(a.ClaimsData.Subject) { return "", errors.New("expected subject to be an account") } - a.ClaimsData.Type = ActivationClaim + a.Type = ActivationClaim return a.ClaimsData.Encode(pair, a) } // DecodeActivationClaims tries to create an activation claim from a JWT string func DecodeActivationClaims(token string) (*ActivationClaims, error) { - v := ActivationClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + ac, ok := claims.(*ActivationClaims) + if !ok { + return nil, errors.New("not activation claim") + } + return ac, nil } // Payload returns the activation specific part of the JWT @@ -109,6 +114,14 @@ func (a *ActivationClaims) Validate(vr *ValidationResults) { } } +func (a *ActivationClaims) ClaimType() ClaimType { + return a.Type +} + +func (a *ActivationClaims) updateVersion() { + a.GenericFields.Version = libVersion +} + // ExpectedPrefixes defines the types that can sign an activation jwt, account and oeprator func (a *ActivationClaims) ExpectedPrefixes() []nkeys.PrefixByte { return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator} diff --git a/v2/activation_claims_test.go b/v2/activation_claims_test.go index 19532b3..e9f3c95 100644 --- a/v2/activation_claims_test.go +++ b/v2/activation_claims_test.go @@ -30,7 +30,7 @@ func TestNewActivationClaims(t *testing.T) { activation := NewActivationClaims(apk) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + activation.Expires = time.Now().Add(time.Hour).Unix() activation.Limits.Max = 10 activation.Limits.Payload = 10 activation.Limits.Src = "192.0.2.0/24" @@ -90,7 +90,7 @@ func TestInvalidActivationTargets(t *testing.T) { func TestInvalidActivationClaimIssuer(t *testing.T) { akp := createAccountNKey(t) ac := NewActivationClaims(publicKey(akp, t)) - ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + ac.Expires = time.Now().Add(time.Hour).Unix() aJwt := encode(ac, akp, t) temp, err := DecodeGeneric(aJwt) @@ -114,7 +114,7 @@ func TestInvalidActivationClaimIssuer(t *testing.T) { for _, i := range inputs { bad := encode(temp, i.kp, t) - _, err = DecodeAccountClaims(bad) + _, err = DecodeActivationClaims(bad) if i.ok && err != nil { t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) } @@ -215,7 +215,7 @@ func TestActivationValidation(t *testing.T) { activation := NewActivationClaims(apk) activation.Issuer = apk activation.Subject = apk2 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + activation.Expires = time.Now().Add(time.Hour).Unix() activation.ImportSubject = "foo" activation.Name = "Foo" @@ -371,7 +371,7 @@ func TestActivationClaimAccountIDValidation(t *testing.T) { ac := NewActivationClaims(importerPK) ac.IssuerAccount = issuerAccountPK ac.Name = "foo.bar" - ac.Activation.ImportSubject = Subject("foo.bar") + ac.Activation.ImportSubject = "foo.bar" ac.Activation.ImportType = Stream var vr ValidationResults diff --git a/v2/claims.go b/v2/claims.go index d402bcc..6da71f1 100644 --- a/v2/claims.go +++ b/v2/claims.go @@ -22,7 +22,6 @@ import ( "encoding/json" "errors" "fmt" - "strings" "time" "github.com/nats-io/nkeys" @@ -32,18 +31,14 @@ import ( type ClaimType string const ( + //OperatorClaim is the type of an operator JWT + OperatorClaim = "operator" // AccountClaim is the type of an Account JWT AccountClaim = "account" - //ActivationClaim is the type of an activation JWT - ActivationClaim = "activation" //UserClaim is the type of an user JWT UserClaim = "user" - //ServerClaim is the type of an server JWT - ServerClaim = "server" - //ClusterClaim is the type of an cluster JWT - ClusterClaim = "cluster" - //OperatorClaim is the type of an operator JWT - OperatorClaim = "operator" + //ActivationClaim is the type of an activation JWT + ActivationClaim = "activation" ) // Claims is a JWT claims @@ -55,20 +50,26 @@ type Claims interface { String() string Validate(vr *ValidationResults) Verify(payload string, sig []byte) bool + ClaimType() ClaimType + updateVersion() +} + +type GenericFields struct { + Tags TagList `json:"tags,omitempty"` + Type ClaimType `json:"type,omitempty"` + Version int `json:"version,omitempty"` } // ClaimsData is the base struct for all claims type ClaimsData struct { - Audience string `json:"aud,omitempty"` - Expires int64 `json:"exp,omitempty"` - ID string `json:"jti,omitempty"` - IssuedAt int64 `json:"iat,omitempty"` - Issuer string `json:"iss,omitempty"` - Name string `json:"name,omitempty"` - NotBefore int64 `json:"nbf,omitempty"` - Subject string `json:"sub,omitempty"` - Tags TagList `json:"tags,omitempty"` - Type ClaimType `json:"type,omitempty"` + Audience string `json:"aud,omitempty"` + Expires int64 `json:"exp,omitempty"` + ID string `json:"jti,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + Issuer string `json:"iss,omitempty"` + Name string `json:"name,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Subject string `json:"sub,omitempty"` } // Prefix holds the prefix byte for an NKey @@ -147,7 +148,7 @@ func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (s } } - c.Issuer = string(issuerBytes) + c.Issuer = issuerBytes c.IssuedAt = time.Now().UTC().Unix() c.ID, err = c.hash() @@ -155,6 +156,8 @@ func (c *ClaimsData) doEncode(header *Header, kp nkeys.KeyPair, claim Claims) (s return "", err } + claim.updateVersion() + payload, err := serialize(claim) if err != nil { return "", err @@ -235,68 +238,3 @@ func (c *ClaimsData) Validate(vr *ValidationResults) { func (c *ClaimsData) IsSelfSigned() bool { return c.Issuer == c.Subject } - -// Decode takes a JWT string decodes it and validates it -// and return the embedded Claims. If the token header -// doesn't match the expected algorithm, or the claim is -// not valid or verification fails an error is returned. -func Decode(token string, target Claims) error { - // must have 3 chunks - chunks := strings.Split(token, ".") - if len(chunks) != 3 { - return errors.New("expected 3 chunks") - } - - _, err := parseHeaders(chunks[0]) - if err != nil { - return err - } - - if err := parseClaims(chunks[1], target); err != nil { - return err - } - - sig, err := decodeString(chunks[2]) - if err != nil { - return err - } - - if !target.Verify(chunks[1], sig) { - return errors.New("claim failed signature verification") - } - - prefixes := target.ExpectedPrefixes() - if prefixes != nil { - ok := false - issuer := target.Claims().Issuer - for _, p := range prefixes { - switch p { - case nkeys.PrefixByteAccount: - if nkeys.IsValidPublicAccountKey(issuer) { - ok = true - } - case nkeys.PrefixByteOperator: - if nkeys.IsValidPublicOperatorKey(issuer) { - ok = true - } - case nkeys.PrefixByteServer: - if nkeys.IsValidPublicServerKey(issuer) { - ok = true - } - case nkeys.PrefixByteCluster: - if nkeys.IsValidPublicClusterKey(issuer) { - ok = true - } - case nkeys.PrefixByteUser: - if nkeys.IsValidPublicUserKey(issuer) { - ok = true - } - } - } - if !ok { - return fmt.Errorf("unable to validate expected prefixes - %v", prefixes) - } - } - - return nil -} diff --git a/v2/cluster_claims.go b/v2/cluster_claims.go deleted file mode 100644 index bbfcf06..0000000 --- a/v2/cluster_claims.go +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "errors" - - "github.com/nats-io/nkeys" -) - -// Cluster stores the cluster specific elements of a cluster JWT -type Cluster struct { - Trust []string `json:"identity,omitempty"` - Accounts []string `json:"accts,omitempty"` - AccountURL string `json:"accturl,omitempty"` - OperatorURL string `json:"opurl,omitempty"` -} - -// Validate checks the cluster and permissions for a cluster JWT -func (c *Cluster) Validate(vr *ValidationResults) { - // fixme validate cluster data -} - -// ClusterClaims defines the data in a cluster JWT -type ClusterClaims struct { - ClaimsData - Cluster `json:"nats,omitempty"` -} - -// NewClusterClaims creates a new cluster JWT with the specified subject/public key -func NewClusterClaims(subject string) *ClusterClaims { - if subject == "" { - return nil - } - c := &ClusterClaims{} - c.Subject = subject - return c -} - -// Encode tries to turn the cluster claims into a JWT string -func (c *ClusterClaims) Encode(pair nkeys.KeyPair) (string, error) { - if !nkeys.IsValidPublicClusterKey(c.Subject) { - return "", errors.New("expected subject to be a cluster public key") - } - c.ClaimsData.Type = ClusterClaim - return c.ClaimsData.Encode(pair, c) -} - -// DecodeClusterClaims tries to parse cluster claims from a JWT string -func DecodeClusterClaims(token string) (*ClusterClaims, error) { - v := ClusterClaims{} - if err := Decode(token, &v); err != nil { - return nil, err - } - return &v, nil -} - -func (c *ClusterClaims) String() string { - return c.ClaimsData.String(c) -} - -// Payload returns the cluster specific data -func (c *ClusterClaims) Payload() interface{} { - return &c.Cluster -} - -// Validate checks the generic and cluster data in the cluster claims -func (c *ClusterClaims) Validate(vr *ValidationResults) { - c.ClaimsData.Validate(vr) - c.Cluster.Validate(vr) -} - -// ExpectedPrefixes defines the types that can encode a cluster JWT, operator or cluster -func (c *ClusterClaims) ExpectedPrefixes() []nkeys.PrefixByte { - return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} -} - -// Claims returns the generic data -func (c *ClusterClaims) Claims() *ClaimsData { - return &c.ClaimsData -} diff --git a/v2/cluster_claims_test.go b/v2/cluster_claims_test.go deleted file mode 100644 index 5573c8d..0000000 --- a/v2/cluster_claims_test.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "fmt" - "testing" - "time" - - "github.com/nats-io/nkeys" -) - -func TestNewClusterClaims(t *testing.T) { - ckp := createClusterNKey(t) - skp := createClusterNKey(t) - - uc := NewClusterClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - uc2, err := DecodeClusterClaims(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - AssertEquals(uc.String(), uc2.String(), t) - - AssertEquals(uc.Claims() != nil, true, t) - AssertEquals(uc.Payload() != nil, true, t) -} - -func TestClusterClaimsIssuer(t *testing.T) { - ckp := createClusterNKey(t) - skp := createClusterNKey(t) - - uc := NewClusterClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - temp, err := DecodeGeneric(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"user", createUserNKey(t), false}, - {"operator", createOperatorNKey(t), true}, - {"server", createServerNKey(t), false}, - {"cluster", createClusterNKey(t), true}, - } - - for _, i := range inputs { - bad := encode(temp, i.kp, t) - _, err = DecodeClusterClaims(bad) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to decode cluster signed by %q", i.name) - t.Fail() - } - } -} - -func TestClusterSubjects(t *testing.T) { - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"server", createServerNKey(t), false}, - {"operator", createOperatorNKey(t), false}, - {"cluster", createClusterNKey(t), true}, - {"user", createUserNKey(t), false}, - } - - for _, i := range inputs { - c := NewClusterClaims(publicKey(i.kp, t)) - _, err := c.Encode(createOperatorNKey(t)) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to encode cluster with with %q subject", i.name) - t.Fail() - } - } -} - -func TestNewNilClusterClaims(t *testing.T) { - v := NewClusterClaims("") - if v != nil { - t.Fatal("expected nil user claim") - } -} - -func TestClusterType(t *testing.T) { - c := NewClusterClaims(publicKey(createClusterNKey(t), t)) - s := encode(c, createClusterNKey(t), t) - u, err := DecodeClusterClaims(s) - if err != nil { - t.Fatalf("failed to decode cluster claim: %v", err) - } - - if ClusterClaim != u.Type { - t.Fatalf("type is unexpected %q (wanted cluster)", u.Type) - } - -} diff --git a/v2/creds_utils.go b/v2/creds_utils.go index 265057f..32e650d 100644 --- a/v2/creds_utils.go +++ b/v2/creds_utils.go @@ -12,11 +12,11 @@ import ( // DecorateJWT returns a decorated JWT that describes the kind of JWT func DecorateJWT(jwtString string) ([]byte, error) { - gc, err := DecodeGeneric(jwtString) + gc, err := Decode(jwtString) if err != nil { return nil, err } - return formatJwt(string(gc.Type), jwtString) + return formatJwt(string(gc.ClaimType()), jwtString) } func formatJwt(kind string, jwtString string) ([]byte, error) { @@ -99,17 +99,17 @@ var userConfigRE = regexp.MustCompile(`\s*(?:(?:[-]{3,}.*[-]{3,}\r?\n)([\w\-.=]+ // FormatUserConfig returns a decorated file with a decorated JWT and decorated seed func FormatUserConfig(jwtString string, seed []byte) ([]byte, error) { - gc, err := DecodeGeneric(jwtString) + gc, err := Decode(jwtString) if err != nil { return nil, err } - if gc.Type != UserClaim { - return nil, fmt.Errorf("%q cannot be serialized as a user config", string(gc.Type)) + if gc.ClaimType() != UserClaim { + return nil, fmt.Errorf("%q cannot be serialized as a user config", string(gc.ClaimType())) } w := bytes.NewBuffer(nil) - jd, err := formatJwt(string(gc.Type), jwtString) + jd, err := formatJwt(string(gc.ClaimType()), jwtString) if err != nil { return nil, err } diff --git a/v2/decoder.go b/v2/decoder.go new file mode 100644 index 0000000..8d63af6 --- /dev/null +++ b/v2/decoder.go @@ -0,0 +1,134 @@ +package jwt + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/nats-io/nkeys" +) + +const libVersion = 2 + +type identifier struct { + Type ClaimType `json:"type,omitempty"` + GenericFields `json:"nats,omitempty"` +} + +func (i *identifier) Kind() ClaimType { + if i.Type != "" { + return i.Type + } + return i.GenericFields.Type +} + +func (i *identifier) Version() int { + if i.Type != "" { + return 1 + } + return i.GenericFields.Version +} + +type v1ClaimsDataDeletedFields struct { + Tags TagList `json:"tags,omitempty"` + Type ClaimType `json:"type,omitempty"` + IssuerAccount string `json:"issuer_account,omitempty"` +} + +// Decode takes a JWT string decodes it and validates it +// and return the embedded Claims. If the token header +// doesn't match the expected algorithm, or the claim is +// not valid or verification fails an error is returned. +func Decode(token string) (Claims, error) { + // must have 3 chunks + chunks := strings.Split(token, ".") + if len(chunks) != 3 { + return nil, errors.New("expected 3 chunks") + } + + // header + if _, err := parseHeaders(chunks[0]); err != nil { + return nil, err + } + // claim + data, err := decodeString(chunks[1]) + if err != nil { + return nil, err + } + claim, err := loadClaims(data) + if err != nil { + return nil, err + } + + // sig + sig, err := decodeString(chunks[2]) + if err != nil { + return nil, err + } + if !claim.Verify(chunks[1], sig) { + return nil, errors.New("claim failed signature verification") + } + + prefixes := claim.ExpectedPrefixes() + if prefixes != nil { + ok := false + issuer := claim.Claims().Issuer + for _, p := range prefixes { + switch p { + case nkeys.PrefixByteAccount: + if nkeys.IsValidPublicAccountKey(issuer) { + ok = true + } + case nkeys.PrefixByteOperator: + if nkeys.IsValidPublicOperatorKey(issuer) { + ok = true + } + case nkeys.PrefixByteUser: + if nkeys.IsValidPublicUserKey(issuer) { + ok = true + } + } + } + if !ok { + return nil, fmt.Errorf("unable to validate expected prefixes - %v", prefixes) + } + } + return claim, nil +} + +func loadClaims(data []byte) (Claims, error) { + var id identifier + if err := json.Unmarshal(data, &id); err != nil { + return nil, err + } + + if id.Version() > libVersion { + return nil, errors.New("JWT was generated by a newer version ") + } + + var claim Claims + var err error + switch id.Kind() { + case OperatorClaim: + claim, err = loadOperator(data, id.Version()) + case AccountClaim: + claim, err = loadAccount(data, id.Version()) + case UserClaim: + claim, err = loadUser(data, id.Version()) + case ActivationClaim: + claim, err = loadActivation(data, id.Version()) + case "cluster": + return nil, errors.New("ClusterClaims are not supported") + case "server": + return nil, errors.New("ServerClaims are not supported") + default: + var gc GenericClaims + if err := json.Unmarshal(data, &gc); err != nil { + return nil, err + } + return &gc, nil + } + + return claim, err +} diff --git a/v2/decoder_account.go b/v2/decoder_account.go new file mode 100644 index 0000000..f934177 --- /dev/null +++ b/v2/decoder_account.go @@ -0,0 +1,61 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +type v1NatsAccount struct { + Imports Imports `json:"imports,omitempty"` + Exports Exports `json:"exports,omitempty"` + Identities []Identity `json:"identity,omitempty"` + Limits OperatorLimits `json:"limits,omitempty"` + SigningKeys StringList `json:"signing_keys,omitempty"` + Revocations RevocationList `json:"revocations,omitempty"` +} + +func loadAccount(data []byte, version int) (*AccountClaims, error) { + switch version { + case 1: + var v1a v1AccountClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a AccountClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +type v1AccountClaims struct { + ClaimsData + v1ClaimsDataDeletedFields + v1NatsAccount `json:"nats,omitempty"` +} + +func (oa v1AccountClaims) Migrate() (*AccountClaims, error) { + return oa.migrateV1() +} + +func (oa v1AccountClaims) migrateV1() (*AccountClaims, error) { + var a AccountClaims + // copy the base claim + a.ClaimsData = oa.ClaimsData + // move the moved fields + a.Account.Type = oa.v1ClaimsDataDeletedFields.Type + a.Account.Tags = oa.v1ClaimsDataDeletedFields.Tags + // copy the account data + a.Account.Imports = oa.v1NatsAccount.Imports + a.Account.Exports = oa.v1NatsAccount.Exports + a.Account.Identities = oa.v1NatsAccount.Identities + a.Account.Limits = oa.v1NatsAccount.Limits + a.Account.SigningKeys = oa.v1NatsAccount.SigningKeys + a.Account.Revocations = oa.v1NatsAccount.Revocations + return &a, nil +} diff --git a/v2/decoder_activation.go b/v2/decoder_activation.go new file mode 100644 index 0000000..aba0975 --- /dev/null +++ b/v2/decoder_activation.go @@ -0,0 +1,57 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +// Migration adds GenericFields +type v1NatsActivation struct { + ImportSubject Subject `json:"subject,omitempty"` + ImportType ExportType `json:"type,omitempty"` + Limits +} + +type v1ActivationClaims struct { + ClaimsData + v1ClaimsDataDeletedFields + v1NatsActivation `json:"nats,omitempty"` +} + +func loadActivation(data []byte, version int) (*ActivationClaims, error) { + switch version { + case 1: + var v1a v1ActivationClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a ActivationClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +func (oa v1ActivationClaims) Migrate() (*ActivationClaims, error) { + return oa.migrateV1() +} + +func (oa v1ActivationClaims) migrateV1() (*ActivationClaims, error) { + var a ActivationClaims + // copy the base claim + a.ClaimsData = oa.ClaimsData + // move the moved fields + a.Activation.Type = oa.v1ClaimsDataDeletedFields.Type + a.Activation.Tags = oa.v1ClaimsDataDeletedFields.Tags + a.Activation.IssuerAccount = oa.v1ClaimsDataDeletedFields.IssuerAccount + // copy the activation data + a.ImportSubject = oa.ImportSubject + a.ImportType = oa.ImportType + a.Limits = oa.Limits + return &a, nil +} diff --git a/v2/decoder_migration_test.go b/v2/decoder_migration_test.go new file mode 100644 index 0000000..0484379 --- /dev/null +++ b/v2/decoder_migration_test.go @@ -0,0 +1,316 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jwt + +import ( + "strings" + "testing" + "time" + + v1jwt "github.com/nats-io/jwt" + "github.com/nats-io/nkeys" + "github.com/stretchr/testify/require" +) + +func createExport(sub string) *v1jwt.Export { + var e v1jwt.Export + e.Type = v1jwt.Service + e.Subject = v1jwt.Subject(sub) + e.Name = "foo" + e.TokenReq = true + e.ResponseType = v1jwt.ResponseTypeSingleton + return &e +} + +func createImport(t *testing.T, e *v1jwt.Export, target string, signer nkeys.KeyPair) *v1jwt.Import { + var i v1jwt.Import + i.Account = target + i.Subject = e.Subject + i.Type = e.Type + i.Name = e.Name + if e.TokenReq { + i.Token = createActivation(t, e, target, signer) + i.To = v1jwt.Subject(e.Name) + } + return &i +} + +func createActivation(t *testing.T, e *v1jwt.Export, target string, signer nkeys.KeyPair) string { + ac := v1jwt.NewActivationClaims(target) + ac.Name = e.Name + ac.ImportType = e.Type + s := strings.Replace(string(e.Subject), "*", target, -1) + ac.ImportSubject = v1jwt.Subject(s) + tok, err := ac.Encode(signer) + require.NoError(t, err) + return tok +} + +func TestMigrateOperator(t *testing.T) { + okp, err := nkeys.CreateOperator() + require.NoError(t, err) + + opk, err := okp.PublicKey() + require.NoError(t, err) + + oc := v1jwt.NewOperatorClaims(opk) + oc.Name = "O" + oc.Audience = "Audience" + + now := time.Now() + oc.NotBefore = now.Unix() + e := now.Add(time.Hour) + oc.ClaimsData.Expires = e.Unix() + + oc.Tags.Add("a") + + oc.OperatorServiceURLs.Add("nats://localhost:4222") + oc.AccountServerURL = "http://localhost:9090/jwt/v1" + + sk, err := nkeys.CreateOperator() + require.NoError(t, err) + psk, err := sk.PublicKey() + require.NoError(t, err) + oc.Operator.SigningKeys.Add(psk) + + oc.Identities = append(oc.Identities, v1jwt.Identity{ + ID: "O", + Proof: "http://www.o.com/o", + }) + + token, err := oc.Encode(okp) + require.NoError(t, err) + + c, err := Decode(token) + require.NoError(t, err) + oc2, ok := c.(*OperatorClaims) + require.True(t, ok) + + equalOperators(t, oc, oc2) +} + +func TestMigrateAccount(t *testing.T) { + okp, err := nkeys.CreateOperator() + require.NoError(t, err) + + akp, err := nkeys.CreateAccount() + require.NoError(t, err) + apk, err := akp.PublicKey() + require.NoError(t, err) + + ac := v1jwt.NewAccountClaims(apk) + ac.Name = "A" + ac.Audience = "Audience" + + now := time.Now() + ac.NotBefore = now.Unix() + e := now.Add(time.Hour) + ac.ClaimsData.Expires = e.Unix() + ac.Tags.Add("a") + + // create an import + ea, err := nkeys.CreateAccount() + require.NoError(t, err) + hex := createExport("help") + ac.Imports.Add(createImport(t, hex, apk, ea)) + + // add an export + ac.Exports = append(ac.Exports, createExport("q")) + + // add an identity + ac.Identities = append(ac.Identities, v1jwt.Identity{ + ID: "A", + Proof: "http://www.a.com/a", + }) + + // set the limits + ac.Limits.Subs = 1 + ac.Limits.Conn = 2 + ac.Limits.LeafNodeConn = 4 + ac.Limits.Imports = 8 + ac.Limits.Exports = 16 + ac.Limits.Data = 32 + ac.Limits.Payload = 64 + ac.Limits.WildcardExports = true + + // add a signing key + sk, err := nkeys.CreateAccount() + require.NoError(t, err) + psk, err := sk.PublicKey() + require.NoError(t, err) + ac.Account.SigningKeys.Add(psk) + + // add a revocation + ukp, err := nkeys.CreateUser() + require.NoError(t, err) + upk, err := ukp.PublicKey() + require.NoError(t, err) + ac.Revocations = make(map[string]int64) + ac.Revocations.Revoke(upk, time.Now()) + + token, err := ac.Encode(okp) + require.NoError(t, err) + + c, err := Decode(token) + require.NoError(t, err) + ac2, ok := c.(*AccountClaims) + require.True(t, ok) + equalAccounts(t, ac, ac2) +} + +func TestMigrateUser(t *testing.T) { + + ukp, err := nkeys.CreateUser() + require.NoError(t, err) + upk, err := ukp.PublicKey() + require.NoError(t, err) + + uc := v1jwt.NewUserClaims(upk) + uc.Name = "U" + uc.Audience = "Audience" + + now := time.Now() + uc.NotBefore = now.Unix() + e := now.Add(time.Hour) + uc.ClaimsData.Expires = e.Unix() + uc.Tags.Add("a") + + uc.Permissions.Sub.Allow.Add("q") + uc.Permissions.Sub.Deny.Add("d") + + uc.Permissions.Pub.Allow.Add("help") + uc.Permissions.Pub.Deny.Add("pleh") + + uc.Permissions.Resp = &v1jwt.ResponsePermission{} + uc.Permissions.Resp.MaxMsgs = 100 + uc.Permissions.Resp.Expires = time.Second + + uc.BearerToken = true + + akp, err := nkeys.CreateAccount() + require.NoError(t, err) + tok, err := uc.Encode(akp) + require.NoError(t, err) + + c, err := Decode(tok) + require.NoError(t, err) + uc2, ok := c.(*UserClaims) + require.True(t, ok) + + equalUsers(t, uc, uc2) +} + +func equalClaims(t *testing.T, o *v1jwt.ClaimsData, n *ClaimsData, gf *GenericFields) { + require.Equal(t, o.Subject, n.Subject) + require.Equal(t, o.Issuer, n.Issuer) + require.Equal(t, o.Name, n.Name) + require.Equal(t, o.Audience, n.Audience) + require.Equal(t, o.NotBefore, n.NotBefore) + require.Equal(t, o.Expires, n.Expires) + require.Equal(t, string(o.Type), string(gf.Type)) + require.EqualValues(t, o.Tags, gf.Tags) +} + +func equalOperators(t *testing.T, o *v1jwt.OperatorClaims, n *OperatorClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) + for _, v := range o.OperatorServiceURLs { + require.Contains(t, n.Operator.OperatorServiceURLs, v) + } + for _, v := range o.SigningKeys { + require.Contains(t, n.Operator.SigningKeys, v) + } + + require.Equal(t, o.Identities[0].ID, n.Operator.Identities[0].ID) + require.Equal(t, o.Identities[0].Proof, n.Operator.Identities[0].Proof) +} + +func equalAccounts(t *testing.T, o *v1jwt.AccountClaims, n *AccountClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) + equalImports(t, o.Imports[0], n.Imports[0]) + equalExports(t, o.Exports[0], n.Exports[0]) + require.Equal(t, o.Identities[0].ID, n.Account.Identities[0].ID) + require.Equal(t, o.Identities[0].Proof, n.Account.Identities[0].Proof) + equalLimits(t, &o.Account.Limits, &n.Account.Limits) + for _, v := range o.SigningKeys { + require.Contains(t, n.Account.SigningKeys, v) + } +} + +func equalUsers(t *testing.T, o *v1jwt.UserClaims, n *UserClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) + for _, v := range o.Sub.Allow { + require.True(t, n.Sub.Allow.Contains(v)) + } + for _, v := range o.Pub.Allow { + require.True(t, n.Pub.Allow.Contains(v)) + } + for _, v := range o.Sub.Deny { + require.True(t, n.Sub.Deny.Contains(v)) + } + for _, v := range o.Pub.Deny { + require.True(t, n.Pub.Deny.Contains(v)) + } + if o.User.Resp == nil { + require.Nil(t, n.User.Resp) + } else { + require.Equal(t, o.User.Resp.Expires, n.User.Resp.Expires) + require.Equal(t, o.User.Resp.MaxMsgs, n.User.Resp.MaxMsgs) + } + if o.IssuerAccount != "" { + require.Equal(t, o.IssuerAccount, n.User.IssuerAccount) + } + require.Equal(t, o.User.BearerToken, n.User.BearerToken) +} + +func equalExports(t *testing.T, o *v1jwt.Export, n *Export) { + require.Equal(t, o.Name, n.Name) + require.Equal(t, string(o.Subject), string(n.Subject)) + require.EqualValues(t, o.Type, n.Type) + require.Equal(t, o.TokenReq, n.TokenReq) + require.EqualValues(t, o.ResponseType, n.ResponseType) +} + +func equalImports(t *testing.T, o *v1jwt.Import, n *Import) { + require.Equal(t, o.Name, n.Name) + require.Equal(t, string(o.Subject), string(n.Subject)) + require.Equal(t, string(o.To), string(n.To)) + require.EqualValues(t, o.Type, n.Type) + + if o.Token != "" { + ot, err := v1jwt.DecodeActivationClaims(o.Token) + require.NoError(t, err) + nt, err := DecodeActivationClaims(n.Token) + require.NoError(t, err) + equalActivation(t, ot, nt) + } +} + +func equalActivation(t *testing.T, o *v1jwt.ActivationClaims, n *ActivationClaims) { + equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.Activation.GenericFields) + require.Equal(t, string(o.ImportSubject), string(n.ImportSubject)) + require.EqualValues(t, o.ImportType, n.ImportType) +} + +func equalLimits(t *testing.T, o *v1jwt.OperatorLimits, n *OperatorLimits) { + require.Equal(t, o.Subs, n.Subs) + require.Equal(t, o.Conn, n.Conn) + require.Equal(t, o.LeafNodeConn, n.LeafNodeConn) + require.Equal(t, o.Imports, n.Imports) + require.Equal(t, o.Exports, n.Exports) + require.Equal(t, o.Data, n.Data) + require.Equal(t, o.Payload, n.Payload) + require.Equal(t, o.WildcardExports, n.WildcardExports) +} diff --git a/v2/decoder_operator.go b/v2/decoder_operator.go new file mode 100644 index 0000000..74a2253 --- /dev/null +++ b/v2/decoder_operator.go @@ -0,0 +1,57 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +type v1NatsOperator struct { + Identities []Identity `json:"identity,omitempty"` + SigningKeys StringList `json:"signing_keys,omitempty"` + AccountServerURL string `json:"account_server_url,omitempty"` + OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` +} + +func loadOperator(data []byte, version int) (*OperatorClaims, error) { + switch version { + case 1: + var v1a v1OperatorClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a OperatorClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +type v1OperatorClaims struct { + ClaimsData + v1ClaimsDataDeletedFields + v1NatsOperator `json:"nats,omitempty"` +} + +func (oa v1OperatorClaims) Migrate() (*OperatorClaims, error) { + return oa.migrateV1() +} + +func (oa v1OperatorClaims) migrateV1() (*OperatorClaims, error) { + var a OperatorClaims + // copy the base claim + a.ClaimsData = oa.ClaimsData + // move the moved fields + a.Operator.Type = oa.v1ClaimsDataDeletedFields.Type + a.Operator.Tags = oa.v1ClaimsDataDeletedFields.Tags + // copy the account data + a.Operator.Identities = oa.v1NatsOperator.Identities + a.Operator.SigningKeys = oa.v1NatsOperator.SigningKeys + a.Operator.AccountServerURL = oa.v1NatsOperator.AccountServerURL + a.Operator.OperatorServiceURLs = oa.v1NatsOperator.OperatorServiceURLs + return &a, nil +} diff --git a/v2/decoder_test.go b/v2/decoder_test.go index 9206d18..0fa0279 100644 --- a/v2/decoder_test.go +++ b/v2/decoder_test.go @@ -290,7 +290,7 @@ func TestSample(t *testing.T) { t.Fatalf("unable to read public key: %v", err) } - if c.Issuer != string(pk) { + if c.Issuer != pk { t.Fatalf("the public key is not trusted") } } diff --git a/v2/decoder_user.go b/v2/decoder_user.go new file mode 100644 index 0000000..0b8a533 --- /dev/null +++ b/v2/decoder_user.go @@ -0,0 +1,61 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +type v1User struct { + Permissions + Limits + BearerToken bool `json:"bearer_token,omitempty"` +} + +type v1UserClaimsDataDeletedFields struct { + v1ClaimsDataDeletedFields + IssuerAccount string `json:"issuer_account,omitempty"` +} + +type v1UserClaims struct { + ClaimsData + v1UserClaimsDataDeletedFields + v1User `json:"nats,omitempty"` +} + +func loadUser(data []byte, version int) (*UserClaims, error) { + switch version { + case 1: + var v1a v1UserClaims + if err := json.Unmarshal(data, &v1a); err != nil { + return nil, err + } + return v1a.Migrate() + case 2: + var v2a UserClaims + if err := json.Unmarshal(data, &v2a); err != nil { + return nil, err + } + return &v2a, nil + default: + return nil, fmt.Errorf("library supports version %d or less - received %d", libVersion, version) + } +} + +func (oa v1UserClaims) Migrate() (*UserClaims, error) { + return oa.migrateV1() +} + +func (oa v1UserClaims) migrateV1() (*UserClaims, error) { + var u UserClaims + // copy the base claim + u.ClaimsData = oa.ClaimsData + // move the moved fields + u.User.Type = oa.v1ClaimsDataDeletedFields.Type + u.User.Tags = oa.v1ClaimsDataDeletedFields.Tags + u.User.IssuerAccount = oa.IssuerAccount + // copy the user data + u.User.Permissions = oa.v1User.Permissions + u.User.Limits = oa.v1User.Limits + u.User.BearerToken = oa.v1User.BearerToken + return &u, nil +} diff --git a/v2/exports.go b/v2/exports.go index 5578f98..f1f1bea 100644 --- a/v2/exports.go +++ b/v2/exports.go @@ -91,7 +91,7 @@ func (e *Export) IsStream() bool { } // IsSingleResponse returns true if an export has a single response -// or no resopnse type is set, also checks that the type is service +// or no response type is set, also checks that the type is service func (e *Export) IsSingleResponse() bool { return e.Type == Service && (e.ResponseType == ResponseTypeSingleton || e.ResponseType == "") } diff --git a/v2/exports_test.go b/v2/exports_test.go index b674c90..fdd7ed0 100644 --- a/v2/exports_test.go +++ b/v2/exports_test.go @@ -280,7 +280,8 @@ func TestExport_Sorting(t *testing.T) { exports.Add(&Export{Subject: "x", Type: Service}) exports.Add(&Export{Subject: "z", Type: Service}) exports.Add(&Export{Subject: "y", Type: Service}) - if exports[0].Subject != "x" { + + if exports[0] == nil || exports[0].Subject != "x" { t.Fatal("added export not in expected order") } sort.Sort(exports) diff --git a/v2/genericclaims_test.go b/v2/genericclaims_test.go index fed632a..797481d 100644 --- a/v2/genericclaims_test.go +++ b/v2/genericclaims_test.go @@ -18,43 +18,37 @@ package jwt import ( "testing" "time" + + "github.com/stretchr/testify/require" ) func TestNewGenericClaims(t *testing.T) { akp := createAccountNKey(t) apk := publicKey(akp, t) - uc := NewGenericClaims(apk) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() - uc.Name = "alberto" - uc.Audience = "everyone" - uc.NotBefore = time.Now().UTC().Unix() - uc.Tags.Add("one") - uc.Tags.Add("one") - uc.Tags.Add("one") - uc.Tags.Add("TWO") // should become lower case - uc.Tags.Add("three") + gc := NewGenericClaims(apk) + gc.Expires = time.Now().Add(time.Hour).UTC().Unix() + gc.Name = "alberto" + gc.Audience = "everyone" + gc.NotBefore = time.Now().UTC().Unix() + gc.Data["test"] = true - uJwt := encode(uc, akp, t) + gcJwt := encode(gc, akp, t) - uc2, err := DecodeGeneric(uJwt) + uc2, err := DecodeGeneric(gcJwt) if err != nil { t.Fatal("failed to decode", err) } - AssertEquals(uc.String(), uc2.String(), t) - AssertEquals(uc.Name, uc2.Name, t) - AssertEquals(uc.Audience, uc2.Audience, t) - AssertEquals(uc.Expires, uc2.Expires, t) - AssertEquals(uc.NotBefore, uc2.NotBefore, t) - AssertEquals(uc.Subject, uc2.Subject, t) - - AssertEquals(3, len(uc2.Tags), t) - AssertEquals(true, uc2.Tags.Contains("two"), t) - AssertEquals("one", uc2.Tags[0], t) - AssertEquals("two", uc2.Tags[1], t) - AssertEquals("three", uc2.Tags[2], t) - - AssertEquals(uc.Claims() != nil, true, t) - AssertEquals(uc.Payload() != nil, true, t) + require.Equal(t, gc.String(), uc2.String()) + require.Equal(t, gc.Name, uc2.Name) + require.Equal(t, gc.Audience, uc2.Audience) + require.Equal(t, gc.Expires, uc2.Expires) + require.Equal(t, gc.NotBefore, uc2.NotBefore) + require.Equal(t, gc.Subject, uc2.Subject) + require.Contains(t, gc.Data, "test") + require.Equal(t, gc.Data["test"], true) + + AssertEquals(gc.Claims() != nil, true, t) + AssertEquals(gc.Payload() != nil, true, t) } diff --git a/v2/genericlaims.go b/v2/genericlaims.go index 94cd86e..230a38f 100644 --- a/v2/genericlaims.go +++ b/v2/genericlaims.go @@ -15,7 +15,13 @@ package jwt -import "github.com/nats-io/nkeys" +import ( + "encoding/json" + "errors" + "strings" + + "github.com/nats-io/nkeys" +) // GenericClaims can be used to read a JWT as a map for any non-generic fields type GenericClaims struct { @@ -36,11 +42,37 @@ func NewGenericClaims(subject string) *GenericClaims { // DecodeGeneric takes a JWT string and decodes it into a ClaimsData and map func DecodeGeneric(token string) (*GenericClaims, error) { - v := GenericClaims{} - if err := Decode(token, &v); err != nil { + // must have 3 chunks + chunks := strings.Split(token, ".") + if len(chunks) != 3 { + return nil, errors.New("expected 3 chunks") + } + + // header + if _, err := parseHeaders(chunks[0]); err != nil { + return nil, err + } + // claim + data, err := decodeString(chunks[1]) + if err != nil { + return nil, err + } + + var gc GenericClaims + if err := json.Unmarshal(data, &gc); err != nil { + return nil, err + } + + // sig + sig, err := decodeString(chunks[2]) + if err != nil { return nil, err } - return &v, nil + if !gc.Verify(chunks[1], sig) { + return nil, errors.New("claim failed signature verification") + } + + return &gc, nil } // Claims returns the standard part of the generic claim @@ -71,3 +103,31 @@ func (gc *GenericClaims) String() string { func (gc *GenericClaims) ExpectedPrefixes() []nkeys.PrefixByte { return nil } + +func (gc *GenericClaims) ClaimType() ClaimType { + v, ok := gc.Data["type"] + if !ok { + v, ok = gc.Data["nats"] + if ok { + m, ok := v.(map[string]interface{}) + if ok { + v = m["type"] + } + } + } + ct, ctok := v.(ClaimType) + if ctok { + return ct + } + return "" +} + +func (gc *GenericClaims) updateVersion() { + v, ok := gc.Data["nats"] + if ok { + m, ok := v.(map[string]interface{}) + if ok { + m["version"] = libVersion + } + } +} diff --git a/v2/go.mod b/v2/go.mod index eebea6c..26c4874 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -1,5 +1,11 @@ -module github.com/nats-io/jwt +module github.com/nats-io/jwt/v2 -require github.com/nats-io/nkeys v0.1.4 +require ( + github.com/nats-io/jwt v0.3.2 + github.com/nats-io/nkeys v0.1.4 + github.com/stretchr/testify v1.4.0 +) + +replace github.com/nats-io/jwt v0.3.2 => ../ go 1.13 diff --git a/v2/go.sum b/v2/go.sum deleted file mode 100644 index 5e6e47e..0000000 --- a/v2/go.sum +++ /dev/null @@ -1,9 +0,0 @@ -github.com/nats-io/nkeys v0.1.4 h1:aEsHIssIk6ETN5m2/MD8Y4B2X7FfXrBAUdkyRvbVYzA= -github.com/nats-io/nkeys v0.1.4/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM= -golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -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= diff --git a/v2/imports.go b/v2/imports.go index 8cd9747..de99342 100644 --- a/v2/imports.go +++ b/v2/imports.go @@ -74,9 +74,9 @@ func (i *Import) Validate(actPubKey string, vr *ValidationResults) { if i.Token != "" { // Check to see if its an embedded JWT or a URL. - if url, err := url.Parse(i.Token); err == nil && url.Scheme != "" { + if u, err := url.Parse(i.Token); err == nil && u.Scheme != "" { c := &http.Client{Timeout: 5 * time.Second} - resp, err := c.Get(url.String()) + resp, err := c.Get(u.String()) if err != nil { vr.AddWarning("import %s contains an unreachable token URL %q", i.Subject, i.Token) } @@ -89,7 +89,7 @@ func (i *Import) Validate(actPubKey string, vr *ValidationResults) { } else { act, err = DecodeActivationClaims(string(body)) if err != nil { - vr.AddWarning("import %s contains a url %q with an invalid activation token", i.Subject, i.Token) + vr.AddWarning("import %s contains a URL %q with an invalid activation token", i.Subject, i.Token) } } } diff --git a/v2/imports_test.go b/v2/imports_test.go index 4405d7b..3362cb3 100644 --- a/v2/imports_test.go +++ b/v2/imports_test.go @@ -56,7 +56,7 @@ func TestImportValidation(t *testing.T) { activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream @@ -154,7 +154,7 @@ func TestInvalidImportTokenValuesValidation(t *testing.T) { activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream @@ -264,7 +264,7 @@ func TestTokenURLImportValidation(t *testing.T) { activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "test" activation.ImportType = Stream @@ -324,7 +324,7 @@ func TestImportSubjectValidation(t *testing.T) { akp := publicKey(ak, t) activation := NewActivationClaims(akp) activation.Max = 1024 * 1024 - activation.Expires = time.Now().Add(time.Duration(time.Hour)).UTC().Unix() + activation.Expires = time.Now().Add(time.Hour).UTC().Unix() activation.ImportSubject = "one.*" activation.ImportType = Stream diff --git a/v2/operator_claims.go b/v2/operator_claims.go index 3c4d4a1..590a340 100644 --- a/v2/operator_claims.go +++ b/v2/operator_claims.go @@ -42,6 +42,7 @@ type Operator struct { OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` // Identity of the system account SystemAccount string `json:"system_account,omitempty"` + GenericFields } // Validate checks the validity of the operators contents @@ -120,15 +121,15 @@ func ValidateOperatorServiceURL(v string) error { } func (o *Operator) validateOperatorServiceURLs() []error { - var errors []error + var errs []error for _, v := range o.OperatorServiceURLs { if v != "" { if err := ValidateOperatorServiceURL(v); err != nil { - errors = append(errors, err) + errs = append(errs, err) } } } - return errors + return errs } // OperatorClaims define the data for an operator JWT @@ -159,11 +160,6 @@ func (oc *OperatorClaims) DidSign(op Claims) bool { return oc.SigningKeys.Contains(issuer) } -// Deprecated: AddSigningKey, use claim.SigningKeys.Add() -func (oc *OperatorClaims) AddSigningKey(pk string) { - oc.SigningKeys.Add(pk) -} - // Encode the claims into a JWT string func (oc *OperatorClaims) Encode(pair nkeys.KeyPair) (string, error) { if !nkeys.IsValidPublicOperatorKey(oc.Subject) { @@ -173,17 +169,25 @@ func (oc *OperatorClaims) Encode(pair nkeys.KeyPair) (string, error) { if err != nil { return "", err } - oc.ClaimsData.Type = OperatorClaim + oc.Type = OperatorClaim return oc.ClaimsData.Encode(pair, oc) } +func (oc *OperatorClaims) ClaimType() ClaimType { + return oc.Type +} + // DecodeOperatorClaims tries to create an operator claims from a JWt string func DecodeOperatorClaims(token string) (*OperatorClaims, error) { - v := OperatorClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + oc, ok := claims.(*OperatorClaims) + if !ok { + return nil, errors.New("not operator claim") + } + return oc, nil } func (oc *OperatorClaims) String() string { @@ -210,3 +214,7 @@ func (oc *OperatorClaims) ExpectedPrefixes() []nkeys.PrefixByte { func (oc *OperatorClaims) Claims() *ClaimsData { return &oc.ClaimsData } + +func (oc *OperatorClaims) updateVersion() { + oc.GenericFields.Version = libVersion +} diff --git a/v2/operator_claims_test.go b/v2/operator_claims_test.go index 28f4890..87a4bc6 100644 --- a/v2/operator_claims_test.go +++ b/v2/operator_claims_test.go @@ -20,6 +20,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/nats-io/nkeys" ) @@ -27,7 +29,7 @@ func TestNewOperatorClaims(t *testing.T) { ckp := createOperatorNKey(t) uc := NewOperatorClaims(publicKey(ckp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.Expires = time.Now().Add(time.Hour).Unix() uJwt := encode(uc, ckp, t) uc2, err := DecodeOperatorClaims(uJwt) @@ -72,7 +74,7 @@ func TestOperatorSubjects(t *testing.T) { func TestInvalidOperatorClaimIssuer(t *testing.T) { akp := createOperatorNKey(t) ac := NewOperatorClaims(publicKey(akp, t)) - ac.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + ac.Expires = time.Now().Add(time.Hour).Unix() aJwt := encode(ac, akp, t) temp, err := DecodeGeneric(aJwt) @@ -133,8 +135,8 @@ func TestSigningKeyValidation(t *testing.T) { ckp2 := createOperatorNKey(t) uc := NewOperatorClaims(publicKey(ckp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uc.AddSigningKey(publicKey(ckp2, t)) + uc.Expires = time.Now().Add(time.Hour).Unix() + uc.SigningKeys.Add(publicKey(ckp2, t)) uJwt := encode(uc, ckp, t) uc2, err := DecodeOperatorClaims(uJwt) @@ -152,7 +154,7 @@ func TestSigningKeyValidation(t *testing.T) { t.Fatal("valid operator key should have no validation issues") } - uc.AddSigningKey("") // add an invalid one + uc.SigningKeys.Add("") // add an invalid one vr = &ValidationResults{} uc.Validate(vr) @@ -193,22 +195,8 @@ func TestSignedBy(t *testing.T) { AssertEquals(uc.DidSign(ac), false, t) // no signing key AssertEquals(uc2.DidSign(ac), true, t) // actual key - uc.AddSigningKey(publicKey(ckp2, t)) + uc.SigningKeys.Add(publicKey(ckp2, t)) AssertEquals(uc.DidSign(ac), true, t) // signing key - - clusterKey := createClusterNKey(t) - clusterClaims := NewClusterClaims(publicKey(clusterKey, t)) - enc, err = clusterClaims.Encode(ckp2) // sign with the operator key - if err != nil { - t.Fatal("failed to encode", err) - } - clusterClaims, err = DecodeClusterClaims(enc) - if err != nil { - t.Fatal("failed to decode", err) - } - - AssertEquals(uc.DidSign(clusterClaims), true, t) // signing key - AssertEquals(uc2.DidSign(clusterClaims), true, t) // actual key } func testAccountWithAccountServerURL(t *testing.T, u string) error { @@ -393,3 +381,23 @@ func Test_OperatorServiceURL(t *testing.T) { errs := vr.Errors() AssertEquals(len(errs), shouldFail, t) } + +func TestTags(t *testing.T) { + okp := createOperatorNKey(t) + opk := publicKey(okp, t) + + oc := NewOperatorClaims(opk) + oc.Tags.Add("one") + oc.Tags.Add("one") // duplicated tags should be ignored + oc.Tags.Add("TWO") // should become lower case + oc.Tags.Add("three") + + oJwt := encode(oc, okp, t) + + oc2, err := DecodeOperatorClaims(oJwt) + require.NoError(t, err) + require.Len(t, oc2.GenericFields.Tags, 3) + require.Contains(t, oc.GenericFields.Tags, "one") + require.Contains(t, oc.GenericFields.Tags, "two") + require.Contains(t, oc.GenericFields.Tags, "three") +} diff --git a/v2/server_claims.go b/v2/server_claims.go deleted file mode 100644 index c18f167..0000000 --- a/v2/server_claims.go +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "errors" - - "github.com/nats-io/nkeys" -) - -// Server defines the custom part of a server jwt -type Server struct { - Permissions - Cluster string `json:"cluster,omitempty"` -} - -// Validate checks the cluster and permissions for a server JWT -func (s *Server) Validate(vr *ValidationResults) { - if s.Cluster == "" { - vr.AddError("servers can't contain an empty cluster") - } -} - -// ServerClaims defines the data in a server JWT -type ServerClaims struct { - ClaimsData - Server `json:"nats,omitempty"` -} - -// NewServerClaims creates a new server JWT with the specified subject/public key -func NewServerClaims(subject string) *ServerClaims { - if subject == "" { - return nil - } - c := &ServerClaims{} - c.Subject = subject - return c -} - -// Encode tries to turn the server claims into a JWT string -func (s *ServerClaims) Encode(pair nkeys.KeyPair) (string, error) { - if !nkeys.IsValidPublicServerKey(s.Subject) { - return "", errors.New("expected subject to be a server public key") - } - s.ClaimsData.Type = ServerClaim - return s.ClaimsData.Encode(pair, s) -} - -// DecodeServerClaims tries to parse server claims from a JWT string -func DecodeServerClaims(token string) (*ServerClaims, error) { - v := ServerClaims{} - if err := Decode(token, &v); err != nil { - return nil, err - } - return &v, nil -} - -func (s *ServerClaims) String() string { - return s.ClaimsData.String(s) -} - -// Payload returns the server specific data -func (s *ServerClaims) Payload() interface{} { - return &s.Server -} - -// Validate checks the generic and server data in the server claims -func (s *ServerClaims) Validate(vr *ValidationResults) { - s.ClaimsData.Validate(vr) - s.Server.Validate(vr) -} - -// ExpectedPrefixes defines the types that can encode a server JWT, operator or cluster -func (s *ServerClaims) ExpectedPrefixes() []nkeys.PrefixByte { - return []nkeys.PrefixByte{nkeys.PrefixByteOperator, nkeys.PrefixByteCluster} -} - -// Claims returns the generic data -func (s *ServerClaims) Claims() *ClaimsData { - return &s.ClaimsData -} diff --git a/v2/server_claims_test.go b/v2/server_claims_test.go deleted file mode 100644 index 70fc3d5..0000000 --- a/v2/server_claims_test.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2018 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 - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package jwt - -import ( - "fmt" - "testing" - "time" - - "github.com/nats-io/nkeys" -) - -func TestNewServerClaims(t *testing.T) { - ckp := createClusterNKey(t) - skp := createServerNKey(t) - - uc := NewServerClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - uc2, err := DecodeServerClaims(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - AssertEquals(uc.String(), uc2.String(), t) - - AssertEquals(uc.Claims() != nil, true, t) - AssertEquals(uc.Payload() != nil, true, t) -} - -func TestServerClaimsIssuer(t *testing.T) { - ckp := createClusterNKey(t) - skp := createServerNKey(t) - - uc := NewServerClaims(publicKey(skp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() - uJwt := encode(uc, ckp, t) - - temp, err := DecodeGeneric(uJwt) - if err != nil { - t.Fatal("failed to decode", err) - } - - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"user", createUserNKey(t), false}, - {"operator", createOperatorNKey(t), true}, - {"server", createServerNKey(t), false}, - {"cluster", createClusterNKey(t), true}, - } - - for _, i := range inputs { - bad := encode(temp, i.kp, t) - _, err = DecodeServerClaims(bad) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to decode server signed by %q", i.name) - t.Fail() - } - } -} - -func TestServerSubjects(t *testing.T) { - type kpInputs struct { - name string - kp nkeys.KeyPair - ok bool - } - - inputs := []kpInputs{ - {"account", createAccountNKey(t), false}, - {"cluster", createClusterNKey(t), false}, - {"operator", createOperatorNKey(t), false}, - {"server", createServerNKey(t), true}, - {"user", createUserNKey(t), false}, - } - - for _, i := range inputs { - c := NewServerClaims(publicKey(i.kp, t)) - _, err := c.Encode(createOperatorNKey(t)) - if i.ok && err != nil { - t.Fatal(fmt.Sprintf("unexpected error for %q: %v", i.name, err)) - } - if !i.ok && err == nil { - t.Logf("should have failed to encode server with with %q subject", i.name) - t.Fail() - } - } -} - -func TestNewNilServerClaims(t *testing.T) { - v := NewServerClaims("") - if v != nil { - t.Fatal("expected nil user claim") - } -} - -func TestServerType(t *testing.T) { - c := NewServerClaims(publicKey(createServerNKey(t), t)) - s := encode(c, createClusterNKey(t), t) - u, err := DecodeServerClaims(s) - if err != nil { - t.Fatalf("failed to decode server claim: %v", err) - } - - if ServerClaim != u.Type { - t.Fatalf("type is unexpected %q (wanted server)", u.Type) - } - -} diff --git a/v2/types.go b/v2/types.go index e729c7e..aa7dbed 100644 --- a/v2/types.go +++ b/v2/types.go @@ -228,7 +228,7 @@ type ResponsePermission struct { } // Validate the response permission. -func (p *ResponsePermission) Validate(vr *ValidationResults) { +func (p *ResponsePermission) Validate(_ *ValidationResults) { // Any values can be valid for now. } @@ -327,6 +327,6 @@ type Identity struct { } // Validate checks the values in an Identity -func (u *Identity) Validate(vr *ValidationResults) { +func (u *Identity) Validate(_ *ValidationResults) { //Fixme identity validation } diff --git a/v2/user_claims.go b/v2/user_claims.go index 78fe6a9..83b2156 100644 --- a/v2/user_claims.go +++ b/v2/user_claims.go @@ -26,6 +26,10 @@ type User struct { Permissions Limits BearerToken bool `json:"bearer_token,omitempty"` + // IssuerAccount stores the public key for the account the issuer represents. + // When set, the claim was issued by a signing key. + IssuerAccount string `json:"issuer_account,omitempty"` + GenericFields } // Validate checks the permissions and limits in a User jwt @@ -39,9 +43,6 @@ func (u *User) Validate(vr *ValidationResults) { type UserClaims struct { ClaimsData User `json:"nats,omitempty"` - // IssuerAccount stores the public key for the account the issuer represents. - // When set, the claim was issued by a signing key. - IssuerAccount string `json:"issuer_account,omitempty"` } // NewUserClaims creates a user JWT with the specific subject/public key @@ -59,17 +60,25 @@ func (u *UserClaims) Encode(pair nkeys.KeyPair) (string, error) { if !nkeys.IsValidPublicUserKey(u.Subject) { return "", errors.New("expected subject to be user public key") } - u.ClaimsData.Type = UserClaim + u.Type = UserClaim return u.ClaimsData.Encode(pair, u) } // DecodeUserClaims tries to parse a user claims from a JWT string func DecodeUserClaims(token string) (*UserClaims, error) { - v := UserClaims{} - if err := Decode(token, &v); err != nil { + claims, err := Decode(token) + if err != nil { return nil, err } - return &v, nil + ac, ok := claims.(*UserClaims) + if !ok { + return nil, errors.New("not user claim") + } + return ac, nil +} + +func (u *UserClaims) ClaimType() ClaimType { + return u.Type } // Validate checks the generic and specific parts of the user jwt @@ -100,6 +109,10 @@ func (u *UserClaims) String() string { return u.ClaimsData.String(u) } +func (u *UserClaims) updateVersion() { + u.GenericFields.Version = libVersion +} + // IsBearerToken returns true if nonce-signing requirements should be skipped func (u *UserClaims) IsBearerToken() bool { return u.BearerToken diff --git a/v2/user_claims_test.go b/v2/user_claims_test.go index 447132c..0ce715e 100644 --- a/v2/user_claims_test.go +++ b/v2/user_claims_test.go @@ -28,7 +28,7 @@ func TestNewUserClaims(t *testing.T) { ukp := createUserNKey(t) uc := NewUserClaims(publicKey(ukp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.Expires = time.Now().Add(time.Hour).Unix() uJwt := encode(uc, akp, t) uc2, err := DecodeUserClaims(uJwt) @@ -47,7 +47,7 @@ func TestUserClaimIssuer(t *testing.T) { ukp := createUserNKey(t) uc := NewUserClaims(publicKey(ukp, t)) - uc.Expires = time.Now().Add(time.Duration(time.Hour)).Unix() + uc.Expires = time.Now().Add(time.Hour).Unix() uJwt := encode(uc, akp, t) temp, err := DecodeGeneric(uJwt) diff --git a/v2/util_test.go b/v2/util_test.go index 5ccddca..eda0ad9 100644 --- a/v2/util_test.go +++ b/v2/util_test.go @@ -93,7 +93,7 @@ func publicKey(kp nkeys.KeyPair, t *testing.T) string { if err != nil { t.Fatal("error reading public key", err) } - return string(pk) + return pk } func seedKey(kp nkeys.KeyPair, t *testing.T) []byte { diff --git a/v2/validation.go b/v2/validation.go index c87a992..4625efd 100644 --- a/v2/validation.go +++ b/v2/validation.go @@ -38,7 +38,7 @@ type ValidationResults struct { // CreateValidationResults creates an empty list of validation issues func CreateValidationResults() *ValidationResults { - issues := []*ValidationIssue{} + var issues []*ValidationIssue return &ValidationResults{ Issues: issues, } From 9a2e4cb0bb692ccb6cbabbe210e52c0b81b5b6d3 Mon Sep 17 00:00:00 2001 From: aricart Date: Fri, 21 Feb 2020 18:54:33 -0400 Subject: [PATCH 09/14] Merge change to master (v1) where the regex was relaxed. --- v2/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v2/Makefile b/v2/Makefile index c805857..53585ce 100644 --- a/v2/Makefile +++ b/v2/Makefile @@ -12,7 +12,7 @@ test: go vet ./... staticcheck ./... rm -rf ./coverage.out - go test -coverprofile=./coverage.out ./... + go test -v -coverprofile=./coverage.out ./... cover: go tool cover -html=coverage.out From 86d4640615998fc164765dcdff5d3f948e93b5a8 Mon Sep 17 00:00:00 2001 From: Matthias Hanel Date: Thu, 21 May 2020 14:32:02 -0400 Subject: [PATCH 10/14] Include system_account in v1 as well Signed-off-by: Matthias Hanel --- go.mod | 5 ++++- go.sum | 10 +++++++++ operator_claims.go | 7 ++++++ operator_claims_test.go | 41 ++++++++++++++++++++++++++++++++++++ v2/decoder_migration_test.go | 5 +++++ v2/decoder_operator.go | 2 ++ v2/go.mod | 2 +- v2/go.sum | 6 ++++++ 8 files changed, 76 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 778d12c..1be250d 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,8 @@ module github.com/nats-io/jwt -require github.com/nats-io/nkeys v0.1.3 +require ( + github.com/nats-io/nkeys v0.1.3 + github.com/stretchr/testify v1.5.1 // indirect +) go 1.13 diff --git a/go.sum b/go.sum index 9baf67f..09ab54c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +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/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -7,3 +14,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/operator_claims.go b/operator_claims.go index 68d904c..299b7b9 100644 --- a/operator_claims.go +++ b/operator_claims.go @@ -40,6 +40,8 @@ type Operator struct { // A list of NATS urls (tls://host:port) where tools can connect to the server // using proper credentials. OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` + // Identity of the system account + SystemAccount string `json:"system_account,omitempty"` } // Validate checks the validity of the operators contents @@ -63,6 +65,11 @@ func (o *Operator) Validate(vr *ValidationResults) { vr.AddError("%s is not an operator public key", k) } } + if o.SystemAccount != "" { + if !nkeys.IsValidPublicAccountKey(o.SystemAccount) { + vr.AddError("%s is not an account public key", o.SystemAccount) + } + } } func (o *Operator) validateAccountServerURL() error { diff --git a/operator_claims_test.go b/operator_claims_test.go index 73cae23..506895d 100644 --- a/operator_claims_test.go +++ b/operator_claims_test.go @@ -235,6 +235,47 @@ func testAccountWithAccountServerURL(t *testing.T, u string) error { return nil } +func Test_SystemAccount(t *testing.T) { + operatorWithSystemAcc := func(t *testing.T, u string) error { + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + oc.SystemAccount = u + s, err := oc.Encode(kp) + if err != nil { + return err + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + AssertEquals(oc.SystemAccount, u, t) + vr := ValidationResults{} + oc.Validate(&vr) + if !vr.IsEmpty() { + return fmt.Errorf("%s", vr.Errors()[0]) + } + return nil + } + var asuTests = []struct { + accKey string + shouldFail bool + }{ + {"", false}, + {"x", true}, + {"ADZ547B24WHPLWOK7TMLNBSA7FQFXR6UM2NZ4HHNIB7RDFVZQFOZ4GQQ", false}, + {"ADZ547B24WHPLWOK7TMLNBSA7FQFXR6UM2NZ4HHNIB7RDFVZQFOZ4777", true}, + } + for i, tt := range asuTests { + err := operatorWithSystemAcc(t, tt.accKey) + if err != nil && tt.shouldFail == false { + t.Fatalf("expected not to fail: %v", err) + } else if err == nil && tt.shouldFail { + t.Fatalf("test %s expected to fail but didn't", asuTests[i].accKey) + } + } +} + func Test_AccountServerURL(t *testing.T) { var asuTests = []struct { u string diff --git a/v2/decoder_migration_test.go b/v2/decoder_migration_test.go index 0484379..cdf75aa 100644 --- a/v2/decoder_migration_test.go +++ b/v2/decoder_migration_test.go @@ -66,6 +66,9 @@ func TestMigrateOperator(t *testing.T) { opk, err := okp.PublicKey() require.NoError(t, err) + sapk, err := okp.PublicKey() + require.NoError(t, err) + oc := v1jwt.NewOperatorClaims(opk) oc.Name = "O" oc.Audience = "Audience" @@ -79,6 +82,7 @@ func TestMigrateOperator(t *testing.T) { oc.OperatorServiceURLs.Add("nats://localhost:4222") oc.AccountServerURL = "http://localhost:9090/jwt/v1" + oc.SystemAccount = sapk sk, err := nkeys.CreateOperator() require.NoError(t, err) @@ -235,6 +239,7 @@ func equalOperators(t *testing.T, o *v1jwt.OperatorClaims, n *OperatorClaims) { require.Equal(t, o.Identities[0].ID, n.Operator.Identities[0].ID) require.Equal(t, o.Identities[0].Proof, n.Operator.Identities[0].Proof) + require.Equal(t, o.SystemAccount, o.Operator.SystemAccount) } func equalAccounts(t *testing.T, o *v1jwt.AccountClaims, n *AccountClaims) { diff --git a/v2/decoder_operator.go b/v2/decoder_operator.go index 74a2253..a24f50d 100644 --- a/v2/decoder_operator.go +++ b/v2/decoder_operator.go @@ -10,6 +10,7 @@ type v1NatsOperator struct { SigningKeys StringList `json:"signing_keys,omitempty"` AccountServerURL string `json:"account_server_url,omitempty"` OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` + SystemAccount string `json:"system_account,omitempty"` } func loadOperator(data []byte, version int) (*OperatorClaims, error) { @@ -53,5 +54,6 @@ func (oa v1OperatorClaims) migrateV1() (*OperatorClaims, error) { a.Operator.SigningKeys = oa.v1NatsOperator.SigningKeys a.Operator.AccountServerURL = oa.v1NatsOperator.AccountServerURL a.Operator.OperatorServiceURLs = oa.v1NatsOperator.OperatorServiceURLs + a.Operator.SystemAccount = oa.v1NatsOperator.SystemAccount return &a, nil } diff --git a/v2/go.mod b/v2/go.mod index 26c4874..a6716f8 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -3,7 +3,7 @@ module github.com/nats-io/jwt/v2 require ( github.com/nats-io/jwt v0.3.2 github.com/nats-io/nkeys v0.1.4 - github.com/stretchr/testify v1.4.0 + github.com/stretchr/testify v1.5.1 ) replace github.com/nats-io/jwt v0.3.2 => ../ diff --git a/v2/go.sum b/v2/go.sum index 0d26dad..de0e8af 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -2,15 +2,21 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.4 h1:aEsHIssIk6ETN5m2/MD8Y4B2X7FfXrBAUdkyRvbVYzA= +github.com/nats-io/nkeys v0.1.4/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= 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 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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= From 090bfbeb35a2e655ae1ec678ec6255ee528b19ba Mon Sep 17 00:00:00 2001 From: Matthias Hanel Date: Tue, 26 May 2020 16:28:38 -0400 Subject: [PATCH 11/14] v2 jwt need to cause an error in v1 jwt libraries. Changing name of the algorithm to trip v1 jwt libraries. The old name ed25519 is not used by other jwt libraries and was only valid for our library. Therefore changing that string is ok. Add field assert server version to operator. --- operator_claims_test.go | 9 +++++++ v2/header.go | 9 +++++-- v2/operator_claims.go | 26 +++++++++++++++++++ v2/operator_claims_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/operator_claims_test.go b/operator_claims_test.go index 506895d..1bca80e 100644 --- a/operator_claims_test.go +++ b/operator_claims_test.go @@ -393,3 +393,12 @@ func Test_OperatorServiceURL(t *testing.T) { errs := vr.Errors() AssertEquals(len(errs), shouldFail, t) } + +func Test_ForwardCompatibility(t *testing.T) { + newOp := `eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJTSUYyR0ZRSEhWWUtDQlZYRklYUURYV1FCQUcyWEw3SVZLVVJZT0ZTWlhVT0tTRUpLWDdBIiwiaWF0IjoxNTkwNTI0NTAwLCJpc3MiOiJPQlQ2REtGSzQ2STM3TjdCUkwyUkpMVVJLWUdSQTZBWVJQREFISFFFQUFBR05ZWExNR1JEUEtMQyIsInN1YiI6Ik9CVDZES0ZLNDZJMzdON0JSTDJSSkxVUktZR1JBNkFZUlBEQUhIUUVBQUFHTllYTE1HUkRQS0xDIiwibmF0cyI6eyJ0YWdzIjpbIm9uZSIsInR3byIsInRocmVlIl0sInR5cGUiOiJvcGVyYXRvciIsInZlcnNpb24iOjJ9fQ.u6JFiISIh2o-CWxktfEw3binmCLhLaFVMyuIa2HNo_x_6EGWVPVICVWc_MOLFS-6Nm17Cj4SmOh3zUtlTRkfDA` + if _, err := DecodeOperatorClaims(newOp); err == nil { + t.Fatal("Expected error") + } else if err.Error() != `unexpected "ed25519-nkey" algorithm` { + t.Fatal("Expected different error, got: ", err) + } +} diff --git a/v2/header.go b/v2/header.go index 27c6581..f2b63a4 100644 --- a/v2/header.go +++ b/v2/header.go @@ -31,7 +31,8 @@ const ( // AlgorithmNkey is the algorithm supported by JWT tokens // encoded and decoded by this library - AlgorithmNkey = "ed25519" + AlgorithmNkeyOld = "ed25519" + AlgorithmNkey = AlgorithmNkeyOld + "-nkey" ) // Header is a JWT Jose Header @@ -64,7 +65,11 @@ func (h *Header) Valid() error { return fmt.Errorf("not supported type %q", h.Type) } - if AlgorithmNkey != strings.ToLower(h.Algorithm) { + alg := strings.ToLower(h.Algorithm) + if !strings.HasPrefix(alg, AlgorithmNkeyOld) { + return fmt.Errorf("unexpected %q algorithm", h.Algorithm) + } + if AlgorithmNkeyOld != alg && AlgorithmNkey != alg { return fmt.Errorf("unexpected %q algorithm", h.Algorithm) } return nil diff --git a/v2/operator_claims.go b/v2/operator_claims.go index 5f3c8f6..d183d59 100644 --- a/v2/operator_claims.go +++ b/v2/operator_claims.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "net/url" + "strconv" "strings" "github.com/nats-io/nkeys" @@ -42,9 +43,31 @@ type Operator struct { OperatorServiceURLs StringList `json:"operator_service_urls,omitempty"` // Identity of the system account SystemAccount string `json:"system_account,omitempty"` + // Min Server version + AssertServerVersion string `json:"assert_server_version,omitempty"` GenericFields } +func ParseServerVersion(version string) (int, int, int, error) { + if version == "" { + return 0, 0, 0, nil + } + split := strings.Split(version, ".") + if len(split) != 3 { + return 0, 0, 0, fmt.Errorf("asserted server version must be of the form ..") + } else if major, err := strconv.Atoi(split[0]); err != nil { + return 0, 0, 0, fmt.Errorf("asserted server version cant parse %s to int", split[0]) + } else if minor, err := strconv.Atoi(split[1]); err != nil { + return 0, 0, 0, fmt.Errorf("asserted server version cant parse %s to int", split[1]) + } else if update, err := strconv.Atoi(split[2]); err != nil { + return 0, 0, 0, fmt.Errorf("asserted server version cant parse %s to int", split[2]) + } else if major < 0 || minor < 0 || update < 0 { + return 0, 0, 0, fmt.Errorf("asserted server version can'b contain negative values: %s", version) + } else { + return major, minor, update, nil + } +} + // Validate checks the validity of the operators contents func (o *Operator) Validate(vr *ValidationResults) { if err := o.validateAccountServerURL(); err != nil { @@ -71,6 +94,9 @@ func (o *Operator) Validate(vr *ValidationResults) { vr.AddError("%s is not an account public key", o.SystemAccount) } } + if _, _, _, err := ParseServerVersion(o.AssertServerVersion); err != nil { + vr.AddError("assert server version error: %s", err) + } } func (o *Operator) validateAccountServerURL() error { diff --git a/v2/operator_claims_test.go b/v2/operator_claims_test.go index 87a4bc6..90dfc13 100644 --- a/v2/operator_claims_test.go +++ b/v2/operator_claims_test.go @@ -288,6 +288,58 @@ func Test_SystemAccount(t *testing.T) { } } +func Test_AssertServerVersion(t *testing.T) { + operatorWithAssertServerVer := func(t *testing.T, v string) error { + kp := createOperatorNKey(t) + pk := publicKey(kp, t) + oc := NewOperatorClaims(pk) + oc.AssertServerVersion = v + s, err := oc.Encode(kp) + if err != nil { + return err + } + oc, err = DecodeOperatorClaims(s) + if err != nil { + t.Fatal(err) + } + AssertEquals(oc.AssertServerVersion, v, t) + vr := ValidationResults{} + oc.Validate(&vr) + if !vr.IsEmpty() { + return fmt.Errorf("%s", vr.Errors()[0]) + } + return nil + } + var asuTests = []struct { + assertVer string + shouldFail bool + }{ + {"1.2.3", false}, + {"10.2.3", false}, + {"1.20.3", false}, + {"1.2.30", false}, + {"10.20.30", false}, + {"0.0.0", false}, + {"0.0", true}, + {"0", true}, + {"a", true}, + {"a.b.c", true}, + {"1..1", true}, + {"1a.b.c", true}, + {"-1.0.0", true}, + {"1.-1.0", true}, + {"1.0.-1", true}, + } + for i, tt := range asuTests { + err := operatorWithAssertServerVer(t, tt.assertVer) + if err != nil && tt.shouldFail == false { + t.Fatalf("expected not to fail: %v", err) + } else if err == nil && tt.shouldFail { + t.Fatalf("test %s expected to fail but didn't", asuTests[i].assertVer) + } + } +} + func testOperatorWithOperatorServiceURL(t *testing.T, u string) error { kp := createOperatorNKey(t) pk := publicKey(kp, t) From 3469ce64e8fa5589e0fdc3005d0cdfa997c149dd Mon Sep 17 00:00:00 2001 From: Alberto Ricart Date: Thu, 28 May 2020 09:15:19 -0500 Subject: [PATCH 12/14] review changes --- .travis.yml | 5 ++--- creds_utils.go | 15 +++++++++++++++ creds_utils_test.go | 15 +++++++++++++++ go.mod | 4 ++-- go.sum | 5 +++++ revocation_list.go | 15 +++++++++++++++ user_claims_test.go | 2 +- v2/creds_utils.go | 15 +++++++++++++++ v2/creds_utils_test.go | 15 +++++++++++++++ v2/decoder.go | 15 +++++++++++++++ v2/decoder_account.go | 15 +++++++++++++++ v2/decoder_activation.go | 14 ++++++++++++++ v2/decoder_operator.go | 15 +++++++++++++++ v2/decoder_user.go | 15 +++++++++++++++ v2/go.mod | 2 +- v2/go.sum | 6 ------ v2/revocation_list.go | 15 +++++++++++++++ 17 files changed, 175 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9a35fac..f1aab1e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ os: linux language: go go: - 1.13.x - - 1.12.x + - 1.14.x git: depth: false env: @@ -21,13 +21,12 @@ before_script: - misspell -error -locale US . - staticcheck ./... script: - - cd $TRAVIS_BUILD_DIR/${V} - go test -v -coverprofile=./coverage.out ./... deploy: - provider: script script: $HOME/gopath/bin/goveralls -coverprofile=v2/coverage.out -service travis-ci on: tags: true - condition: ${V} = v2 && $TRAVIS_GO_VERSION =~ ^1.13. + condition: ${V} = v2 && $TRAVIS_GO_VERSION =~ ^1.14. diff --git a/creds_utils.go b/creds_utils.go index bb913dc..f704b55 100644 --- a/creds_utils.go +++ b/creds_utils.go @@ -1,3 +1,18 @@ +/* + * Copyright 2019-2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/creds_utils_test.go b/creds_utils_test.go index 5075274..4db5d40 100644 --- a/creds_utils_test.go +++ b/creds_utils_test.go @@ -1,3 +1,18 @@ +/* + * Copyright 2019-2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/go.mod b/go.mod index 1be250d..a96a39c 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/nats-io/jwt require ( - github.com/nats-io/nkeys v0.1.3 + github.com/nats-io/nkeys v0.1.4 github.com/stretchr/testify v1.5.1 // indirect ) -go 1.13 +go 1.14 diff --git a/go.sum b/go.sum index 09ab54c..e5a944c 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,19 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.4 h1:aEsHIssIk6ETN5m2/MD8Y4B2X7FfXrBAUdkyRvbVYzA= +github.com/nats-io/nkeys v0.1.4/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= 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 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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= diff --git a/revocation_list.go b/revocation_list.go index fb1d836..b9c3837 100644 --- a/revocation_list.go +++ b/revocation_list.go @@ -1,3 +1,18 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/user_claims_test.go b/user_claims_test.go index c9da7fe..eab5711 100644 --- a/user_claims_test.go +++ b/user_claims_test.go @@ -1,5 +1,5 @@ /* - * Copyright 2018 The NATS Authors + * Copyright 2018-2020 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 diff --git a/v2/creds_utils.go b/v2/creds_utils.go index 32e650d..93ba6d1 100644 --- a/v2/creds_utils.go +++ b/v2/creds_utils.go @@ -1,3 +1,18 @@ +/* + * Copyright 2019-2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/v2/creds_utils_test.go b/v2/creds_utils_test.go index 436f379..d56a34a 100644 --- a/v2/creds_utils_test.go +++ b/v2/creds_utils_test.go @@ -1,3 +1,18 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/v2/decoder.go b/v2/decoder.go index 8d63af6..7cae5e9 100644 --- a/v2/decoder.go +++ b/v2/decoder.go @@ -1,3 +1,18 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/v2/decoder_account.go b/v2/decoder_account.go index f934177..43a7285 100644 --- a/v2/decoder_account.go +++ b/v2/decoder_account.go @@ -1,3 +1,18 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/v2/decoder_activation.go b/v2/decoder_activation.go index aba0975..74aa68a 100644 --- a/v2/decoder_activation.go +++ b/v2/decoder_activation.go @@ -1,3 +1,17 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package jwt import ( diff --git a/v2/decoder_operator.go b/v2/decoder_operator.go index a24f50d..42efbc1 100644 --- a/v2/decoder_operator.go +++ b/v2/decoder_operator.go @@ -1,3 +1,18 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/v2/decoder_user.go b/v2/decoder_user.go index 0b8a533..70bc41a 100644 --- a/v2/decoder_user.go +++ b/v2/decoder_user.go @@ -1,3 +1,18 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( diff --git a/v2/go.mod b/v2/go.mod index a6716f8..23ceb9c 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -8,4 +8,4 @@ require ( replace github.com/nats-io/jwt v0.3.2 => ../ -go 1.13 +go 1.14 diff --git a/v2/go.sum b/v2/go.sum index de0e8af..eed6be4 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -1,20 +1,14 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= -github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.4 h1:aEsHIssIk6ETN5m2/MD8Y4B2X7FfXrBAUdkyRvbVYzA= github.com/nats-io/nkeys v0.1.4/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= 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 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/v2/revocation_list.go b/v2/revocation_list.go index fb1d836..b9c3837 100644 --- a/v2/revocation_list.go +++ b/v2/revocation_list.go @@ -1,3 +1,18 @@ +/* + * Copyright 2020 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jwt import ( From 169a155e7b117ef46ecaba9fcac58989878d5f9a Mon Sep 17 00:00:00 2001 From: Alberto Ricart Date: Thu, 28 May 2020 11:37:44 -0500 Subject: [PATCH 13/14] removed dependencies on the require library added windows for build test --- .travis.yml | 17 ++-- go.mod | 1 - v2/decoder_migration_test.go | 148 ++++++++++++++++++----------------- v2/genericclaims_test.go | 19 ++--- v2/go.mod | 1 - v2/operator_claims_test.go | 20 +++-- v2/util_test.go | 24 ++++++ 7 files changed, 130 insertions(+), 100 deletions(-) diff --git a/.travis.yml b/.travis.yml index f1aab1e..dab323b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ -os: linux +os: + - linux + - windows language: go go: - 1.13.x @@ -16,17 +18,18 @@ install: - go get github.com/wadey/gocovmerge before_script: - cd $TRAVIS_BUILD_DIR/${V} - - $(exit $(go fmt ./... | wc -l)) + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then EXCLUDE_VENDOR=$(go list ./... | grep -v "/vendor/") && $(exit $(go fmt $EXCLUDE_VENDOR | wc -l)) && go vet $EXCLUDE_VENDOR; fi - go vet ./... - misspell -error -locale US . - staticcheck ./... script: - go test -v -coverprofile=./coverage.out ./... deploy: - - provider: script - script: $HOME/gopath/bin/goveralls -coverprofile=v2/coverage.out -service travis-ci - on: - tags: true - condition: ${V} = v2 && $TRAVIS_GO_VERSION =~ ^1.14. +- provider: script + script: $HOME/gopath/bin/goveralls -coverprofile=v2/coverage.out -service travis-ci + on: + tags: true + condition: ${V} = v2 && $TRAVIS_OS_NAME = linux && $TRAVIS_GO_VERSION =~ ^1.14 + diff --git a/go.mod b/go.mod index a96a39c..65f1c53 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,6 @@ module github.com/nats-io/jwt require ( github.com/nats-io/nkeys v0.1.4 - github.com/stretchr/testify v1.5.1 // indirect ) go 1.14 diff --git a/v2/decoder_migration_test.go b/v2/decoder_migration_test.go index cdf75aa..03efd36 100644 --- a/v2/decoder_migration_test.go +++ b/v2/decoder_migration_test.go @@ -22,7 +22,6 @@ import ( v1jwt "github.com/nats-io/jwt" "github.com/nats-io/nkeys" - "github.com/stretchr/testify/require" ) func createExport(sub string) *v1jwt.Export { @@ -55,19 +54,19 @@ func createActivation(t *testing.T, e *v1jwt.Export, target string, signer nkeys s := strings.Replace(string(e.Subject), "*", target, -1) ac.ImportSubject = v1jwt.Subject(s) tok, err := ac.Encode(signer) - require.NoError(t, err) + AssertNoError(err, t) return tok } func TestMigrateOperator(t *testing.T) { okp, err := nkeys.CreateOperator() - require.NoError(t, err) + AssertNoError(err, t) opk, err := okp.PublicKey() - require.NoError(t, err) + AssertNoError(err, t) sapk, err := okp.PublicKey() - require.NoError(t, err) + AssertNoError(err, t) oc := v1jwt.NewOperatorClaims(opk) oc.Name = "O" @@ -85,9 +84,9 @@ func TestMigrateOperator(t *testing.T) { oc.SystemAccount = sapk sk, err := nkeys.CreateOperator() - require.NoError(t, err) + AssertNoError(err, t) psk, err := sk.PublicKey() - require.NoError(t, err) + AssertNoError(err, t) oc.Operator.SigningKeys.Add(psk) oc.Identities = append(oc.Identities, v1jwt.Identity{ @@ -96,24 +95,24 @@ func TestMigrateOperator(t *testing.T) { }) token, err := oc.Encode(okp) - require.NoError(t, err) + AssertNoError(err, t) c, err := Decode(token) - require.NoError(t, err) + AssertNoError(err, t) oc2, ok := c.(*OperatorClaims) - require.True(t, ok) + AssertTrue(ok, t) equalOperators(t, oc, oc2) } func TestMigrateAccount(t *testing.T) { okp, err := nkeys.CreateOperator() - require.NoError(t, err) + AssertNoError(err, t) akp, err := nkeys.CreateAccount() - require.NoError(t, err) + AssertNoError(err, t) apk, err := akp.PublicKey() - require.NoError(t, err) + AssertNoError(err, t) ac := v1jwt.NewAccountClaims(apk) ac.Name = "A" @@ -127,7 +126,7 @@ func TestMigrateAccount(t *testing.T) { // create an import ea, err := nkeys.CreateAccount() - require.NoError(t, err) + AssertNoError(err, t) hex := createExport("help") ac.Imports.Add(createImport(t, hex, apk, ea)) @@ -152,35 +151,35 @@ func TestMigrateAccount(t *testing.T) { // add a signing key sk, err := nkeys.CreateAccount() - require.NoError(t, err) + AssertNoError(err, t) psk, err := sk.PublicKey() - require.NoError(t, err) + AssertNoError(err, t) ac.Account.SigningKeys.Add(psk) // add a revocation ukp, err := nkeys.CreateUser() - require.NoError(t, err) + AssertNoError(err, t) upk, err := ukp.PublicKey() - require.NoError(t, err) + AssertNoError(err, t) ac.Revocations = make(map[string]int64) ac.Revocations.Revoke(upk, time.Now()) token, err := ac.Encode(okp) - require.NoError(t, err) + AssertNoError(err, t) c, err := Decode(token) - require.NoError(t, err) + AssertNoError(err, t) ac2, ok := c.(*AccountClaims) - require.True(t, ok) + AssertTrue(ok, t) equalAccounts(t, ac, ac2) } func TestMigrateUser(t *testing.T) { ukp, err := nkeys.CreateUser() - require.NoError(t, err) + AssertNoError(err, t) upk, err := ukp.PublicKey() - require.NoError(t, err) + AssertNoError(err, t) uc := v1jwt.NewUserClaims(upk) uc.Name = "U" @@ -205,117 +204,120 @@ func TestMigrateUser(t *testing.T) { uc.BearerToken = true akp, err := nkeys.CreateAccount() - require.NoError(t, err) + AssertNoError(err, t) tok, err := uc.Encode(akp) - require.NoError(t, err) + AssertNoError(err, t) c, err := Decode(tok) - require.NoError(t, err) + AssertNoError(err, t) uc2, ok := c.(*UserClaims) - require.True(t, ok) + AssertTrue(ok, t) equalUsers(t, uc, uc2) } func equalClaims(t *testing.T, o *v1jwt.ClaimsData, n *ClaimsData, gf *GenericFields) { - require.Equal(t, o.Subject, n.Subject) - require.Equal(t, o.Issuer, n.Issuer) - require.Equal(t, o.Name, n.Name) - require.Equal(t, o.Audience, n.Audience) - require.Equal(t, o.NotBefore, n.NotBefore) - require.Equal(t, o.Expires, n.Expires) - require.Equal(t, string(o.Type), string(gf.Type)) - require.EqualValues(t, o.Tags, gf.Tags) + AssertEquals(o.Subject, n.Subject, t) + AssertEquals(o.Issuer, n.Issuer, t) + AssertEquals(o.Name, n.Name, t) + AssertEquals(o.Audience, n.Audience, t) + AssertEquals(o.NotBefore, n.NotBefore, t) + AssertEquals(o.Expires, n.Expires, t) + AssertEquals(string(o.Type), string(gf.Type), t) + AssertTrue(len(o.Tags) == len(gf.Tags), t) + for _, v := range gf.Tags { + AssertTrue(o.Tags.Contains(v), t) + } } func equalOperators(t *testing.T, o *v1jwt.OperatorClaims, n *OperatorClaims) { equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) for _, v := range o.OperatorServiceURLs { - require.Contains(t, n.Operator.OperatorServiceURLs, v) + AssertTrue(n.OperatorServiceURLs.Contains(v), t) } for _, v := range o.SigningKeys { - require.Contains(t, n.Operator.SigningKeys, v) + AssertTrue(n.Operator.SigningKeys.Contains(v), t) } - require.Equal(t, o.Identities[0].ID, n.Operator.Identities[0].ID) - require.Equal(t, o.Identities[0].Proof, n.Operator.Identities[0].Proof) - require.Equal(t, o.SystemAccount, o.Operator.SystemAccount) + AssertEquals(o.Identities[0].ID, n.Operator.Identities[0].ID, t) + AssertEquals(o.Identities[0].Proof, n.Operator.Identities[0].Proof, t) + AssertEquals(o.SystemAccount, o.Operator.SystemAccount, t) } func equalAccounts(t *testing.T, o *v1jwt.AccountClaims, n *AccountClaims) { equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) equalImports(t, o.Imports[0], n.Imports[0]) equalExports(t, o.Exports[0], n.Exports[0]) - require.Equal(t, o.Identities[0].ID, n.Account.Identities[0].ID) - require.Equal(t, o.Identities[0].Proof, n.Account.Identities[0].Proof) + AssertEquals(o.Identities[0].ID, n.Account.Identities[0].ID, t) + AssertEquals(o.Identities[0].Proof, n.Account.Identities[0].Proof, t) equalLimits(t, &o.Account.Limits, &n.Account.Limits) for _, v := range o.SigningKeys { - require.Contains(t, n.Account.SigningKeys, v) + AssertTrue(n.Account.SigningKeys.Contains(v), t) } } func equalUsers(t *testing.T, o *v1jwt.UserClaims, n *UserClaims) { equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.GenericFields) for _, v := range o.Sub.Allow { - require.True(t, n.Sub.Allow.Contains(v)) + AssertTrue(n.Sub.Allow.Contains(v), t) } for _, v := range o.Pub.Allow { - require.True(t, n.Pub.Allow.Contains(v)) + AssertTrue(n.Pub.Allow.Contains(v), t) } for _, v := range o.Sub.Deny { - require.True(t, n.Sub.Deny.Contains(v)) + AssertTrue(n.Sub.Deny.Contains(v), t) } for _, v := range o.Pub.Deny { - require.True(t, n.Pub.Deny.Contains(v)) + AssertTrue(n.Pub.Deny.Contains(v), t) } if o.User.Resp == nil { - require.Nil(t, n.User.Resp) + AssertNil(n.User.Resp, t) } else { - require.Equal(t, o.User.Resp.Expires, n.User.Resp.Expires) - require.Equal(t, o.User.Resp.MaxMsgs, n.User.Resp.MaxMsgs) + AssertEquals(o.User.Resp.Expires, n.User.Resp.Expires, t) + AssertEquals(o.User.Resp.MaxMsgs, n.User.Resp.MaxMsgs, t) } if o.IssuerAccount != "" { - require.Equal(t, o.IssuerAccount, n.User.IssuerAccount) + AssertEquals(o.IssuerAccount, n.User.IssuerAccount, t) } - require.Equal(t, o.User.BearerToken, n.User.BearerToken) + AssertEquals(o.User.BearerToken, n.User.BearerToken, t) } func equalExports(t *testing.T, o *v1jwt.Export, n *Export) { - require.Equal(t, o.Name, n.Name) - require.Equal(t, string(o.Subject), string(n.Subject)) - require.EqualValues(t, o.Type, n.Type) - require.Equal(t, o.TokenReq, n.TokenReq) - require.EqualValues(t, o.ResponseType, n.ResponseType) + AssertEquals(o.Name, n.Name, t) + AssertEquals(string(o.Subject), string(n.Subject), t) + AssertEquals(int(o.Type), int(n.Type), t) + AssertEquals(o.TokenReq, n.TokenReq, t) + AssertEquals(string(o.ResponseType), string(n.ResponseType), t) } func equalImports(t *testing.T, o *v1jwt.Import, n *Import) { - require.Equal(t, o.Name, n.Name) - require.Equal(t, string(o.Subject), string(n.Subject)) - require.Equal(t, string(o.To), string(n.To)) - require.EqualValues(t, o.Type, n.Type) + AssertEquals(o.Name, n.Name, t) + AssertEquals(string(o.Subject), string(n.Subject), t) + AssertEquals(string(o.To), string(n.To), t) + AssertEquals(int(o.Type), int(n.Type), t) if o.Token != "" { ot, err := v1jwt.DecodeActivationClaims(o.Token) - require.NoError(t, err) + AssertNoError(err, t) nt, err := DecodeActivationClaims(n.Token) - require.NoError(t, err) + AssertNoError(err, t) equalActivation(t, ot, nt) } } func equalActivation(t *testing.T, o *v1jwt.ActivationClaims, n *ActivationClaims) { equalClaims(t, &o.ClaimsData, &n.ClaimsData, &n.Activation.GenericFields) - require.Equal(t, string(o.ImportSubject), string(n.ImportSubject)) - require.EqualValues(t, o.ImportType, n.ImportType) + AssertEquals(string(o.ImportSubject), string(n.ImportSubject), t) + AssertEquals(int(o.ImportType), int(n.ImportType), t) } func equalLimits(t *testing.T, o *v1jwt.OperatorLimits, n *OperatorLimits) { - require.Equal(t, o.Subs, n.Subs) - require.Equal(t, o.Conn, n.Conn) - require.Equal(t, o.LeafNodeConn, n.LeafNodeConn) - require.Equal(t, o.Imports, n.Imports) - require.Equal(t, o.Exports, n.Exports) - require.Equal(t, o.Data, n.Data) - require.Equal(t, o.Payload, n.Payload) - require.Equal(t, o.WildcardExports, n.WildcardExports) + AssertEquals(o.Subs, n.Subs, t) + AssertEquals(o.Conn, n.Conn, t) + AssertEquals(o.LeafNodeConn, n.LeafNodeConn, t) + AssertEquals(o.Imports, n.Imports, t) + AssertEquals(o.Exports, n.Exports, t) + AssertEquals(o.Data, n.Data, t) + AssertEquals(o.Payload, n.Payload, t) + AssertEquals(o.WildcardExports, n.WildcardExports, t) } diff --git a/v2/genericclaims_test.go b/v2/genericclaims_test.go index 797481d..f0af14f 100644 --- a/v2/genericclaims_test.go +++ b/v2/genericclaims_test.go @@ -1,5 +1,5 @@ /* - * Copyright 2018 The NATS Authors + * Copyright 2018-2020 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 @@ -18,8 +18,6 @@ package jwt import ( "testing" "time" - - "github.com/stretchr/testify/require" ) func TestNewGenericClaims(t *testing.T) { @@ -40,15 +38,14 @@ func TestNewGenericClaims(t *testing.T) { t.Fatal("failed to decode", err) } - require.Equal(t, gc.String(), uc2.String()) - require.Equal(t, gc.Name, uc2.Name) - require.Equal(t, gc.Audience, uc2.Audience) - require.Equal(t, gc.Expires, uc2.Expires) - require.Equal(t, gc.NotBefore, uc2.NotBefore) - require.Equal(t, gc.Subject, uc2.Subject) - require.Contains(t, gc.Data, "test") - require.Equal(t, gc.Data["test"], true) + AssertEquals(gc.String(), uc2.String(), t) + AssertEquals(gc.Name, uc2.Name, t) + AssertEquals(gc.Audience, uc2.Audience, t) + AssertEquals(gc.Expires, uc2.Expires, t) + AssertEquals(gc.NotBefore, uc2.NotBefore, t) + AssertEquals(gc.Subject, uc2.Subject, t) + AssertEquals(gc.Data["test"], true, t) AssertEquals(gc.Claims() != nil, true, t) AssertEquals(gc.Payload() != nil, true, t) } diff --git a/v2/go.mod b/v2/go.mod index 23ceb9c..9a2c5a3 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -3,7 +3,6 @@ module github.com/nats-io/jwt/v2 require ( github.com/nats-io/jwt v0.3.2 github.com/nats-io/nkeys v0.1.4 - github.com/stretchr/testify v1.5.1 ) replace github.com/nats-io/jwt v0.3.2 => ../ diff --git a/v2/operator_claims_test.go b/v2/operator_claims_test.go index 90dfc13..31dac22 100644 --- a/v2/operator_claims_test.go +++ b/v2/operator_claims_test.go @@ -20,8 +20,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/require" - "github.com/nats-io/nkeys" ) @@ -447,9 +445,17 @@ func TestTags(t *testing.T) { oJwt := encode(oc, okp, t) oc2, err := DecodeOperatorClaims(oJwt) - require.NoError(t, err) - require.Len(t, oc2.GenericFields.Tags, 3) - require.Contains(t, oc.GenericFields.Tags, "one") - require.Contains(t, oc.GenericFields.Tags, "two") - require.Contains(t, oc.GenericFields.Tags, "three") + if err != nil { + t.Fatal(err) + } + if len(oc2.GenericFields.Tags) != 3 { + t.Fatal("expected 3 tags") + } + for _, v := range oc.GenericFields.Tags { + AssertFalse(v == "TWO", t) + } + + AssertTrue(oc.GenericFields.Tags.Contains("one"), t) + AssertTrue(oc.GenericFields.Tags.Contains("two"), t) + AssertTrue(oc.GenericFields.Tags.Contains("three"), t) } diff --git a/v2/util_test.go b/v2/util_test.go index eda0ad9..9ee78a7 100644 --- a/v2/util_test.go +++ b/v2/util_test.go @@ -48,6 +48,30 @@ func AssertEquals(expected, v interface{}, t *testing.T) { } } +func AssertNil(v interface{}, t *testing.T) { + if v != nil { + t.FailNow() + } +} + +func AssertNoError(err error, t *testing.T) { + if err != nil { + t.Fatal(err) + } +} + +func AssertTrue(condition bool, t *testing.T) { + if !condition { + t.FailNow() + } +} + +func AssertFalse(condition bool, t *testing.T) { + if condition { + t.FailNow() + } +} + func createAccountNKey(t *testing.T) nkeys.KeyPair { kp, err := nkeys.CreateAccount() if err != nil { From d2ec31ee2d224b5b2720cad92877829907e2f338 Mon Sep 17 00:00:00 2001 From: Alberto Ricart Date: Mon, 1 Jun 2020 08:59:43 -0500 Subject: [PATCH 14/14] changed coveralls to collect coverage for every build - currently only targetting v1. --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index edc8530..e43e623 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,10 +27,9 @@ script: - go test -v -coverprofile=./coverage.out ./... deploy: - provider: script - script: $HOME/gopath/bin/goveralls -coverprofile=v2/coverage.out -service travis-ci + script: $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service travis-ci on: - tags: true - condition: ${V} = v2 && $TRAVIS_OS_NAME = linux && $TRAVIS_GO_VERSION =~ ^1.14 + condition: ${V} = "" && $TRAVIS_OS_NAME = linux && $TRAVIS_GO_VERSION =~ ^1.14