From cd2a632fff75db2b692a41bfdc93813edebda6c3 Mon Sep 17 00:00:00 2001 From: Arnav Das Date: Fri, 22 Aug 2025 17:46:35 +0530 Subject: [PATCH] jwt and claims --- nats/contrib/__init__.py | 0 nats/contrib/accounts/__init__.py | 0 nats/contrib/accounts/limits.py | 103 ++++++++++++++ nats/contrib/accounts/models.py | 104 ++++++++++++++ nats/contrib/claims/__init__.py | 0 nats/contrib/claims/generic.py | 20 +++ nats/contrib/claims/models.py | 60 ++++++++ nats/contrib/constants.py | 89 ++++++++++++ nats/contrib/exports.py | 34 +++++ nats/contrib/flatten_model.py | 95 +++++++++++++ nats/contrib/imports.py | 18 +++ nats/contrib/jwt.py | 50 +++++++ nats/contrib/keys.py | 42 ++++++ nats/contrib/nkeys.py | 45 ++++++ nats/contrib/operator/__init__.py | 0 nats/contrib/operator/models.py | 53 +++++++ nats/contrib/signingkeys.py | 16 +++ nats/contrib/types.py | 81 +++++++++++ nats/contrib/users/__init__.py | 0 nats/contrib/users/limits.py | 29 ++++ nats/contrib/users/models.py | 32 +++++ nats/contrib/utils.py | 92 ++++++++++++ tests/test_claims.py | 167 ++++++++++++++++++++++ tests/test_jwt.py | 227 ++++++++++++++++++++++++++++++ tests/test_keypairs.py | 100 +++++++++++++ 25 files changed, 1457 insertions(+) create mode 100644 nats/contrib/__init__.py create mode 100644 nats/contrib/accounts/__init__.py create mode 100644 nats/contrib/accounts/limits.py create mode 100644 nats/contrib/accounts/models.py create mode 100644 nats/contrib/claims/__init__.py create mode 100644 nats/contrib/claims/generic.py create mode 100644 nats/contrib/claims/models.py create mode 100644 nats/contrib/constants.py create mode 100644 nats/contrib/exports.py create mode 100644 nats/contrib/flatten_model.py create mode 100644 nats/contrib/imports.py create mode 100644 nats/contrib/jwt.py create mode 100644 nats/contrib/keys.py create mode 100644 nats/contrib/nkeys.py create mode 100644 nats/contrib/operator/__init__.py create mode 100644 nats/contrib/operator/models.py create mode 100644 nats/contrib/signingkeys.py create mode 100644 nats/contrib/types.py create mode 100644 nats/contrib/users/__init__.py create mode 100644 nats/contrib/users/limits.py create mode 100644 nats/contrib/users/models.py create mode 100644 nats/contrib/utils.py create mode 100644 tests/test_claims.py create mode 100644 tests/test_jwt.py create mode 100644 tests/test_keypairs.py diff --git a/nats/contrib/__init__.py b/nats/contrib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nats/contrib/accounts/__init__.py b/nats/contrib/accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nats/contrib/accounts/limits.py b/nats/contrib/accounts/limits.py new file mode 100644 index 00000000..52f152f6 --- /dev/null +++ b/nats/contrib/accounts/limits.py @@ -0,0 +1,103 @@ +from dataclasses import dataclass +from typing import Dict, Optional + +from nats.contrib.flatten_model import FlatteningModel + + +@dataclass +class NatsLimits: + data: Optional[int] = None + payload: Optional[int] = None + subs: Optional[int] = None + + +class AccountLimits(FlatteningModel): + imports: Optional[ + int] # `json:"imports,omitempty"` // Max number of imports + exports: Optional[ + int] # `json:"exports,omitempty"` // Max number of exports + wildcards: Optional[ + bool + ] # `json:"wildcards,omitempty"` // Are wildcards allowed in exports + disallow_bearer: Optional[ + bool + ] # `json:"disallow_bearer,omitempty"` // User JWT can't be bearer token + conn: Optional[ + int + ] # `json:"conn,omitempty"` // Max number of active connections + leaf: Optional[ + int + ] # `json:"leaf,omitempty"` // Max number of active leaf node connections + + def __init__( + self, + imports: Optional[int] = None, + exports: Optional[int] = None, + wildcards: Optional[bool] = None, + disallow_bearer: Optional[bool] = None, + conn: Optional[int] = None, + leaf: Optional[int] = None, + ): + self.imports = imports + self.exports = exports + self.wildcards = wildcards + self.disallow_bearer = disallow_bearer + self.conn = conn + self.leaf = leaf + + +class JetStreamLimits(FlatteningModel): + mem_storage: Optional[int] = None + disk_storage: Optional[int] = None + streams: Optional[int] = None + consumer: Optional[int] = None + mem_max_stream_bytes: Optional[int] = None + disk_max_stream_bytes: Optional[int] = None + max_bytes_required: Optional[bool] = None + max_ack_pending: Optional[int] = None + + def __init__( + self, + mem_storage: Optional[int] = None, + disk_storage: Optional[int] = None, + streams: Optional[int] = None, + consumer: Optional[int] = None, + mem_max_stream_bytes: Optional[int] = None, + disk_max_stream_bytes: Optional[int] = None, + max_bytes_required: Optional[bool] = None, + max_ack_pending: Optional[int] = None, + ): + self.mem_storage = mem_storage + self.disk_storage = disk_storage + self.streams = streams + self.consumer = consumer + self.mem_max_stream_bytes = mem_max_stream_bytes + self.disk_max_stream_bytes = disk_max_stream_bytes + self.max_bytes_required = max_bytes_required + self.max_ack_pending = max_ack_pending + + +JetStreamTieredLimits = Dict[str, JetStreamLimits] + + +class OperatorLimits(FlatteningModel): + nats_limits: NatsLimits + account_limits: AccountLimits + jetstream_limits: Optional[JetStreamLimits] + tiered_limits: Optional[JetStreamTieredLimits + ] # `json:"tiered_limits,omitempty"` + + def __init__( + self, + nats_limits: NatsLimits, + account_limits: AccountLimits, + jetstream_limits: Optional[JetStreamLimits] = None, + tiered_limits: Optional[JetStreamTieredLimits] = None, + ): + self.nats_limits = nats_limits + self.account_limits = account_limits + self.jetstream_limits = jetstream_limits + self.tiered_limits = tiered_limits + + class META: + unflattened_fields = [('tiered_limits', 'tiered_limits')] diff --git a/nats/contrib/accounts/models.py b/nats/contrib/accounts/models.py new file mode 100644 index 00000000..777dca4b --- /dev/null +++ b/nats/contrib/accounts/models.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass, field, fields +from typing import Dict, List, Optional, Union + +from nats.contrib.accounts.limits import OperatorLimits +from nats.contrib.claims.generic import GenericFields +from nats.contrib.exports import Exports +from nats.contrib.flatten_model import FlatteningModel +from nats.contrib.imports import Imports +from nats.contrib.signingkeys import SigningKeys +from nats.contrib.types import Info, Permissions, Types + + +@dataclass +class WeightedMapping: + subject: str + weight: Optional[ + int] = None # uint8 is mapped to int, with Optional for omitempty + cluster: Optional[str] = None + + +@dataclass +class ExternalAuthorization: + auth_users: Optional[List[str]] + allowed_accounts: Optional[List[str]] + xkey: Optional[str] + + +@dataclass +class MsgTrace: + # Destination is the subject the server will send message traces to + # if the inbound message contains the "traceparent" header and has + # its sampled field indicating that the trace should be triggered. + dest: Optional[str] # `json:"dest,omitempty"` + + # Sampling is used to set the probability sampling, that is, the + # server will get a random number between 1 and 100 and trigger + # the trace if the number is lower than this Sampling value. + # The valid range is [1..100]. If the value is not set Validate() + # will set the value to 100. + sampling: Optional[int] # `json:"sampling,omitempty"` + + +class Account(FlatteningModel): + imports: Optional[Imports] # `json:"imports,omitempty"` + exports: Optional[Exports] # `json:"exports,omitempty"` + limits: Optional[OperatorLimits] # `json:"limits,omitempty"` + signing_keys: Optional[SigningKeys] # `json:"signing_keys,omitempty"` + revocations: Optional[Dict[str, int]] # `json:"revocations,omitempty"` + default_permissions: Optional[Permissions + ] # `json:"default_permissions,omitempty"` + mappings: Optional[Dict[str, + WeightedMapping]] # `json:"mappings,omitempty"` + authorization: Optional[ExternalAuthorization + ] # `json:"authorization,omitempty"` + trace: Optional[MsgTrace] # `json:"trace,omitempty"` + cluster_traffic: Optional[str] # `json:"cluster_traffic,omitempty"` + + info: Info = field(default_factory=Info) + generic_fields: GenericFields = field(default_factory=GenericFields) + + def __init__( + self, + imports: Optional[Imports] = None, + exports: Optional[Exports] = None, + limits: Optional[OperatorLimits] = None, + signing_keys: Optional[SigningKeys] = None, + revocations: Optional[Dict[str, int]] = None, + default_permissions: Optional[Permissions] = None, + mappings: Optional[Dict[str, WeightedMapping]] = None, + authorization: Optional[ExternalAuthorization] = None, + trace: Optional[MsgTrace] = None, + cluster_traffic: Optional[str] = None, + info: Optional[Info] = None, + generic_fields: Optional[GenericFields] = None + ): + self.imports = imports + self.exports = exports + self.limits = limits + self.signing_keys = signing_keys + self.revocations = revocations + self.default_permissions = default_permissions + self.mappings = mappings + self.authorization = authorization + self.trace = trace + self.cluster_traffic = cluster_traffic + + self.info = info if info else Info() + self.generic_fields = generic_fields if generic_fields else GenericFields( + type=Types.Account, version=2 + ) + + class Meta: + unflatten_fields = [ + ("imports", "imports"), + ("exports", "exports"), + ("limits", "limits"), + ("signing_keys", "signing_keys"), + ("revocations", "revocations"), + ("default_permissions", "default_permissions"), + ("mappings", "mappings"), + ("authorization", "authorization"), + ("trace", "trace"), + ("cluster_traffic", "cluster_traffic"), + ] diff --git a/nats/contrib/claims/__init__.py b/nats/contrib/claims/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nats/contrib/claims/generic.py b/nats/contrib/claims/generic.py new file mode 100644 index 00000000..8dd1415b --- /dev/null +++ b/nats/contrib/claims/generic.py @@ -0,0 +1,20 @@ +from typing import List, Optional + +from nats.contrib.flatten_model import FlatteningModel +from nats.contrib.types import Types + + +class GenericFields(FlatteningModel): + tags: Optional[List[str]] + type: Optional[Types] + version: Optional[int] + + def __init__( + self, + tags: Optional[List[str]] = None, + type: Optional[Types] = None, + version: Optional[int] = None, + ): + self.tags = tags + self.type = type + self.version = version diff --git a/nats/contrib/claims/models.py b/nats/contrib/claims/models.py new file mode 100644 index 00000000..52c41ebb --- /dev/null +++ b/nats/contrib/claims/models.py @@ -0,0 +1,60 @@ +from typing import List, Optional, Union + +from nats.contrib.accounts.models import Account +from nats.contrib.flatten_model import FlatteningModel +from nats.contrib.operator.models import Operator +from nats.contrib.types import Types +from nats.contrib.users.models import User + + +class Claims(FlatteningModel): + # Claims Data + exp: Optional[int] # Expires int64 `json:"exp,omitempty"` + jti: Optional[str] # ID string `json:"jti,omitempty"` + iat: Optional[int] # IssuedAt int64 `json:"iat,omitempty"` + iss: Optional[str] # Issuer string `json:"iss,omitempty"` + name: Optional[str] # Name string `json:"name,omitempty"` + nbf: Optional[int] # NotBefore int64 `json:"nbf,omitempty"` + sub: Optional[str] # Subject string `json:"sub,omitempty"` + + # Nats Data + nats: Optional[Union[User, Account, Operator]] + issuer_account: Optional[Union[str, bytes]] + + def __init__( + self, + exp: Optional[int] = None, + jti: Optional[str] = None, + iat: Optional[int] = None, + iss: Optional[str] = None, + name: Optional[str] = None, + nbf: Optional[int] = None, + sub: Optional[str] = None, + nats: Optional[Union[User, Account, Operator]] = None, + issuer_account: Optional[Union[str, bytes]] = None, + ): + self.exp: Optional[int] = exp + self.jti: Optional[str] = jti + self.iat: Optional[int] = iat + self.iss: Optional[str] = iss + self.name: Optional[str] = name + self.nbf: Optional[int] = nbf + self.sub: Optional[str] = sub + + self.nats = nats + self.issuer_account = issuer_account + + class Meta: + unflatten_fields = [('nats', 'nats')] + + +class UserClaims(Claims): + pass + + +class AccountClaims(Claims): + pass + + +class OperatorClaims(Claims): + pass diff --git a/nats/contrib/constants.py b/nats/contrib/constants.py new file mode 100644 index 00000000..6ae3b1a6 --- /dev/null +++ b/nats/contrib/constants.py @@ -0,0 +1,89 @@ +from enum import Enum + + +class Prefix(Enum): + Unknown = -1 + + # Seed is the version byte used for encoded NATS Seeds + Seed = 18 << 3 # Base32-encodes to 'S...' + + # PrefixBytePrivate is the version byte used for encoded NATS Private keys + Private = 15 << 3 # Base32-encodes to 'P...' + + # PrefixByteOperator is the version byte used for encoded NATS Operators + Operator = 14 << 3 # Base32-encodes to 'O...' + + # PrefixByteServer is the version byte used for encoded NATS Servers + Server = 13 << 3 # Base32-encodes to 'N...' + + # PrefixByteCluster is the version byte used for encoded NATS Clusters + Cluster = 2 << 3 # Base32-encodes to 'C...' + + # PrefixByteAccount is the version byte used for encoded NATS Accounts + Account = 0 # Base32-encodes to 'A...' + + # PrefixByteUser is the version byte used for encoded NATS Users + User = 20 << 3 # Base32-encodes to 'U...' + + Curve = 23 << 3 # Base32-encodes to 'X...' + + +accLookupReqTokens = 6 +accLookupReqSubj = "$SYS.REQ.ACCOUNT.{subject}.CLAIMS.LOOKUP" +accPackReqSubj = "$SYS.REQ.CLAIMS.PACK" +accListReqSubj = "$SYS.REQ.CLAIMS.LIST" +accClaimsReqSubj = "$SYS.REQ.CLAIMS.UPDATE" +accDeleteReqSubj = "$SYS.REQ.CLAIMS.DELETE" + +connectEventSubj = "$SYS.ACCOUNT.{subject}.CONNECT" +disconnectEventSubj = "$SYS.ACCOUNT.{subject}.DISCONNECT" +accDirectReqSubj = "$SYS.REQ.ACCOUNT.{account_name}.{subject}" +accPingReqSubj = "$SYS.REQ.ACCOUNT.PING.{subject}" # atm. only used for STATZ and CONNZ import from system account +# kept for backward compatibility when using http resolver +# this overlaps with the names for events but you'd have to have the operator private key in order to succeed. +accUpdateEventSubjOld = "$SYS.ACCOUNT.{subject}.CLAIMS.UPDATE" +accUpdateEventSubjNew = "$SYS.REQ.ACCOUNT.{subject}.CLAIMS.UPDATE" +connsRespSubj = "$SYS._INBOX_.{subject}" +accConnsEventSubjNew = "$SYS.ACCOUNT.{subject}.SERVER.CONNS" +accConnsEventSubjOld = "$SYS.SERVER.ACCOUNT.{subject}.CONNS" # kept for backward compatibility +lameDuckEventSubj = "$SYS.SERVER.{subject}.LAMEDUCK" +shutdownEventSubj = "$SYS.SERVER.{subject}.SHUTDOWN" +clientKickReqSubj = "$SYS.REQ.SERVER.{subject}.KICK" +clientLDMReqSubj = "$SYS.REQ.SERVER.{subject}.LDM" +authErrorEventSubj = "$SYS.SERVER.{subject}.CLIENT.AUTH.ERR" +authErrorAccountEventSubj = "$SYS.ACCOUNT.CLIENT.AUTH.ERR" +serverStatsSubj = "$SYS.SERVER.{subject}.STATSZ" +serverDirectReqSubj = "$SYS.REQ.SERVER.{server_id}.{subject}" +serverPingReqSubj = "$SYS.REQ.SERVER.PING.{subject}" +serverStatsPingReqSubj = "$SYS.REQ.SERVER.PING" # use $SYS.REQ.SERVER.PING.STATSZ instead +serverReloadReqSubj = "$SYS.REQ.SERVER.{subject}.RELOAD" # with server ID +leafNodeConnectEventSubj = "$SYS.ACCOUNT.{subject}.LEAFNODE.CONNECT" # for internal use only +remoteLatencyEventSubj = "$SYS.LATENCY.M2.{subject}" +inboxRespSubj = "$SYS._INBOX.{subject}.{subject}" + +# Used to return information to a user on bound account and user permissions. +userDirectInfoSubj = "$SYS.REQ.USER.INFO" +userDirectReqSubj = "$SYS.REQ.USER.{subject}.INFO" + +# FIXME(dlc) - Should account scope, even with wc for now, but later on +# we can then shard as needed. +accNumSubsReqSubj = "$SYS.REQ.ACCOUNT.NSUBS" + +# These are for exported debug services. These are local to this server only. +accSubsSubj = "$SYS.DEBUG.SUBSCRIBERS" + +shutdownEventTokens = 4 +serverSubjectIndex = 2 +accUpdateTokensNew = 6 +accUpdateTokensOld = 5 +accUpdateAccIdxOld = 2 + +accReqTokens = 5 +accReqAccIndex = 3 + +ocspPeerRejectEventSubj = "$SYS.SERVER.%s.OCSP.PEER.CONN.REJECT" +ocspPeerChainlinkInvalidEventSubj = "$SYS.SERVER.%s.OCSP.PEER.LINK.INVALID" + +CurveKeyLen = 32 +CurveDecodeLen = 35 +CurveNonceLen = 24 diff --git a/nats/contrib/exports.py b/nats/contrib/exports.py new file mode 100644 index 00000000..b1217b2c --- /dev/null +++ b/nats/contrib/exports.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass, field +from typing import Dict, List, Literal, Optional + +from nats.contrib.flatten_model import FlatteningModel +from nats.contrib.types import Info + + +@dataclass +class ServiceLatency: + sampling: int + results: str + + +@dataclass +class Export(FlatteningModel): + name: str + subject: str + type: Literal["stream", "service"] + token_req: Optional[bool] = None + revocations: Optional[Dict[str, int]] = None + response_type: Optional[Literal["Singleton", "Stream", "Chunked"]] = None + response_threshold: Optional[int] = None + service_latency: Optional[ServiceLatency] = None + account_token_position: Optional[int] = None + advertise: Optional[bool] = None + allow_trace: Optional[bool] = None + + info: Optional[Info] = field(default_factory=Info) + + class Meta: + unflatten_fields = [("service_latency", "service_latency")] + + +Exports = List[Export] diff --git a/nats/contrib/flatten_model.py b/nats/contrib/flatten_model.py new file mode 100644 index 00000000..e219e43b --- /dev/null +++ b/nats/contrib/flatten_model.py @@ -0,0 +1,95 @@ +import json +from dataclasses import is_dataclass +from typing import List, Mapping, Optional, Union + +from nats.contrib.utils import asdict, bytes_serializer, is_optional_annotation + + +class FlatteningModel: + + def __init__(self, **kwargs): + for field_name, field_type in self.__annotations__.items(): + field_value = kwargs.get(field_name) + if field_value is not None: + setattr(field_name, kwargs.get(field_name)) + + class Meta: + unflatten_fields = [] + + def get_fields(self, ): + for key in self.__dict__.keys(): + if not key.startswith("__"): + if not callable(getattr(self, key)): + yield key + + def singleton( + self, field, field_value, exclude_null_values, omitempty + ) -> Mapping: + # Returns Value of a field to be added or updated in the dict + if issubclass(type(field_value), FlatteningModel): + # Is a FlatteningModel + return field_value.to_dict( + exclude_null_values=exclude_null_values, omitempty=omitempty + ) + elif is_dataclass(field_value): + # Is a Dataclass + return asdict(field_value, omitempty=omitempty) + elif field: + # return {field: field_value} + return field_value + else: + raise NotImplementedError() + + def not_dataclass_or_model( + self, field, field_value, field_annotation + ) -> bool: + if issubclass(type(field_value), FlatteningModel): + # Is a FlatteningModel + return False + elif is_dataclass(field_value): + # Is a Dataclass + return False + elif field: + return True + else: + raise NotImplementedError() + + def to_dict(self, exclude_null_values=False, omitempty=True): + result = {} + unflatten_fields = dict(self.Meta.unflatten_fields) + + for field in self.get_fields(): + field_value = getattr(self, field) + field_annotation = self.__annotations__[field] + if omitempty: + if is_optional_annotation(field_annotation + ) and field_value is None: + continue + + singleton_element = self.singleton( + field, field_value, exclude_null_values, omitempty + ) + + if unflatten_fields.get(field, + False) or self.not_dataclass_or_model( + field, field_value, field_annotation): + result[unflatten_fields.get(field, field)] = singleton_element + else: + # result[field] = singleton_element + result.update(singleton_element) + + if not exclude_null_values: + return result + + return {k: v for k, v in result.items() if v} + + def to_json( + self, exclude_null_values=False, omitempty=True, serializer=None + ): + return json.dumps( + self.to_dict( + exclude_null_values=exclude_null_values, omitempty=omitempty + ), + default=serializer if serializer else bytes_serializer, + separators=(',', ':') + ) diff --git a/nats/contrib/imports.py b/nats/contrib/imports.py new file mode 100644 index 00000000..2078c89f --- /dev/null +++ b/nats/contrib/imports.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import List, Literal, Optional + + +@dataclass +class Import: + name: str + subject: str + type: Literal["stream", "service"] + account: str + token: Optional[str] + to: Optional[str] + local_subject: Optional[str] + share: Optional[bool] + allow_trace: Optional[bool] + + +Imports = List[Import] diff --git a/nats/contrib/jwt.py b/nats/contrib/jwt.py new file mode 100644 index 00000000..17cf5776 --- /dev/null +++ b/nats/contrib/jwt.py @@ -0,0 +1,50 @@ +import re +from enum import Enum + + +class Algorithms(str, Enum): + v1 = "ed25519" + v2 = "ed25519-nkey" + + +nkeys_installed = None +try: + import nkeys + nkeys_installed = True +except ModuleNotFoundError: + nkeys_installed = False + +if nkeys_installed: + + def fmt_creds(token: str, kp: nkeys.KeyPair) -> bytes: + seed = kp.seed.decode() + return f'''-----BEGIN NATS USER JWT----- +{token} +------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----- +{seed} +------END USER NKEY SEED------ +'''.encode() + +else: + + def fmt_creds(*args, **kwargs) -> bytes: + raise Exception("Nkeys is not installed") + + +# def parse_creds(creds: bytes): +# text = creds.decode() +# pattern = re.compile(r"\s*(?:[-]{3,}[^\n]*[-]{3,}\n)(.+?)(?:\n\s*[-]{3,}[^\n]*[-]{3,}\n)", re.DOTALL) +# matches = pattern.findall(text) +# if len(matches) != 2: +# raise ValueError("bad credentials") +# jwt = matches[0].strip() +# key = matches[1].strip() +# uc = decode(jwt) +# aid = uc["nats"].get("issuer_account", uc["iss"]) +# return {"key": key, "jwt": jwt, "uc": uc, "aid": aid} diff --git a/nats/contrib/keys.py b/nats/contrib/keys.py new file mode 100644 index 00000000..9aa141b3 --- /dev/null +++ b/nats/contrib/keys.py @@ -0,0 +1,42 @@ +from typing import List, Union + +from nkeys import KeyPair, from_seed + +Key = Union[str, bytes, KeyPair] + + +def from_public(public_key): + raise NotImplementedError() + + +def parse_key(v: Union[str, bytes]) -> KeyPair: + if isinstance(v, bytes): + v = v.decode('utf-8') + if v[0] == "S": + return from_seed(v.encode('utf-8')) + return from_public(v) + + +def check_key( + v: Key, type: Union[str, List[str]] = "", seed: bool = False +) -> KeyPair: + if isinstance(v, (str, bytes)): + kp: KeyPair = parse_key(v) + else: + kp = v + + k = kp.public_key.decode() + types = [] + + if isinstance(type, list): + types.extend(type) + elif type != "": + types.append(type) + + if len(types) > 0 and k[0] not in types: + raise ValueError(f"unexpected type {k[0]} - wanted {types}") + + if seed: + kp.private_key # may raise if not seed + + return kp diff --git a/nats/contrib/nkeys.py b/nats/contrib/nkeys.py new file mode 100644 index 00000000..2b2fad36 --- /dev/null +++ b/nats/contrib/nkeys.py @@ -0,0 +1,45 @@ +import os +from enum import Enum, IntEnum +from typing import Union + +import nats +from nats.contrib.constants import CurveKeyLen, Prefix +from nkeys import KeyPair, decode_seed, encode_seed, from_seed + + +def create_pair(prefix: Prefix) -> KeyPair: + length = CurveKeyLen if prefix == Prefix.Curve else 32 + raw_seed = os.urandom(length) + seed_str = encode_seed(raw_seed, prefix.value) + + if prefix == Prefix.Curve: + raise NotImplementedError() + # return CurveKP(raw_seed) + else: + return from_seed(seed_str) + + +def createOperator() -> KeyPair: + ''' Creates a KeyPair with an operator prefix + ''' + return create_pair(Prefix.Operator) + + +def createAccount() -> KeyPair: + ''' Creates a KeyPair with an account prefix + ''' + return create_pair(Prefix.Account) + + +def createUser() -> KeyPair: + ''' Creates a KeyPair with a user prefix + ''' + return create_pair(Prefix.User) + + +def createCluster() -> KeyPair: + return create_pair(Prefix.Cluster) + + +def createServer() -> KeyPair: + return create_pair(Prefix.Server) diff --git a/nats/contrib/operator/__init__.py b/nats/contrib/operator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nats/contrib/operator/models.py b/nats/contrib/operator/models.py new file mode 100644 index 00000000..5354d299 --- /dev/null +++ b/nats/contrib/operator/models.py @@ -0,0 +1,53 @@ +from typing import List, Optional + +from nats.contrib.claims.generic import GenericFields +from nats.contrib.flatten_model import FlatteningModel +from nats.contrib.types import Types + + +class Operator(FlatteningModel): + # Slice of other operator NKeys that can be used to sign on behalf of the main + # operator identity. + signing_keys: Optional[List[str]] # `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. + account_server_url: Optional[str] # `json:"account_server_url,omitempty"` + # A list of NATS urls (tls://host:port) where tools can connect to the server + # using proper credentials. + operator_service_urls: Optional[ + List[str]] # `json:"operator_service_urls,omitempty"` + # Identity of the system account + system_account: Optional[str] # `json:"system_account,omitempty"` + # Min Server version + assert_server_version: Optional[ + str] # `json:"assert_server_version,omitempty"` + # Signing of subordinate objects will require signing keys + strict_signing_key_usage: Optional[ + bool] # `json:"strict_signing_key_usage,omitempty"` + + generic_fields: GenericFields + + def __init__( + self, + signing_keys: Optional[List[str]] = None, + account_server_url: Optional[str] = None, + operator_service_urls: Optional[List[str]] = None, + system_account: Optional[str] = None, + assert_server_version: Optional[str] = None, + strict_signing_key_usage: Optional[bool] = None, + generic_fields: Optional[GenericFields] = None + ): + + self.signing_keys: Optional[List[str]] = signing_keys + self.account_server_url: Optional[str] = account_server_url + self.operator_service_urls: Optional[List[str]] = operator_service_urls + self.system_account: Optional[str] = system_account + self.assert_server_version: Optional[str] = assert_server_version + self.strict_signing_key_usage: Optional[bool + ] = strict_signing_key_usage + self.generic_fields: GenericFields = generic_fields if generic_fields else GenericFields( + type=Types.Operator, version=2 + ) diff --git a/nats/contrib/signingkeys.py b/nats/contrib/signingkeys.py new file mode 100644 index 00000000..c378967c --- /dev/null +++ b/nats/contrib/signingkeys.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import List, Literal, Union + +from nats.contrib.users.limits import UserPermissionsLimits + + +@dataclass +class SigningKey: + kind: Literal["user_scope"] + key: str + role: str + template: UserPermissionsLimits + description: str + + +SigningKeys = List[Union[str, SigningKey]] diff --git a/nats/contrib/types.py b/nats/contrib/types.py new file mode 100644 index 00000000..fb75815f --- /dev/null +++ b/nats/contrib/types.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Union + +from nats.contrib.accounts.limits import AccountLimits, NatsLimits +from nats.contrib.flatten_model import FlatteningModel + + +class Types(str, Enum): + Operator = "operator" + Account = "account" + User = "user" + Activation = "activation" + AuthorizationResponse = "authorization_response" + + +class ConnectionType(str, Enum): + STANDARD = "STANDARD" + WEBSOCKET = "WEBSOCKET" + LEAFNODE = "LEAFNODE" + LEAFNODE_WS = "LEAFNODE_WS" + MQTT = "MQTT" + MQTT_WS = "MQTT_WS" + IN_PROCESS = "IN_PROCESS" + + +@dataclass +class Info: + description: Optional[str] = None + info_url: Optional[str] = None + + +@dataclass +class TimeRange: + start: Optional[str] + end: Optional[str] + + +@dataclass +class UserLimits: + src: Optional[List[str]] = None + times: Optional[List[TimeRange]] = None + locale: Optional[str] = None + + +# Inherit AccountLimits as well +# @dataclass +# class Limits(UserLimits, NatsLimits): +# pass +Limits = Union[UserLimits, NatsLimits, AccountLimits] + + +@dataclass +class ResponsePermissions: + max: int + ttl: int + + +@dataclass +class Permission: + allow: Optional[List[str]] + deny: Optional[List[str]] + + +class Permissions(FlatteningModel): + pub: Optional[Union[Permission, Dict[str, List[str]]]] + sub: Optional[Union[Permission, Dict[str, List[str]]]] + resp: Optional[ResponsePermissions] + + def __init__( + self, + pub: Optional[Union[Permission, Dict[str, List[str]]]] = None, + sub: Optional[Union[Permission, Dict[str, List[str]]]] = None, + resp: Optional[ResponsePermissions] = None, + ): + self.pub = pub if pub else {} + self.sub = sub if sub else {} + self.resp = resp if resp else None + + class Meta: + unflatten_fields = [('pub', 'pub'), ('sub', 'sub'), ('resp', 'resp')] diff --git a/nats/contrib/users/__init__.py b/nats/contrib/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nats/contrib/users/limits.py b/nats/contrib/users/limits.py new file mode 100644 index 00000000..d5ea6ae4 --- /dev/null +++ b/nats/contrib/users/limits.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from nats.contrib.flatten_model import FlatteningModel +from nats.contrib.types import ConnectionType, Limits, Permissions + + +class UserPermissionsLimits(FlatteningModel): + permissions: Permissions + limits: Limits + bearer_token: Optional[bool] + allowed_connection_types: Optional[List[ConnectionType]] + + def __init__( + self, + permissions: Permissions, + limits: Limits, + bearer_token: Optional[bool] = None, + allowed_connection_types: Optional[List[ConnectionType]] = None, + ): + self.permissions = permissions + self.limits = limits + self.bearer_token = bearer_token + self.allowed_connection_types = allowed_connection_types + + class Meta: + unflatten_fields = [ + ('allowed_connection_types', 'allowed_connection_types') + ] diff --git a/nats/contrib/users/models.py b/nats/contrib/users/models.py new file mode 100644 index 00000000..90200dfe --- /dev/null +++ b/nats/contrib/users/models.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from nats.contrib.claims.generic import GenericFields +from nats.contrib.flatten_model import FlatteningModel +from nats.contrib.types import ( + ConnectionType, + Limits, + Permissions, + Types, + UserLimits, +) +from nats.contrib.users.limits import UserPermissionsLimits + + +class User(FlatteningModel): + permissions_limits: UserPermissionsLimits + issuer_account: Optional[str] + + generic_fields: GenericFields + + def __init__( + self, + permissions_limits: UserPermissionsLimits, + issuer_account: Optional[str] = None, + generic_fields: Optional[GenericFields] = None, + ): + self.permissions_limits: UserPermissionsLimits = permissions_limits + self.issuer_account: Optional[str] = issuer_account + self.generic_fields: GenericFields = generic_fields if generic_fields else GenericFields( + type=Types.User, version=2 + ) diff --git a/nats/contrib/utils.py b/nats/contrib/utils.py new file mode 100644 index 00000000..12f51c44 --- /dev/null +++ b/nats/contrib/utils.py @@ -0,0 +1,92 @@ +import copy +from dataclasses import _is_dataclass_instance, fields +from typing import List, Mapping, Optional, Union, get_args, get_origin + +# from nats.contrib.flatten_model import FlatteningModel + + +def bytes_serializer(obj): + if isinstance(obj, bytes): + return obj.decode() + raise TypeError( + f"Object of type {obj.__class__.__name__} is not JSON serializable" + ) + + +def is_optional_annotation(annotation): + """ + Checks if a type annotation represents an Optional type. + """ + # Optional[T] is a shortcut for Union[T, NoneType] + if get_origin(annotation) is Union: + return type(None) in get_args(annotation) + return False + + +def asdict(obj, *, dict_factory=dict, omitempty=False): + """Return the fields of a dataclass instance as a new dictionary mapping + field names to field values. + + Example usage:: + + @dataclass + class C: + x: int + y: int + + c = C(1, 2) + assert asdict(c) == {'x': 1, 'y': 2} + + If given, 'dict_factory' will be used instead of built-in dict. + The function applies recursively to field values that are + dataclass instances. This will also look into built-in containers: + tuples, lists, and dicts. + """ + if not _is_dataclass_instance(obj): + raise TypeError("asdict() should be called on dataclass instances") + return _asdict_inner(obj, dict_factory, omitempty) + + +def _asdict_inner(obj, dict_factory, omitempty=False): + if _is_dataclass_instance(obj): + result = [] + for f in fields(obj): + value = getattr(obj, f.name) + if omitempty and is_optional_annotation(f.type) and value is None: + continue + value = _asdict_inner(value, dict_factory) + result.append((f.name, value)) + return dict_factory(result) + elif isinstance(obj, tuple) and hasattr(obj, '_fields'): + # obj is a namedtuple. Recurse into it, but the returned + # object is another namedtuple of the same type. This is + # similar to how other list- or tuple-derived classes are + # treated (see below), but we just need to create them + # differently because a namedtuple's __init__ needs to be + # called differently (see bpo-34363). + + # I'm not using namedtuple's _asdict() + # method, because: + # - it does not recurse in to the namedtuple fields and + # convert them to dicts (using dict_factory). + # - I don't actually want to return a dict here. The main + # use case here is json.dumps, and it handles converting + # namedtuples to lists. Admittedly we're losing some + # information here when we produce a json list instead of a + # dict. Note that if we returned dicts here instead of + # namedtuples, we could no longer call asdict() on a data + # structure where a namedtuple was used as a dict key. + + return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj]) + elif isinstance(obj, (list, tuple)): + # Assume we can create an object of this type by passing in a + # generator (which is not true for namedtuples, handled + # above). + return type(obj)(_asdict_inner(v, dict_factory) for v in obj) + elif isinstance(obj, dict): + return type(obj)( + (_asdict_inner(k, dict_factory), _asdict_inner(v, dict_factory)) + for k, v in obj.items() + ) + else: + return copy.deepcopy(obj) diff --git a/tests/test_claims.py b/tests/test_claims.py new file mode 100644 index 00000000..d8d26519 --- /dev/null +++ b/tests/test_claims.py @@ -0,0 +1,167 @@ +import asyncio + +import pytest + +nkeys_installed = None +try: + import nkeys + nkeys_installed = True +except ModuleNotFoundError: + nkeys_installed = False + +from nats.contrib.accounts.limits import ( + AccountLimits, + JetStreamLimits, + NatsLimits, + OperatorLimits, +) +from nats.contrib.accounts.models import Account +from nats.contrib.claims.generic import GenericFields +from nats.contrib.claims.models import Claims +from nats.contrib.types import Permission, Permissions, Types +from nats.contrib.users.limits import UserPermissionsLimits +from nats.contrib.users.models import User +from tests.utils import ( + NkeysServerTestCase, + SingleServerTestCase, + TrustedServerTestCase, + async_test, + get_config_file, +) + + +class ClaimsTest(SingleServerTestCase): + + def test_account_claims(self): + + if not nkeys_installed: + pytest.skip("nkeys not installed") + + from nats.contrib.nkeys import from_seed + + operatorKP = from_seed( + b'SOALU7LPGJK2BDF7IHD7UZT6ZM23UMKYLGJLNN35QJSUI5BNR4DJRFH4R4' + ) + accountKP = from_seed( + b'SAALXUEDN2QR5KZDDSH5S4RIWAZDM7CVDG5HNJI2HS5LBVYFTLAQCOXZAU' + ) + userKP = from_seed( + b'SUALJTG5JNRQCQKFE652DV4XID522ALOHJNQVHKKDJNVGWHCLHOEXEROEM' + ) + + account_claims = Claims( + name="my-account", + jti="PBFES33GGIFZM6UGC7NY5ARHRBFVFU4UD7FS2WNLZH3KPGWFVEFQ", + iat=1678973945, + iss=operatorKP.public_key.decode(), + sub=accountKP.public_key.decode(), + nats=Account( + limits=OperatorLimits( + nats_limits=NatsLimits(data=-1, payload=-1, subs=-1), + account_limits=AccountLimits( + exports=-1, + imports=-1, + wildcards=True, + conn=-1, + leaf=-1 + ), + jetstream_limits=JetStreamLimits( + disk_storage=-1, mem_storage=-1 + ) + ), + default_permissions=Permissions(), + generic_fields=GenericFields(version=2, type=Types.Account) + ) + ) + + self.assertEqual( + account_claims.to_dict(), { + "iat": 1678973945, + "name": "my-account", + "iss": + "OCCKR76QCKV4R224WP6ZISXWLXLJZWDF22TRZFQM2I6KUPEDQ3OVCJ6N", + "jti": "PBFES33GGIFZM6UGC7NY5ARHRBFVFU4UD7FS2WNLZH3KPGWFVEFQ", + "sub": + "AB5D6N64ZGUTCGETBW3HSORLTJH5UCCB5CKPZFWCF6UV3KI5BCTRPFDC", + "nats": { + "default_permissions": { + "pub": {}, + "sub": {} + }, + "limits": { + "conn": -1, + "data": -1, + "disk_storage": -1, + "wildcards": True, + "exports": -1, + "imports": -1, + "leaf": -1, + "mem_storage": -1, + "payload": -1, + "subs": -1, + }, + "type": Types.Account, + "version": 2 + }, + } + ) + + def test_user_claims(self): + + if not nkeys_installed: + pytest.skip("nkeys not installed") + + from nats.contrib.nkeys import from_seed + + operatorKP = from_seed( + b'SOALU7LPGJK2BDF7IHD7UZT6ZM23UMKYLGJLNN35QJSUI5BNR4DJRFH4R4' + ) + accountKP = from_seed( + b'SAALXUEDN2QR5KZDDSH5S4RIWAZDM7CVDG5HNJI2HS5LBVYFTLAQCOXZAU' + ) + userKP = from_seed( + b'SUALJTG5JNRQCQKFE652DV4XID522ALOHJNQVHKKDJNVGWHCLHOEXEROEM' + ) + + user_claims = Claims( + name="my-user", + jti="OJZUDMVGC5WOWNEVFJOEBQG7CGNVRSEZUPM7KTRLE26TX7OPH3TQ", + iat=1678973945, + iss=accountKP.public_key.decode(), + sub=userKP.public_key.decode(), + nats=User( + permissions_limits=UserPermissionsLimits( + limits=NatsLimits(data=1073741824, payload=-1, subs=-1), + permissions=Permissions( + pub=Permission(allow=["foo.>", "bar.>"], deny=None), + sub=Permission(allow=["_INBOX.>"], deny=None), + ) + ), + generic_fields=GenericFields(version=2, type=Types.User) + ) + ) + + self.assertEqual( + user_claims.to_dict(), { + "iat": 1678973945, + "iss": + "AB5D6N64ZGUTCGETBW3HSORLTJH5UCCB5CKPZFWCF6UV3KI5BCTRPFDC", + "sub": + "UATWOEX5T5LGWJ54SC7H762G5PKSSFPVTJX67IUFENTVPYHA4DPDJUZU", + "jti": "OJZUDMVGC5WOWNEVFJOEBQG7CGNVRSEZUPM7KTRLE26TX7OPH3TQ", + "name": "my-user", + "nats": { + "data": 1073741824, + "payload": -1, + "pub": { + "allow": ["foo.>", "bar.>"] + }, + "sub": { + "allow": ["_INBOX.>"] + }, + "subs": -1, + "type": Types.User, + "version": 2 + }, + } + ) diff --git a/tests/test_jwt.py b/tests/test_jwt.py new file mode 100644 index 00000000..f2aaa8a3 --- /dev/null +++ b/tests/test_jwt.py @@ -0,0 +1,227 @@ +import asyncio + +import pytest + +nkeys_installed = None +try: + import nkeys + nkeys_installed = True +except ModuleNotFoundError: + nkeys_installed = False + +import base64 +import json + +from nats.contrib.jwt import Algorithms +from nats.contrib.utils import bytes_serializer +from tests.utils import SingleServerTestCase, async_test + + +def encode_account_claim(account_claims, operator_key_pair, options): + + header = json.dumps({ + "typ": "JWT", + "alg": Algorithms.v2 + }, + separators=(',', ':')).encode() + jwt_header = base64.urlsafe_b64encode(header).decode().rstrip("=") + + payload = { + "jti": options["jti"], + "iat": options["iat"], + "iss": options["iss"], + "name": account_claims["name"], + "sub": account_claims["sub"], + "nats": { + "limits": { + "subs": account_claims["nats"]["limits"]["subs"], + "data": account_claims["nats"]["limits"]["data"], + "payload": account_claims["nats"]["limits"]["payload"], + "imports": account_claims["nats"]["limits"]["imports"], + "exports": account_claims["nats"]["limits"]["exports"], + "wildcards": account_claims["nats"]["limits"]["wildcards"], + "conn": account_claims["nats"]["limits"]["conn"], + "leaf": account_claims["nats"]["limits"]["leaf"], + "mem_storage": account_claims["nats"]["limits"]["mem_storage"], + "disk_storage": + account_claims["nats"]["limits"]["disk_storage"], + }, + "default_permissions": { + "pub": account_claims["nats"]["default_permissions"]["pub"], + "sub": account_claims["nats"]["default_permissions"]["sub"] + }, + "type": options["type"], + "version": options["version"] + } + } + + payload = json.dumps( + payload, default=bytes_serializer, separators=(',', ':') + ) + jwt_payload = base64.urlsafe_b64encode(payload.encode() + ).decode().rstrip("=") + + jwt_header_payload = f"{jwt_header}.{jwt_payload}" + + jwt_signature = base64.urlsafe_b64encode( + operator_key_pair.sign(jwt_header_payload.encode()) + ).decode().rstrip("=") + + jwt_token = f"{jwt_header}.{jwt_payload}.{jwt_signature}" + return jwt_token + + +def encode_user_claim(user_claims, account_key_pair, options): + + header = json.dumps({ + "typ": "JWT", + "alg": Algorithms.v2 + }, + separators=(',', ':')).encode() + jwt_header = base64.urlsafe_b64encode(header).decode().rstrip("=") + + payload = { + "jti": options["jti"], + "iat": options["iat"], + "iss": options["iss"], + "name": user_claims["name"], + "sub": user_claims["sub"], + "nats": { + "pub": { + "allow": user_claims["nats"]["pub"]["allow"], + }, + "sub": { + "allow": user_claims["nats"]["sub"]["allow"] + }, + "subs": user_claims["nats"]["subs"], + "data": user_claims["nats"]["data"], + "payload": user_claims["nats"]["payload"], + "type": options["type"], + "version": options["version"] + } + } + + payload = json.dumps( + payload, default=bytes_serializer, separators=(',', ':') + ) + jwt_payload = base64.urlsafe_b64encode( + payload.encode().replace( + b">", b"\u003e" + ) # Unicode as per example in https://natsbyexample.com/examples/auth/nkeys-jwts/go + ).decode().rstrip("=") + + jwt_header_payload = f"{jwt_header}.{jwt_payload}" + + jwt_signature = base64.urlsafe_b64encode( + account_key_pair.sign(jwt_header_payload.encode()) + ).decode().rstrip("=") + + jwt_token = f"{jwt_header}.{jwt_payload}.{jwt_signature}" + + return jwt_token + + +class JWTTest(SingleServerTestCase): + + def test_account_claim_jwt(self): + # NOTE: Test replicated from https://natsbyexample.com/examples/auth/nkeys-jwts/go + + if not nkeys_installed: + pytest.skip("nkeys not installed") + + from nats.contrib.nkeys import from_seed + + operatorKP = from_seed( + b'SOALU7LPGJK2BDF7IHD7UZT6ZM23UMKYLGJLNN35QJSUI5BNR4DJRFH4R4' + ) + accountKP = from_seed( + b'SAALXUEDN2QR5KZDDSH5S4RIWAZDM7CVDG5HNJI2HS5LBVYFTLAQCOXZAU' + ) + userKP = from_seed( + b'SUALJTG5JNRQCQKFE652DV4XID522ALOHJNQVHKKDJNVGWHCLHOEXEROEM' + ) + + account_claim = { + "name": "my-account", + "nats": { + "default_permissions": { + "pub": {}, + "sub": {} + }, + "limits": { + "conn": -1, + "data": -1, + "disk_storage": -1, + "exports": -1, + "imports": -1, + "leaf": -1, + "mem_storage": -1, + "payload": -1, + "subs": -1, + "wildcards": True + } + }, + "sub": accountKP.public_key.decode() + } + opts = { + "jti": "PBFES33GGIFZM6UGC7NY5ARHRBFVFU4UD7FS2WNLZH3KPGWFVEFQ", + "iat": 1678973945, + "iss": operatorKP.public_key.decode(), + "type": "account", + "version": 2 + } + account_claim_jwt_token = encode_account_claim( + account_claim, operatorKP, options=opts + ) + + self.assertEqual( + account_claim_jwt_token, + "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJQQkZFUzMzR0dJRlpNNlVHQzdOWTVBUkhSQkZWRlU0VUQ3RlMyV05MWkgzS1BHV0ZWRUZRIiwiaWF0IjoxNjc4OTczOTQ1LCJpc3MiOiJPQ0NLUjc2UUNLVjRSMjI0V1A2WklTWFdMWExKWldERjIyVFJaRlFNMkk2S1VQRURRM09WQ0o2TiIsIm5hbWUiOiJteS1hY2NvdW50Iiwic3ViIjoiQUI1RDZONjRaR1VUQ0dFVEJXM0hTT1JMVEpINVVDQ0I1Q0tQWkZXQ0Y2VVYzS0k1QkNUUlBGREMiLCJuYXRzIjp7ImxpbWl0cyI6eyJzdWJzIjotMSwiZGF0YSI6LTEsInBheWxvYWQiOi0xLCJpbXBvcnRzIjotMSwiZXhwb3J0cyI6LTEsIndpbGRjYXJkcyI6dHJ1ZSwiY29ubiI6LTEsImxlYWYiOi0xLCJtZW1fc3RvcmFnZSI6LTEsImRpc2tfc3RvcmFnZSI6LTF9LCJkZWZhdWx0X3Blcm1pc3Npb25zIjp7InB1YiI6e30sInN1YiI6e319LCJ0eXBlIjoiYWNjb3VudCIsInZlcnNpb24iOjJ9fQ.4-kUapoPK_9A9L_CfJRBEe1XukgVBGaSU3J5tBFbajF3G5660BORa2CRUnN6x0dv-jUgui5EIQeANDB5kh_wDw" + ) + + def test_user_claim_jwt(self): + if not nkeys_installed: + pytest.skip("nkeys not installed") + + from nats.contrib.nkeys import from_seed + + operatorKP = from_seed( + b'SOALU7LPGJK2BDF7IHD7UZT6ZM23UMKYLGJLNN35QJSUI5BNR4DJRFH4R4' + ) + accountKP = from_seed( + b'SAALXUEDN2QR5KZDDSH5S4RIWAZDM7CVDG5HNJI2HS5LBVYFTLAQCOXZAU' + ) + userKP = from_seed( + b'SUALJTG5JNRQCQKFE652DV4XID522ALOHJNQVHKKDJNVGWHCLHOEXEROEM' + ) + + user_claim = { + "name": "my-user", + "sub": userKP.public_key.decode(), + "nats": { + "pub": { + "allow": ["foo.>", "bar.>"] + }, + "sub": { + "allow": ["_INBOX.>"] + }, + "subs": -1, + "data": 1073741824, + "payload": -1, + } + } + opts = { + "jti": "OJZUDMVGC5WOWNEVFJOEBQG7CGNVRSEZUPM7KTRLE26TX7OPH3TQ", + "iat": 1678973945, + "iss": accountKP.public_key.decode(), + "type": "user", + "version": 2 + } + user_claim_jwt_token = encode_user_claim( + user_claim, accountKP, options=opts + ) + + self.assertEqual( + user_claim_jwt_token, + "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJPSlpVRE1WR0M1V09XTkVWRkpPRUJRRzdDR05WUlNFWlVQTTdLVFJMRTI2VFg3T1BIM1RRIiwiaWF0IjoxNjc4OTczOTQ1LCJpc3MiOiJBQjVENk42NFpHVVRDR0VUQlczSFNPUkxUSkg1VUNDQjVDS1BaRldDRjZVVjNLSTVCQ1RSUEZEQyIsIm5hbWUiOiJteS11c2VyIiwic3ViIjoiVUFUV09FWDVUNUxHV0o1NFNDN0g3NjJHNVBLU1NGUFZUSlg2N0lVRkVOVFZQWUhBNERQREpVWlUiLCJuYXRzIjp7InB1YiI6eyJhbGxvdyI6WyJmb28uXHUwMDNlIiwiYmFyLlx1MDAzZSJdfSwic3ViIjp7ImFsbG93IjpbIl9JTkJPWC5cdTAwM2UiXX0sInN1YnMiOi0xLCJkYXRhIjoxMDczNzQxODI0LCJwYXlsb2FkIjotMSwidHlwZSI6InVzZXIiLCJ2ZXJzaW9uIjoyfX0.PYT1aJJXiJd9Jb5b1m03jBs64GJzjKRtLOH4hoKJ8v8MKk13nhzGFtKcIKn2vg00uYBYlOkWPgzJ6hYuKr0ECA" + ) diff --git a/tests/test_keypairs.py b/tests/test_keypairs.py new file mode 100644 index 00000000..ce667ae7 --- /dev/null +++ b/tests/test_keypairs.py @@ -0,0 +1,100 @@ +import asyncio + +import pytest + +nkeys_installed = None +try: + import nkeys + nkeys_installed = True +except ModuleNotFoundError: + nkeys_installed = False + +from tests.utils import ( + NkeysServerTestCase, + TrustedServerTestCase, + async_test, + get_config_file, +) + + +class NkeysTest(NkeysServerTestCase): + + def test_create_nkeys(self): + + if not nkeys_installed: + pytest.skip("nkeys not installed") + + from nats.contrib.nkeys import ( + createAccount, + createOperator, + createUser, + ) + + operatorKP = createOperator() + operatorPub = operatorKP.public_key + operatorPriv = operatorKP.private_key + operatorSeed = operatorKP.seed + + self.assertEqual(type(operatorPub), bytes) + self.assertEqual(type(operatorPriv), bytes) + self.assertEqual(type(operatorSeed), bytes) + + self.assertTrue(operatorPub.startswith(b'O')) + self.assertTrue(operatorPriv.startswith(b'P')) + self.assertTrue(operatorSeed.startswith(b'S')) + + accountKP = createAccount() + accountPub = accountKP.public_key + accountPriv = accountKP.private_key + accountSeed = accountKP.seed + + self.assertEqual(type(accountPub), bytes) + self.assertEqual(type(accountPriv), bytes) + self.assertEqual(type(accountSeed), bytes) + + self.assertTrue(accountPub.startswith(b'A')) + self.assertTrue(accountPriv.startswith(b'P')) + self.assertTrue(accountSeed.startswith(b'S')) + + userKP = createUser() + userPub = userKP.public_key + userPriv = userKP.private_key + userSeed = userKP.seed + + self.assertEqual(type(userPub), bytes) + self.assertEqual(type(userPriv), bytes) + self.assertEqual(type(userSeed), bytes) + + self.assertTrue(userPub.startswith(b'U')) + self.assertTrue(userPriv.startswith(b'P')) + self.assertTrue(userSeed.startswith(b'S')) + + def test_nkeys_from_seed(self): + if not nkeys_installed: + pytest.skip("nkeys not installed") + + from nats.contrib.nkeys import from_seed + + operatorKP = from_seed( + b'SOALU7LPGJK2BDF7IHD7UZT6ZM23UMKYLGJLNN35QJSUI5BNR4DJRFH4R4' + ) + self.assertEqual( + operatorKP.public_key, + b"OCCKR76QCKV4R224WP6ZISXWLXLJZWDF22TRZFQM2I6KUPEDQ3OVCJ6N" + ) + + accountKP = from_seed( + b'SAALXUEDN2QR5KZDDSH5S4RIWAZDM7CVDG5HNJI2HS5LBVYFTLAQCOXZAU' + ) + self.assertEqual( + accountKP.public_key, + b"AB5D6N64ZGUTCGETBW3HSORLTJH5UCCB5CKPZFWCF6UV3KI5BCTRPFDC" + ) + + userKP = from_seed( + b'SUALJTG5JNRQCQKFE652DV4XID522ALOHJNQVHKKDJNVGWHCLHOEXEROEM' + ) + self.assertEqual( + userKP.public_key, + b"UATWOEX5T5LGWJ54SC7H762G5PKSSFPVTJX67IUFENTVPYHA4DPDJUZU" + )