Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Fixes for v0.11.1 #25

Merged
merged 8 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ repos:
hooks:
- id: blacken-docs
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.13
rev: v0.1.15
hooks:
- id: ruff
args: [ --fix ]
Expand Down
144 changes: 128 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
# JwSkate
# ![jwskate](docs/logo.png)

[![PyPi](https://img.shields.io/pypi/v/jwskate.svg)](https://pypi.python.org/pypi/jwskate)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![PyPi - License](https://img.shields.io/pypi/l/jwskate)](https://pypi.python.org/pypi/jwskate)
[![PyPI - Downloads](https://img.shields.io/pypi/dw/jwskate)](https://pypi.python.org/pypi/jwskate)
[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)

A Pythonic implementation of the JOSE set of IETF specifications: [Json Web Signature][rfc7515], [Keys][rfc7517],
[Algorithms][rfc7518], [Tokens][rfc7519] and [Encryption][rfc7516] (RFC7515 to 7519), and their extensions
[ECDH Signatures][rfc8037] (RFC8037), [JWK Thumbprints][rfc7638] (RFC7638), and [JWK Thumbprint URI][rfc9278] (RFC9278),
and with respects to [JWT Best Current Practices][rfc8725] (RFC8725).
[Algorithms][rfc7518], [Tokens][rfc7519] and [Encryption][rfc7516] (RFC7515 to 7519), hence the name **JWSKATE**, and
their extensions [ECDH Signatures][rfc8037] (RFC8037), [JWK Thumbprints][rfc7638] (RFC7638), and
[JWK Thumbprint URI][rfc9278] (RFC9278), with respects to [JWT Best Current Practices][rfc8725] (RFC8725).

- Free software: MIT
- Documentation: [https://guillp.github.io/jwskate/](https://guillp.github.io/jwskate/)
- Repository: https://github.com/guillp/jwskate/
- Documentation: https://guillp.github.io/jwskate/

Here is a quick usage example: generating a private RSA key, signing some data, then validating that signature with the matching public key:
Here is a quick usage example: generating a private RSA key, signing some data, then validating that signature with the
matching public key:

```python
from jwskate import Jwk
Expand Down Expand Up @@ -119,8 +123,8 @@ jwt = signer.sign(
print(jwt.claims)
```

The generated JWT will include the standardized claims (`iss`, `aud`, `sub`, `iat`, `exp` and `jti`),
together with the `extra_claims` provided to `.sign()`:
The generated JWT will include the standardized claims (`iss`, `aud`, `sub`, `iat`, `exp` and `jti`), together with the
`extra_claims` provided to `.sign()`:

```
{'custom_claim1': 'value1',
Expand Down Expand Up @@ -192,7 +196,6 @@ together with the `extra_claims` provided to `.sign()`:
| `A192GCM` | AES GCM using 192-bit key | [RFC7518, Section 5.3] |
| `A256GCM` | AES GCM using 256-bit key | [RFC7518, Section 5.3] |


### Supported Key Management algorithms


Expand Down Expand Up @@ -245,26 +248,135 @@ have been dissatisfied by all of them so far, so I decided to come up with my ow
- [AuthLib](https://docs.authlib.org/en/latest/jose/)

Not to say that those are _bad_ libs (I actually use `jwcrypto` myself for `jwskate` unit tests), but they either don't
support some important features, lack documentation, or more generally have APIs that don't feel easy-enough, Pythonic-enough
to use.
support some important features, lack documentation, or more generally have APIs that don't feel easy-enough,
Pythonic-enough to use. See [Design](#Design) below for some of the design decisions that lead to `jwskate`.

## Design

### JWK are UserDicts
### Tokens are objects

Since JSON Web Tokens (JWT) are more and more used, JWT generation and validation must be as easy to do as possible. The
`Jwt` class wraps around a JWT value to allow easy access to its headers, claims and signature, and exposes methods to
easily verify the signature.

```python
from jwskate import Jwt

jwt = Jwt(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)
assert jwt.headers == {"alg": "HS256", "typ": "JWT"}

assert jwt.claims == {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}

assert (
jwt.signature
== b"I\xf9J\xc7\x04IH\xc7\x8a(]\x90O\x87\xf0\xa4\xc7\x89\x7f~\x8f:N\xb2%_\xdau\x0b,\xc3\x97"
)
```

`Jwt` instances always represent a syntactically valid JWT. If you try to initialize one with a malformed value, you
will get a `InvalidJwt` exception, with an helpful error message:

```python
jwt = Jwt(
"eyJhbGci-malformedheader.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)
# jwskate.jwt.base.InvalidJwt: Invalid JWT header: it must be a Base64URL-encoded JSON object
```

`Jwt` may be objects, but they are easy to serialize into their representation. Use either `str()` or `bytes()`
depending on what type of value you need, or the `value` attribute:

```python
jwt = Jwt(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)
str(jwt)
# 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
bytes(jwt)
# b'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
assert jwt.value == bytes(jwt)
```

The same is true for JWS and JWE tokens.

### Headers are auto-generated

When signing a JWS, JWE or JWT, the headers are autogenerated by default, based on the used key and algorithm.

You may add your own custom headers using the `extra_headers` parameter, and/or set a custom `typ` header with the parameter of the same name:

```python
from jwskate import SymmetricJwk, Jwt

jwk = SymmetricJwk.from_bytes(b"T0t4llyR@nd0M", kid="symmetric_key1")
jwt = Jwt.sign(
claims={"my": "claims"},
key=jwk,
alg="HS256",
typ="CustomJWT",
extra_headers={"custom_header": "custom_value"},
)
print(jwt)
# eyJhbGciOiJIUzI1NiIsImN1c3RvbV9oZWFkZXIiOiJjdXN0b21fdmFsdWUiLCJ0eXAiOiJDdXN0b21KV1QiLCJraWQiOiJzeW1tZXRyaWNfa2V5MSJ9.eyJteSI6ImNsYWltcyJ9.ZqCp8Crq-mdCXLoy5NiEdPTSUlIFEjrzexA6mKHrMAc
print(jwt.headers)
# {'alg': 'HS256', 'custom_header': 'custom_value', 'typ': 'CustomJWT', 'kid': 'symmetric_key1'}
```

If, for testing purposes, you need to fully control which headers are included in the JWT, even if they are inconsistent,
you can use `Jwt.sign_arbitrary()`:

```python
from jwskate import SymmetricJwk, Jwt

jwk = SymmetricJwk.from_bytes(b"T0t4llyR@nd0M", kid="symmetric_key1")
jwt = Jwt.sign_arbitrary(
headers={
"custom_header": "custom_value",
"typ": "WeirdJWT",
"kid": "R@nd0m_KID",
"alg": "WeirdAlg",
},
claims={"my": "claims"},
key=jwk,
alg="HS256",
)
print(jwt)
# eyJjdXN0b21faGVhZGVyIjoiY3VzdG9tX3ZhbHVlIiwidHlwIjoiV2VpcmRKV1QiLCJraWQiOiJSQG5kMG1fS0lEIiwiYWxnIjoiV2VpcmRBbGcifQ.eyJteSI6ImNsYWltcyJ9.bcTFqCSiVIbyJhxClgsBDIyhbvLXTOXOV55QGqo2mhw
print(jwt.headers) # you asked for inconsistent headers, you have them:
# {'custom_header': 'custom_value', 'typ': 'WeirdJWT', 'kid': 'R@nd0m_KID', 'alg': 'WeirdAlg'}
```

### `Jwk` as thin wrapper around `cryptography` keys

`Jwk` keys are just _thin_ wrappers around keys from the `cryptography` module, or, in the case of symmetric keys,
around `bytes`. But, unlike `cryptography`keys, they present a consistent interface for signature creation/verification,
key management, and encryption/decryption, with all available algorithms.

Everywhere a key is required as parameter, you may pass either a raw `cryptography` key instance, or a `Jwk` instance
(which is actually a thin wrapper around a cryptography key), or a `Mapping` representing the JWK key.

### `Jwk` are `UserDict` instances

JWK are specified as JSON objects, which are parsed as `dict` in Python. The `Jwk` class in `jwskate` is actually a
`UserDict` subclass, which is very similar to a standard `dict`. So you can use it exactly like you would use a `dict`:
you can access its members, dump it back as JSON, etc. The same is true for Signed or Encrypted Json Web tokens in JSON
format. However, you cannot change the key cryptographic materials, since that would lead to unusable keys.

Note that the keys with a `JwkSet` are converted to instances of `Jwk` on initialization. This may introduce an issue
if you try to serialize it to JSON with the standard `json` module, which does not handle `UserDict` by default. You may
either use `JwkSet.to_json()` to get a JSON-serialized string, or `JwkSet.to_dict()` to get a standard `dict`, that is
serializable by the standard `json` module.

### JWA Wrappers

You can use `cryptography` to do the cryptographic operations that are described in
[JWA](https://www.rfc-editor.org/info/rfc7518), but since `cryptography` is a general purpose library, its usage is not
straightforward and gives you plenty of options to carefully select and combine, leaving room for mistakes, errors and
confusion. It has also a quite inconsistent API to handle the different type of keys and algorithms. To work around
this, `jwskate` comes with a set of consistent wrappers that implement the exact JWA specifications, with minimum risk
of mistakes.
confusion. It also has a quite inconsistent API to handle the different key types and algorithms. To work around this,
`jwskate` comes with a set of consistent wrappers that implement the exact JWA specifications, with minimum risk of
mistakes.

### Safe Signature Verification

Expand Down
Binary file added docs/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion jwskate/jwt/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def sign(
headers = dict(alg=alg, **extra_headers)
if typ:
headers["typ"] = typ
if key.kid:
if "kid" in key:
headers["kid"] = key.kid

return cls.sign_arbitrary(claims=claims, headers=headers, key=key, alg=alg)
Expand Down
4 changes: 2 additions & 2 deletions jwskate/jwt/signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import uuid
from typing import Any, Callable, Iterable, Mapping

from jwskate.jwk import Jwk
from jwskate.jwk import Jwk, to_jwk

from .base import Jwt
from .signed import SignedJwt
Expand Down Expand Up @@ -76,7 +76,7 @@ def __init__(
default_leeway: int | None = None,
):
self.issuer = issuer
self.jwk = Jwk(key)
self.jwk = to_jwk(key)
self.alg = alg
self.default_lifetime = default_lifetime
self.default_leeway = default_leeway
Expand Down
2 changes: 1 addition & 1 deletion jwskate/jwt/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def __init__(
self.leeway = leeway
self.verifiers = list(verifiers) if verifiers else []

def verify(self, jwt: SignedJwt | str) -> None:
def verify(self, jwt: SignedJwt | str | bytes) -> None:
"""Verify a given JWT token.

This checks the token signature, issuer, audience and expiration date, plus any custom verification,
Expand Down
25 changes: 25 additions & 0 deletions jwskate/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,28 @@ def to_json(self, *, compact: bool = True, **kwargs: Any) -> str:

"""
return BinaPy.serialize_to("json", self, compact=compact, **kwargs).decode()

def to_dict(self) -> dict[str, Any]:
"""Transform this UserDict into an actual `dict`.

This should only ever be required when serializing to JSON, since the default json
serializer doesn't know how to handle UserDicts.

"""
return {
key: [
dict(inner)
if isinstance(inner, dict)
else inner.to_dict()
if isinstance(inner, BaseJsonDict)
else inner
for inner in val
]
if isinstance(val, list)
else dict(val)
if isinstance(val, dict)
else val.to_dict()
if isinstance(val, BaseJsonDict)
else val
for key, val in self.data.items()
}
14 changes: 14 additions & 0 deletions tests/test_jwk/test_jwks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from typing import Mapping, Any

import pytest
Expand Down Expand Up @@ -75,6 +76,19 @@ def test_jwkset() -> None:
assert jwks.get_jwk_by_kid(jwk.kid) == jwk
assert jwks.get_jwk_by_kid(jwk.kid).use == "sig"

for jwk in jwks.jwks:
assert isinstance(jwk, Jwk)

jwks.remove_jwk(jwk.kid)

d = jwks.to_dict()
assert isinstance(d, dict)
for jwk in d['keys']:
assert isinstance(jwk, dict)

assert json.dumps(d) == '{"keys": [{"kty": "RSA", "n": "mUdmf5vJ3svsPSQ8BCOQVfwQdP8AmAEW21sYYUC5eSKR-pdwnRDBuFrIEjon2ry8cU-uaMjAoEZikPXcCTErye2Sj8fWQ8Wyo8DoGacJlFOJvs_18-CmNBc7oL8gBlYax3-feZZnaVIiJjvxQwUw5GQA6JTFnO8n2pnKMOOd8Gf6YrG-r0T6NXdviw0-2IW4f2UMJApqlu37yF8sgRNGZwDljNOkUtPK76Uz5T513Va4ckOqsVfnt4WoAkAkCl3eVBwGw3TJIbp_DaLUq53go0pXBCNxCHRD9mst69ZuknBLqn0SwKbQ9zJH9QvoqrEZ2q7GzkFzw70F6qH5MDEx2-dxQz_QccFV0XBpq4pkfuWzS8qKVO4QjyC7A0vIJUzrRHE2_moOtWvKTDsa7gfvK6kpnAW0iKnNchzBV0fzXWIIxRJ3_cc8Ue-KPRU9Wxm3heBOx_Qh-bKv9s9fVY9X6rimyX-pIwf-jkgWG8_FgTBuGkKTRcLi-XnwsCFIVNOtolmakbQHlin_lgDQm9s0nHoDJbZgAtzQfkIorclBJBzr2t__xgaZCfpSCLdwZFQvGEh1mK4WbSMMt5-L3zKsNLCBfdMbn2fS9n2hylfRwU_NZCY8f2RHAdP-z402Vq1c9-m2Ew3_695OmV5HoinJQPagY9hI-_EW8nhNWf8l4FE", "e": "AQAB", "d": "EGYw-3SrybxII3eBu1Chw-1Sxm20_s5ZB3GM33TZEzKFa0nyGL_e9g4yay7RLkg0oivsBVZ7M3qsV4WSe-JIpNNX862mCTy44ufD_WCfeADjEyj1T9kodxjIPqfMMZlbRp5rLctPd5d7w1r08l53D2x6o2etZ9-3hB4hoY7syjiZs58AP4jR-0_yvW4Wm_xck7a4MI_zvP-ryVGTdaDeDq2sIZ_QLDNwOikS7xM6cYqyc7k1JUG6Lyqr4cfCg2BtJdMUzysqzMLDDq6t8cmTq-zLeAwhrw2fatknQd0AmgbNNampjLacU2JMDBXw1_hYQ4shBpa-n8HUxPh87HDK_Gl4v7BZJHlzRQ73vkjHExcssZ3gkqEi90T4TNov-9uBXOtbVQCmut1IK6TnzBpaHepIqDovNmuzpc2gD3HFI78wdgHgzzV117lFgmLLIfg-mR9lj-qsJn-mdtKauXCXuZX24dBjGpknyBABx850px5nVQ2X9dWMFLxQomLhlxJAfhHQUsFlnpbw4StMDTz96zx9k_rgeNMTuj8VVTZ6DpOwhItYCvk41uIEbQTuehBRhOk7n6R8GELSizzLd6Yjw7FCxwWYavbnpx1sM9s3oOmxhQmm3BpcSRcochhPr14CA0SkTl1nQY3Z4Q_rIBTNtkDngrRBagjJGgtfN95cSIU", "kid": "uzPODAa44gSUXvSI6zilPwxMJCULUHm0FmXoqXRfYdI"}, {"kty": "RSA", "n": "ycSaZ7HWpKCY4vfmoLWCxsg2KvpD-3dfFlz-2YsrfnMwpJF0nuRrceAxhFF34NlzwTn-JgVc8-lUrOJmcrBcxQNtr7eaJ0QhcRxu_Wre72QhqTddJgNt4V5Zn9P8l8CplSqiLruVI4nKGoiPFQ7mftGY39wxYiiFB6BMb04lAshvCuUOIP8WFFvCb5Afqpugl-NKpO2-C5vWeN_4xNkSO0fEq49Do01oUkMM5t1D4kvE6_BDVA2jxPHSSulXwcnwHmGuhXEfpVh-ME9MW0S0g7v-uNfpJmjVl7VDV87au6C9GKXHQ3NEXt8DWM8HXqRyRQ0XidJLymDOdUnF1_DMtS_wOCDrPFO-4rG_ZH3dwm-fBRSl_chu324OBi_ER6XIQpjkFiQo2pqbD8bI4X6kH8kUASq5gVsBU_hfQUZljSJoR2gQtorNw5EK4rlAQ5jS-ww16WiSna6jzgT_M4ZLr9IPCloe7isVxSk35-gD87xXtl8PzmE1vdpFC9aArkdFL_PK7MOqDvYIb6MRIOBvoxOV524tFKZhNrZUowTLH3a_-sWFLmdvd14iGxuv_DaC-hCmgI0eq7rzE2wUNP5IVmZASoI-3rKb22QdDlAPHNzdsyJCX4JVIPAJrpLOwptObN30404_C4UUD0IN9OXHD-16NaX5SY1thge94shJpTE", "e": "AQAB", "d": "1I1426RNKkDEztW477RHgIGKDtx2oYKveS-eii5CM4PFypgw8vJO_jff8jSgxQ5PE0-0nPkpYwp7WWVn54pDMIjcFDCnBJaRZEc_5VegYzBpBYp9Zn5WUwTCBc2cW4FrJOk00WZrRnTxo-IYWWbJCvBiy_F7VJy7B72mx9rawoPD9wY2TCxtZiUEP-LkeSZZl6iqCfUqL7CLz-qidzz2J90DInhaGL6DF6XrAYo26T5IxQTm6LU1wVO-5YvMFypU-qyM3aa-X8FJrjrbhYprYBu7y54oz33BBYC-4NHZO6-phT2fHT9g11C4heYTLXCvsG6KTXZswVYaKRT-hu31t0mgD8myErBQYcbityrKzdqKNdE9pBiaGMUngUMuaInLRbr2ihXeLSTzgQv6LrEOBxyDeyW43kPTzHtkFOoArOT_xpTjYobIYUTnFOer2rFpetG-B-yRMGSq5hMQ9067cBLfBoOAvJc9MrFTzM6ynPuTh2ZPRV7jZAR0cymtYb2CK_-6eKju2-bqQ0awjb9VkZolYgDccDZWJiM5TuiOBb-HRIdJSkI8KPGlWC-p14Eo3xeMFNjVJo_-lrT91IIaQC-WDSiRva3HZZGVjQPUiABji62wkC9QPD8VwLou044fnBqkiY7whAbDIGRQHpPiN5Co0_ZUEJdKFdVnncS74Q", "kid": "IYIB72QYGIUGP5lYlGmnrBeVOFOxTk9SO_5ajWBu1QE"}]}'



def test_empty_jwkset() -> None:
jwks = JwkSet()
Expand Down
Loading