Skip to content

Commit

Permalink
start migration of jwt module
Browse files Browse the repository at this point in the history
  • Loading branch information
maxkahan committed May 14, 2024
1 parent 27bcffa commit 445cab8
Show file tree
Hide file tree
Showing 14 changed files with 300 additions and 2 deletions.
2 changes: 1 addition & 1 deletion http_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }]
requires-python = ">=3.8"
dependencies = [
"vonage-utils>=1.1.1",
"vonage-jwt>=1.1.0",
"vonage-jwt>=1.1.1",
"requests>=2.27.0",
"typing-extensions>=4.9.0",
"pydantic>=2.7.1",
Expand Down
5 changes: 5 additions & 0 deletions jwt/CHANGES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 1.1.0
- Add new module with method to verify JWT signatures, `verify_jwt.verify_signature`

# 1.0.0
- First stable release
60 changes: 60 additions & 0 deletions jwt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Vonage JWT Generator for Python

This package (`vonage-jwt`) provides functionality to generate a JWT in Python code.

It is used by the [Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk), specifically by the `vonage-http-client` package, to generate JWTs for authentication. Thus, it doesn't require manual installation or configuration unless you're using this package independently of a SDK.

For full API documentation, refer to the [Vonage Developer documentation](https://developer.vonage.com).

- [Installation](#installation)
- [Generating JWTs](#generating-jwts)
- [Verifying a JWT signature](#verifying-a-jwt-signature)

## Installation

Install from the Python Package Index with pip:

```bash
pip install vonage-jwt
```

## Generating JWTs

This JWT Generator can be used implicitly, just by using the [Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) to make JWT-authenticated API calls.

It can also be used as a standalone JWT generator for use with Vonage APIs, like so:

### Import the `JwtClient` object

```python
from vonage_jwt.jwt import JwtClient
```

### Create a `JwtClient` object

```python
jwt_client = JwtClient(application_id, private_key)
```

### Generate a JWT using the provided application id and private key

```python
jwt_client.generate_application_jwt()
```

Optional JWT claims can be provided in a python dictionary:

```python
claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100}
jwt_client.generate_application_jwt(claims)
```

## Verifying a JWT signature

You can use the `verify_jwt.verify_signature` method to verify a JWT signature is valid.

```python
from vonage_jwt.verify_jwt import verify_signature

verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean
```
27 changes: 27 additions & 0 deletions jwt/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[project]
name = "vonage-jwt"
version = "1.1.1"
description = "Tooling for working with JWTs for Vonage APIs in Python."
readme = "README.md"
authors = [{ name = "Vonage", email = "devrel@vonage.com" }]
requires-python = ">=3.8"
dependencies = [
"pyjwt[crypto] >=1.6.4"
]
classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: Apache Software License",
]

[project.urls]
Homepage = "https://github.com/Vonage/vonage-python-sdk"

[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
1 change: 1 addition & 0 deletions jwt/src/vonage_jwt/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python_sources()
4 changes: 4 additions & 0 deletions jwt/src/vonage_jwt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .jwt import JwtClient, VonageJwtError
from .verify_jwt import verify_signature

__all__ = ['JwtClient', 'VonageJwtError', 'verify_signature']
57 changes: 57 additions & 0 deletions jwt/src/vonage_jwt/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import re
from time import time
from jwt import encode
from uuid import uuid4
from typing import Union


class JwtClient:
"""Object used to pass in an application ID and private key to generate JWT methods."""

def __init__(self, application_id: str, private_key: str):
self._application_id = application_id

try:
self._set_private_key(private_key)
except Exception as err:
raise VonageJwtError(err)

if self._application_id is None or self._private_key is None:
raise VonageJwtError(
'Both of "application_id" and "private_key" are required.'
)

def generate_application_jwt(self, jwt_options: dict = {}):
"""
Generates a JWT for the specified Vonage application.
You can override values for application_id and private_key on the JWTClient object by
specifying them in the `jwt_options` dict if required.
"""

iat = int(time())

payload = jwt_options
payload["application_id"] = self._application_id
payload.setdefault("iat", iat)
payload.setdefault("jti", str(uuid4()))
payload.setdefault("exp", iat + (15 * 60))

headers = {'alg': 'RS256', 'typ': 'JWT'}

token = encode(payload, self._private_key, algorithm='RS256', headers=headers)
return bytes(token, 'utf-8')

def _set_private_key(self, key: Union[str, bytes]):
if isinstance(key, (str, bytes)) and re.search("[.][a-zA-Z0-9_]+$", key):
with open(key, "rb") as key_file:
self._private_key = key_file.read()
elif isinstance(key, str) and '-----BEGIN PRIVATE KEY-----' not in key:
raise VonageJwtError(
"If passing the private key directly as a string, it must be formatted correctly with newlines."
)
else:
self._private_key = key


class VonageJwtError(Exception):
"""An error relating to the Vonage JWT Generator."""
19 changes: 19 additions & 0 deletions jwt/src/vonage_jwt/verify_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from jwt import InvalidSignatureError, decode


def verify_signature(token: str, signature_secret: str = None) -> bool:
"""
Method to verify that an incoming JWT was sent by Vonage.
"""

try:
decode(token, signature_secret, algorithms='HS256')
return True
except InvalidSignatureError:
return False
except Exception as e:
raise VonageVerifyJwtError(repr(e))


class VonageVerifyJwtError(Exception):
"""The signature could not be verified."""
1 change: 1 addition & 0 deletions jwt/tests/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python_tests(dependencies=['messages', 'testutils'])
28 changes: 28 additions & 0 deletions jwt/tests/data/private_key.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQdAHqJHs/a+Ra
2ubvSd1vz/aWlJ9BqnMUtB7guTlyggdENAbleIkzep6mUHepDJdQh8Qv6zS3lpUe
K0UkDfr1/FvsvxurGw/YYPagUEhP/HxMbs2rnQTiAdWOT+Ux9vPABoyNYvZB90xN
IVhBDRWgkz1HPQBRNjFcm3NOol83h5Uwp5YroGTWx+rpmIiRhQj3mv6luk102d95
4ulpPpzcYWKIpJNdclJrEkBZaghDZTOpbv79qd+ds9AVp1j8i9cG/owBJpsJWxfw
StMDpNeEZqopeQWmA121sSEsxpAbKJ5DA7F/lmckx74sulKHX1fDWT76cRhloaEQ
VmETdj0VAgMBAAECggEAZ+SBtchz8vKbsBqtAbM/XcR5Iqi1TR2eWMHDJ/65HpSm
+XuyujjerN0e6EZvtT4Uxmq8QaPJNP0kmhI31hXvsB0UVcUUDa4hshb1pIYO3Gq7
Kr8I29EZB2mhndm9Ii9yYhEBiVA66zrNeR225kkWr97iqjhBibhoVr8Vc6oiqcIP
nFy5zSFtQSkhucaPge6rW00JSOD3wg2GM+rgS6r22t8YmqTzAwvwfil5pQfUngal
oywqLOf6CUYXPBleJc1KgaIIP/cSvqh6b/t25o2VXnI4rpRhtleORvYBbH6K6xLa
OWgg6B58T+0/QEqtZIAn4miYtVCkYLB78Ormc7Q9ewKBgQDuSytuYqxdZh/L/RDU
CErFcNO5I1e9fkLAs5dQEBvvdQC74+oA1MsDEVv0xehFa1JwPKSepmvB2UznZg9L
CtR7QKMDZWvS5xx4j0E/b+PiNQ/tlcFZB2UZ0JwviSxdd7omOTscq9c3RIhFHar1
Y38Fixkfm44Ij/K3JqIi2v2QMwKBgQDf8TYOOmAr9UuipUDxMsRSqTGVIY8B+aEJ
W+2aLrqJVkLGTRfrbjzXWYo3+n7kNJjFgNkltDq6HYtufHMYRs/0PPtNR0w0cDPS
Xr7m2LNHTDcBalC/AS4yKZJLNLm+kXA84vkw4qiTjc0LSFxJkouTQzkea0l8EWHt
zRMv/qYVlwKBgBaJOWRJJK/4lo0+M7c5yYh+sSdTNlsPc9Sxp1/FBj9RO26JkXne
pgx2OdIeXWcjTTqcIZ13c71zhZhkyJF6RroZVNFfaCEcBk9IjQ0o0c504jq/7Pc0
gdU9K2g7etykFBDFXNfLUKFDc/fFZIOskzi8/PVGStp4cqXrm23cdBqNAoGBAKtf
A2bP9ViuVjsZCyGJIAPBxlfBXpa8WSe4WZNrvwPqJx9pT6yyp4yE0OkVoJUyStaZ
S5M24NocUd8zDUC+r9TP9d+leAOI+Z87MgumOUuOX2mN2kzQsnFgrrsulhXnZmSx
rNBkI20HTqobrcP/iSAgiU1l/M4c3zwDe3N3A9HxAoGBAM2hYu0Ij6htSNgo/WWr
IEYYXuwf8hPkiuwzlaiWhD3eocgd4S8SsBu/bTCY19hQ2QbBPaYyFlNem+ynQyXx
IOacrgIHCrYnRCxjPfFF/MxgUHJb8ZoiexprP/FME5p0PoRQIEFYa+jVht3hT5wC
9aedWufq4JJb+akO6MVUjTvs
-----END PRIVATE KEY-----
9 changes: 9 additions & 0 deletions jwt/tests/data/public_key.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0HQB6iR7P2vkWtrm70nd
b8/2lpSfQapzFLQe4Lk5coIHRDQG5XiJM3qeplB3qQyXUIfEL+s0t5aVHitFJA36
9fxb7L8bqxsP2GD2oFBIT/x8TG7Nq50E4gHVjk/lMfbzwAaMjWL2QfdMTSFYQQ0V
oJM9Rz0AUTYxXJtzTqJfN4eVMKeWK6Bk1sfq6ZiIkYUI95r+pbpNdNnfeeLpaT6c
3GFiiKSTXXJSaxJAWWoIQ2UzqW7+/anfnbPQFadY/IvXBv6MASabCVsX8ErTA6TX
hGaqKXkFpgNdtbEhLMaQGyieQwOxf5ZnJMe+LLpSh19Xw1k++nEYZaGhEFZhE3Y9
FQIDAQAB
-----END PUBLIC KEY-----
66 changes: 66 additions & 0 deletions jwt/tests/test_jwt_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from vonage_jwt.jwt import JwtClient, VonageJwtError

import os
from pytest import raises
from jwt import decode
from jwt.exceptions import ImmatureSignatureError
from time import time

# Ensure the client isn't being configured with real values
os.environ.clear()


def read_file(path):
with open(os.path.join(os.path.dirname(__file__), path)) as input_file:
return input_file.read()


application_id = 'asdf1234'
private_key_string = read_file('data/private_key.txt')
private_key_file_path = './tests/data/private_key.txt'
jwt_client = JwtClient(application_id, private_key_file_path)

public_key = read_file('data/public_key.txt')


def test_create_jwt_client_key_string():
jwt_client = JwtClient(application_id, private_key_string)
assert jwt_client._application_id == application_id
assert jwt_client._private_key == private_key_string


def test_create_jwt_client_key_file():
jwt_client = JwtClient(application_id, private_key_file_path)
assert jwt_client._application_id == application_id
assert jwt_client._private_key == bytes(private_key_string, 'utf-8')


def test_create_jwt_client_error_incomplete():
with raises(VonageJwtError) as err:
JwtClient(application_id, None)
assert str(err.value) == 'Both of "application_id" and "private_key" are required.'


def test_create_jwt_client_error_invalid_key():
with raises(VonageJwtError) as err:
JwtClient(application_id, 'invalid-private-key-string')
assert (
str(err.value)
== 'If passing the private key directly as a string, it must be formatted correctly with newlines.'
)


def test_generate_application_jwt_basic():
jwt = jwt_client.generate_application_jwt()
decoded_jwt = decode(jwt, key=public_key, algorithms='RS256')
assert decoded_jwt['application_id'] == 'asdf1234'
assert decoded_jwt['exp'] - decoded_jwt['iat'] == 15 * 60


def test_generate_application_jwt_custom_claims():
now = int(time())
claims = {'jti': 'qwerasdfzxcv1234', 'nbf': now + 100}
jwt = jwt_client.generate_application_jwt(claims)
with raises(ImmatureSignatureError) as err:
decode(jwt, key=public_key, algorithms='RS256')
assert str(err.value) == 'The token is not yet valid (nbf)'
20 changes: 20 additions & 0 deletions jwt/tests/test_verify_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from vonage_jwt.verify_jwt import verify_signature, VonageVerifyJwtError
import pytest

token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM'
valid_signature = "qwertyuiopasdfghjklzxcvbnm123456"
invalid_signature = 'asdf'


def test_verify_signature_valid():
assert verify_signature(token, valid_signature) is True


def test_verify_signature_invalid():
assert verify_signature(token, invalid_signature) is False


def test_verify_signature_error():
with pytest.raises(VonageVerifyJwtError) as e:
verify_signature('asdf', valid_signature)
assert 'DecodeError' in str(e.value)
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ requests>=2.31.0
responses>=0.24.1
pydantic>=2.7.1
typing-extensions>=4.9.0
vonage-jwt>=1.1.0
pyjwt[crypto]>=1.6.4

-e http_client
-e jwt
-e messages
-e number_insight
-e number_insight_v2
Expand Down

0 comments on commit 445cab8

Please sign in to comment.