# Fractal Tokens
> Fractal Tokens provides a flexible way to generate and verify JWT tokens for your Python applications.
[![PyPI Version][pypi-image]][pypi-url]
[![Build Status][build-image]][build-url]
[![Code Coverage][coverage-image]][coverage-url]
[![Code Quality][quality-image]][quality-url]
<!-- Badges -->
[pypi-image]: https://img.shields.io/pypi/v/fractal-tokens
[pypi-url]: https://pypi.org/project/fractal-tokens/
[build-image]: https://github.com/douwevandermeij/fractal-tokens/actions/workflows/build.yml/badge.svg
[build-url]: https://github.com/douwevandermeij/fractal-tokens/actions/workflows/build.yml
[coverage-image]: https://codecov.io/gh/douwevandermeij/fractal-tokens/branch/main/graph/badge.svg
[coverage-url]: https://codecov.io/gh/douwevandermeij/fractal-tokens
[quality-image]: https://api.codeclimate.com/v1/badges/9242f796b5edee2c327d/maintainability
[quality-url]: https://codeclimate.com/github/douwevandermeij/fractal-tokens
## Installation
```sh
pip install fractal-tokens
```
## Development
Setup the development environment by running:
```sh
make deps
pre-commit install
```
Happy coding.
Occasionally you can run:
```sh
make lint
```
This is not explicitly necessary because the git hook does the same thing.
**Do not disable the git hooks upon commit!**
## Usage
A token is a digital key that gives (temporary) access to a certain (online) resource.
This can be anything, it can give a user access to a backend system (login/authentication), or allow a certain file to be downloaded, etc.
In any case, the token is stand-alone, contains its own permissions, is signed and can (thus) be validated and used in a stateless way.
Only to be able to validate the signature, a key related to the key that has been used to sign the token, is necessary to the validating service (the validator).
In case of a symmetric encryption key, the exact same "secret key" needs to be available to the validator.\
In case of an asymmetric encryption key (public/private key pair), the public key needs to be available to the validator (assuming the token has been signed with the private key).
### Dummy TokenService
To illustrate how the token service works we'll be using a dummy TokenService. This dummy TokenService doesn't use encryption.
In our case, it uses JSON to generate and verify tokens (json.dumps and json.loads respectively).
Consider the following TokenService:
```python
class DummyJsonTokenService(TokenService):
def __init__(self, token_payload_cls: Type[TokenPayload] = TokenPayload):
self.token_payload_cls = token_payload_cls
def generate(
self,
payload: dict,
token_type: str = "access",
seconds_valid: int = ACCESS_TOKEN_EXPIRATION_SECONDS,
) -> str:
return json.dumps(self._prepare(payload, token_type, seconds_valid, "dummy"))
def verify(self, token: str, *, typ: str = "access") -> TokenPayload:
try:
return self.token_payload_cls(**self.decode(token))
except Exception:
raise TokenInvalidException()
def decode(self, token: str) -> dict:
return json.loads(token)
def get_unverified_claims(self, token: str) -> dict:
return self.decode(token)
```
Note the `generate` function calls `json.dumps` and the `decode` function, which is also called from `verify`, will call `json.loads`.
The generated tokens from this TokenService are in a JSON format. The JSON formatted tokens can be verified and a TokenPayload object will be returned.
This TokenService can be used during testing. The generated tokens are human-readable, so easy to debug.
Notice the `verify` function returns a TokenPayload object.
### TokenPayload
The `verify` function of a TokenService returns a TokenPayload object, or a subclass. To use a subclass, that class needs to be registered upon initialization of the TokenService.
The TokenPayload is a contract about the payload of the token.
While generating and verifying tokens is stateless, it can be handy to make agreements about the payload structure of the tokens.
Stateless token authentication can be particularly handy in a microservice architecture, where one microservice generated the token
and another consuming (validating) it to be able to process something on behalf of the token's subject.
Since both microservices have their own application context, they don't share the same code (directly).
This means that the payload that is used to generate the token, may be of a different structure than the payload that is expected in the consuming service.
This may happen when one of the two microservices gets a change regarding the payload structure and the other doesn't.
It is a good habit to make at least some constraints around the token payload structure and the TokenPayload object may be of help here.
By default, the TokenPayload just contains default JWT claims and a custom claim `typ` (type of token).
A JWT is a JSON Web Token (https://www.rfc-editor.org/rfc/rfc7519) that holds certain claims to be transferred between different applications/services.
```python
@dataclass
class TokenPayload:
iss: str # Issuer
aud: str # Audience
sub: str # Subject
exp: int # Expiration Time
nbf: int # Not Before
iat: int # Issued At
jti: str # JWT ID
typ: str # Type of token (custom)
```
#### Additional custom claims
While the TokenPayload object is already quite usefull, it can be extended with more custom claims.
For example, when using role-based permissions, the role(s) can be added to the token as well.
Consider the following TokenPayloadRoles object:
```python
@dataclass
class TokenPayloadRoles(TokenPayload):
roles: List[str]
```
This new TokenPayloadRoles can be used as follows in both the generating service as the consuming service (the validator):
```python
token_service = DummyJsonTokenService(TokenPayloadRoles)
```
The TokenPayloadRoles object needs to be available in all application's contexts.
This means it needs to be either copied over to all applications or injected via a dependency (shared module).
The generating service now needs to provide `roles` when generating the token:
```python
token_service = DummyJsonTokenService(TokenPayloadRoles)
token = token_service.generate(
{
"roles": ["admin", "user"],
}
)
```
Failing to provide a `roles` claim will result in an `InvalidPayloadException` error.
The consuming service can now verify the token and get a TokenPayloadRoles object.
```python
token_service = DummyJsonTokenService(TokenPayloadRoles)
payload = token_service.verify(token)
```
The roles themselves can also be verified, but that's out of scope for the TokenService.
For more information on how to validate roles, please check out the [**Fractal Roles**](https://github.com/douwevandermeij/fractal-roles) package.
### Symmetric JWT TokenService
Symmetric encryption means that the key that is used to encrypt/generate a token, should be the same key that should be used to decrypt/validate the token.
This key should be secret because when someone gets hold of the key, he can not only decrypt/validate tokens, but also encrypt/generate new ones.
Any consumer needs to know the secret key to be able to validate tokens. This could be a security risk.
However, if security of the secret key is covered, symmetric encryption is a viable solution for JWT tokens and can be used as follows:
```python
token_service = SymmetricJwtTokenService(issuer="example", secret_key="**SECRET**")
token = token_service.generate(
{
"sub": str(uuid.uuid4()), # any subject
}
)
payload = token_service.verify(token)
```
Similar to the dummy TokenService, also the symmetric TokenService supports custom claims:
```python
token_service = SymmetricJwtTokenService(issuer="example", secret_key="**SECRET**", token_payload_cls=TokenPayloadRoles)
```
### Asymmetric JWT TokenService
Asymmetric encryption means that there are two keys, a private one and a public one, that can be used to respectively encrypt/generate and decrypt/validate tokens.
The private key should be kept secret, but the public key doesn't have to be kept secret because it cannot be used to encrypt/generate new tokens, it can only be used to decrypt/validate.
This is useful, because this way any consumer can validate a token without knowing the secret/private key. This is different from symmetric encryption.
To be able to use asymmetric encryption, you need to have or generate a public/private key pair using the [RSA algorithm](https://en.wikipedia.org/wiki/RSA_(cryptosystem)).
In Python this can be accomplished as follows:
```python
def rsa_key_pair():
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = rsa.generate_private_key(
backend=default_backend(),
public_exponent=65537,
key_size=4096, # use at least 4096 in production
)
private_key = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode("utf-8")
return private_key, key.public_key()
```
Having the public/private key pair, the asymmetric TokenService can be used as follows:
```python
private_key, public_key = rsa_key_pair()
token_service = AsymmetricJwtTokenService(
issuer="example", private_key=private_key, public_key=public_key
)
token = token_service.generate(
{
"sub": str(uuid.uuid4()), # any subject
}
)
payload = token_service.verify(token)
```
Similar to the dummy TokenService, also the asymmetric TokenService supports custom claims:
```python
token_service = AsymmetricJwtTokenService(
issuer="example", private_key=private_key, public_key=public_key, token_payload_cls=TokenPayloadRoles
)
```
#### Extended Asymmetric JWT TokenService
When using asymmetric encryption, where the public key can be shared with other applications, this enables a lot of possibilities.
For example, a consuming applications can have a list of public keys from several different token generating services, using different private keys.
Also, a token generating service may change to a different private key, for any reason.
When multiple public/private key pairs are in play, it is useful to know which private key has been used to generate the token,
to be able to pick the correct/corresponding public key to validate the token.
To do this, the public/private key pair can be annotated with a **key ID** and can be included in the header of the JWT token.
The `ExtendedAsymmetricJwtTokenService` is similar to the regular `AsymmetricJwtTokenService`, but adds a `kid` (key ID) to the header of the JWT token.
This `kid` refers to a generated key pair (public/private key).
```python
private_key, public_key = rsa_key_pair()
kid = str(uuid.uuid4())
token_service = ExtendedAsymmetricJwtTokenService(
issuer="example",
private_key=private_key,
public_key=public_key,
kid=kid,
)
```
In the code above an example is shown for generating asymmetric tokens having a `kid` in the header.
While this code can also be used to validate these tokens, this isn't common since the consuming application most probably doesn't know the private key.
A consuming application would use the following code to validate tokens with a `kid`:
```python
headers = jwt.get_unverified_headers(token)
kid = headers.get("kid", None)
public_key_str = find_public_key(kid) # find public key from a registry using `kid`
public_key = serialization.load_pem_public_key(
public_key_str.encode("utf-8"), backend=default_backend()
)
token_service = AsymmetricJwtTokenService(
issuer="example",
private_key="", # unknown
public_key=public_key,
)
token_service.verify(token)
```
Note that we first need to fetch the `kid` from the header and then do a lookup in a registry to find the public key corresponding to the `kid`.
Once we have the public key, we can just use a regular `AsymmetricJwtTokenService` to validate the token, providing the public key.
However, the function `find_public_key(kid)` is not implemented in this example. For this, Fractal Tokens comes with a JWK service.
Also note that this TokenService cannot be used to generate new tokens, as the private key is unknown (or just an empty string).
If you still try to do so, the TokenService will raise an exception.
#### JWK Service
A JWK is a JSON Web Key (https://www.rfc-editor.org/rfc/rfc7517) that refers to a specific encryption key (pair).
The JWK service holds, or interfaces with, a registry with public keys and their respective key IDs.
For a local registry the `LocalJwkService` can be used. The `LocalJwkService` contains an in-memory list of `Jwk` objects.
```python
@dataclass
class Jwk:
id: str
public_key: str
```
Creating a `LocalJwkService` would go as follows:
```python
private_key, public_key = rsa_key_pair()
jwk_service = LocalJwkService(
[
Jwk(
id=kid,
public_key=public_key.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8"),
)
]
)
```
Using it in the `ExtendedAsymmetricJwtTokenService` would go as follows:
```python
token_service = ExtendedAsymmetricJwtTokenService(
issuer="",
private_key="",
kid="",
jwk_service=jwk_service,
)
```
This TokenService is intended to be used _only_ to validate tokens. It cannot generate new tokens since it doesn't know any private key,
nor kid and therefore the issuer is also not relevant. Hence, these three parameters are empty strings.
When verifying a token with this TokenService, it will look up the `kid` from the token header and find a public key corresponding to the `kid`.
So just call:
```python
token_service.verify(token)
```
When all is fine a payload will be returned. When no public key can be found by `kid`, or `kid` is not present in the token header,
a `NotAllowedException` will be raised.
It might well be that the public key is invalidated/deleted in order to block all access from certain private key.
For example in the case of a security breach.
This might not be happening a lot in case of a `LocalJwkService`, but with a `RemoteJwkService` this makes more sense,
as the public keys are managed in a separate remote service or application.
The `RemoteJwkService` opens an HTTP GET request on a certain endpoint upon calling `get_jwks(...)` on the JWK service.
By default, the `RemoteJwkService` assumes the endpoint will be `/public/keys` on a certain host, and the host is assumed to be in the `iss` (issuer) claim of the token.
The `iss` claim in the token to validate, should then start with (at least) `http`.
Because you can't know beforehand if a token to validate contains a host in the `iss` claim, you can also use the `AutomaticJwkService`
which will check for the issuer to be a host or not and select the `RemoteJwkService` or `LocalJwkService` respectively.
```python
private_key, public_key = rsa_key_pair()
jwk_service = AutomaticJwkService(
[
Jwk(
id=kid,
public_key=public_key.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8"),
)
]
)
```
### Automatic JWT TokenService
To be fully resilient for multiple types of tokens coming at your consuming application, you can use the `AutomaticJwtTokenService`.
```python
token_service = AutomaticJwtTokenService(
issuer="example",
secret_key=secret_key,
jwk_service=jwk_service,
auto_load_jwks_from_issuer=False,
)
```
You can supply it with a symmetric secret key and a JWK service.
The parameter `auto_load_jwks_from_issuer` can be used to preload any JWKs from the issuer.
This is only possible when the `issuer` parameter is a url and it provides JWK public keys.
By default `auto_load_jwks_from_issuer` is `False`.
An `AutomaticJwtTokenService` cannot be used for generating tokens. Token generation requires an explicit chosen TokenService to do so.
#### How does it work?
First, based on the algorithm used, which can be found in the `alg` claim of the token, either the `SymmetricJwtTokenService` or an `AsymmetricJwtTokenService` will be used.
Based on the `kid` claim to be available in the token header the regular `AsymmetricJwtTokenService` will be used (in case of no `kid` claim) or the `ExtendedAsymmetricJwtTokenService` will be used.
The `ExtendedAsymmetricJwtTokenService` can be using an `AutomaticJwkService`, so even if the token contains a `kid` claim, it can still contain an `iss` claim which not represents a host.
This doesn't guarantee all tokens can be validated of course, it still depends on the right public key(s) and/or secret key to be available upon validating a token.
But of course this is the whole essence of security.
### FastAPI implementation
Since Fractal Tokens is designed in a generic way, it can be easily integrated in your FastAPI applications.
You can find the full example on a FastAPI demo in the examples in this repository.
We will make use of FastAPI's dependency injection system to verify tokens.
A protected view may look like this:
```python
@app.get("/protected")
def protected(
payload: TokenPayload = Depends(get_token_payload(token_service=token_service)),
):
return {"Protected": "Data", "TokenPayload": payload}
```
A more complete example:
```python
from fastapi import Depends, FastAPI
from fractal_tokens.services.generic import TokenPayload
from fractal_tokens.services.jwt.asymmetric import AsymmetricJwtTokenService
from fastapi_demo.utils import get_token_payload, rsa_key_pair
app = FastAPI()
# key pair will renew everytime you restart the app, store externally to reuse
private_key, public_key = rsa_key_pair()
token_service = AsymmetricJwtTokenService(
issuer="example", private_key=private_key, public_key=public_key
)
@app.get("/protected")
def protected(
payload: TokenPayload = Depends(get_token_payload(token_service=token_service)),
):
return {"Protected": "Data", "TokenPayload": payload}
```
The most important function here is `get_token_payload`. This dependency will be injected by FastAPI upon calling this function/endpoint.
The `get_token_payload` could be implemented as follows:
```python
from fastapi import Depends
from fastapi.security import OAuth2PasswordBearer
from fractal_tokens.services.generic import TokenPayload, TokenService
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
def get_token_payload(
*,
token_service: TokenService,
typ: str = "access",
):
def _get_payload(token: str = Depends(oauth2_scheme)) -> TokenPayload:
return token_service.verify(token, typ=typ)
return _get_payload
```
The trick is of course calling `verify` on the `token_service`.
```python
return token_service.verify(token, typ=typ)
```
#### Including Roles Verification
I mentioned `get_token_payload` _could_ be implemented like this, because variations or extensions are also possible.
Consider validating roles, as provided in the [**Fractal Roles**](https://github.com/douwevandermeij/fractal-roles) package.
Using Fractal Tokens together with Fractal Roles could result in a function like this:
```python
def get_token_payload_roles(
*,
token_service: TokenService,
roles_service: BaseRolesService,
endpoint: str = "",
method: str = "get",
typ: str = "access",
):
def _get_payload(token: str = Depends(oauth2_scheme)) -> TokenPayloadRoles:
payload = token_service.verify(token, typ=typ)
payload = roles_service.verify(payload, endpoint, method)
return payload
return _get_payload
```
Notice the `roles_service` next to the `token_service` and the roles verification `roles_service.verify(payload, endpoint, method)`.
The protected view in this case may look like this:
```python
@app.get("/protected")
def protected(
payload: TokenPayloadRoles = Depends(
get_token_payload_roles(
token_service=token_service,
roles_service=roles_service,
endpoint="protected",
method="get",
)
),
):
return {"Protected": "Data", "TokenPayload": payload}
```
Checkout the examples for a more complete overview of using FastAPI with Fractal Tokens and Fractal Roles.
For more information on how to validate roles, please check out the [**Fractal Roles**](https://github.com/douwevandermeij/fractal-roles) package.
Raw data
{
"_id": null,
"home_page": "https://github.com/douwevandermeij/fractal-tokens",
"name": "fractal-tokens",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.8",
"maintainer_email": null,
"keywords": null,
"author": "Douwe van der Meij",
"author_email": "douwe@karibu-online.nl",
"download_url": "https://files.pythonhosted.org/packages/c8/41/9e5d7ebd676519e47aa92ab0b90e1fd2029dfe1fb7ff6ab2153da79a7d9f/fractal_tokens-1.3.0.tar.gz",
"platform": null,
"description": "# Fractal Tokens\n\n> Fractal Tokens provides a flexible way to generate and verify JWT tokens for your Python applications.\n\n[![PyPI Version][pypi-image]][pypi-url]\n[![Build Status][build-image]][build-url]\n[![Code Coverage][coverage-image]][coverage-url]\n[![Code Quality][quality-image]][quality-url]\n\n<!-- Badges -->\n\n[pypi-image]: https://img.shields.io/pypi/v/fractal-tokens\n[pypi-url]: https://pypi.org/project/fractal-tokens/\n[build-image]: https://github.com/douwevandermeij/fractal-tokens/actions/workflows/build.yml/badge.svg\n[build-url]: https://github.com/douwevandermeij/fractal-tokens/actions/workflows/build.yml\n[coverage-image]: https://codecov.io/gh/douwevandermeij/fractal-tokens/branch/main/graph/badge.svg\n[coverage-url]: https://codecov.io/gh/douwevandermeij/fractal-tokens\n[quality-image]: https://api.codeclimate.com/v1/badges/9242f796b5edee2c327d/maintainability\n[quality-url]: https://codeclimate.com/github/douwevandermeij/fractal-tokens\n\n## Installation\n\n```sh\npip install fractal-tokens\n```\n\n## Development\n\nSetup the development environment by running:\n\n```sh\nmake deps\npre-commit install\n```\n\nHappy coding.\n\nOccasionally you can run:\n\n```sh\nmake lint\n```\n\nThis is not explicitly necessary because the git hook does the same thing.\n\n**Do not disable the git hooks upon commit!**\n\n## Usage\n\nA token is a digital key that gives (temporary) access to a certain (online) resource.\nThis can be anything, it can give a user access to a backend system (login/authentication), or allow a certain file to be downloaded, etc.\n\nIn any case, the token is stand-alone, contains its own permissions, is signed and can (thus) be validated and used in a stateless way.\nOnly to be able to validate the signature, a key related to the key that has been used to sign the token, is necessary to the validating service (the validator).\n\nIn case of a symmetric encryption key, the exact same \"secret key\" needs to be available to the validator.\\\nIn case of an asymmetric encryption key (public/private key pair), the public key needs to be available to the validator (assuming the token has been signed with the private key).\n\n### Dummy TokenService\n\nTo illustrate how the token service works we'll be using a dummy TokenService. This dummy TokenService doesn't use encryption.\nIn our case, it uses JSON to generate and verify tokens (json.dumps and json.loads respectively).\n\nConsider the following TokenService:\n\n```python\nclass DummyJsonTokenService(TokenService):\n def __init__(self, token_payload_cls: Type[TokenPayload] = TokenPayload):\n self.token_payload_cls = token_payload_cls\n\n def generate(\n self,\n payload: dict,\n token_type: str = \"access\",\n seconds_valid: int = ACCESS_TOKEN_EXPIRATION_SECONDS,\n ) -> str:\n return json.dumps(self._prepare(payload, token_type, seconds_valid, \"dummy\"))\n\n def verify(self, token: str, *, typ: str = \"access\") -> TokenPayload:\n try:\n return self.token_payload_cls(**self.decode(token))\n except Exception:\n raise TokenInvalidException()\n\n def decode(self, token: str) -> dict:\n return json.loads(token)\n\n def get_unverified_claims(self, token: str) -> dict:\n return self.decode(token)\n```\n\nNote the `generate` function calls `json.dumps` and the `decode` function, which is also called from `verify`, will call `json.loads`.\n\nThe generated tokens from this TokenService are in a JSON format. The JSON formatted tokens can be verified and a TokenPayload object will be returned.\n\nThis TokenService can be used during testing. The generated tokens are human-readable, so easy to debug.\n\nNotice the `verify` function returns a TokenPayload object.\n\n### TokenPayload\n\nThe `verify` function of a TokenService returns a TokenPayload object, or a subclass. To use a subclass, that class needs to be registered upon initialization of the TokenService.\n\nThe TokenPayload is a contract about the payload of the token.\n\nWhile generating and verifying tokens is stateless, it can be handy to make agreements about the payload structure of the tokens.\nStateless token authentication can be particularly handy in a microservice architecture, where one microservice generated the token\nand another consuming (validating) it to be able to process something on behalf of the token's subject.\nSince both microservices have their own application context, they don't share the same code (directly).\nThis means that the payload that is used to generate the token, may be of a different structure than the payload that is expected in the consuming service.\nThis may happen when one of the two microservices gets a change regarding the payload structure and the other doesn't.\n\nIt is a good habit to make at least some constraints around the token payload structure and the TokenPayload object may be of help here.\nBy default, the TokenPayload just contains default JWT claims and a custom claim `typ` (type of token).\nA JWT is a JSON Web Token (https://www.rfc-editor.org/rfc/rfc7519) that holds certain claims to be transferred between different applications/services.\n\n```python\n@dataclass\nclass TokenPayload:\n iss: str # Issuer\n aud: str # Audience\n sub: str # Subject\n exp: int # Expiration Time\n nbf: int # Not Before\n iat: int # Issued At\n jti: str # JWT ID\n typ: str # Type of token (custom)\n```\n\n#### Additional custom claims\n\nWhile the TokenPayload object is already quite usefull, it can be extended with more custom claims.\nFor example, when using role-based permissions, the role(s) can be added to the token as well.\n\nConsider the following TokenPayloadRoles object:\n\n```python\n@dataclass\nclass TokenPayloadRoles(TokenPayload):\n roles: List[str]\n```\n\nThis new TokenPayloadRoles can be used as follows in both the generating service as the consuming service (the validator):\n\n```python\ntoken_service = DummyJsonTokenService(TokenPayloadRoles)\n```\n\nThe TokenPayloadRoles object needs to be available in all application's contexts.\nThis means it needs to be either copied over to all applications or injected via a dependency (shared module).\n\nThe generating service now needs to provide `roles` when generating the token:\n\n```python\ntoken_service = DummyJsonTokenService(TokenPayloadRoles)\ntoken = token_service.generate(\n {\n \"roles\": [\"admin\", \"user\"],\n }\n)\n```\n\nFailing to provide a `roles` claim will result in an `InvalidPayloadException` error.\n\nThe consuming service can now verify the token and get a TokenPayloadRoles object.\n\n```python\ntoken_service = DummyJsonTokenService(TokenPayloadRoles)\npayload = token_service.verify(token)\n```\n\nThe roles themselves can also be verified, but that's out of scope for the TokenService.\nFor more information on how to validate roles, please check out the [**Fractal Roles**](https://github.com/douwevandermeij/fractal-roles) package.\n\n### Symmetric JWT TokenService\n\nSymmetric encryption means that the key that is used to encrypt/generate a token, should be the same key that should be used to decrypt/validate the token.\nThis key should be secret because when someone gets hold of the key, he can not only decrypt/validate tokens, but also encrypt/generate new ones.\nAny consumer needs to know the secret key to be able to validate tokens. This could be a security risk.\n\nHowever, if security of the secret key is covered, symmetric encryption is a viable solution for JWT tokens and can be used as follows:\n\n```python\ntoken_service = SymmetricJwtTokenService(issuer=\"example\", secret_key=\"**SECRET**\")\n\ntoken = token_service.generate(\n {\n \"sub\": str(uuid.uuid4()), # any subject\n }\n)\n\npayload = token_service.verify(token)\n```\n\nSimilar to the dummy TokenService, also the symmetric TokenService supports custom claims:\n\n```python\ntoken_service = SymmetricJwtTokenService(issuer=\"example\", secret_key=\"**SECRET**\", token_payload_cls=TokenPayloadRoles)\n```\n\n### Asymmetric JWT TokenService\n\nAsymmetric encryption means that there are two keys, a private one and a public one, that can be used to respectively encrypt/generate and decrypt/validate tokens.\nThe private key should be kept secret, but the public key doesn't have to be kept secret because it cannot be used to encrypt/generate new tokens, it can only be used to decrypt/validate.\nThis is useful, because this way any consumer can validate a token without knowing the secret/private key. This is different from symmetric encryption.\n\nTo be able to use asymmetric encryption, you need to have or generate a public/private key pair using the [RSA algorithm](https://en.wikipedia.org/wiki/RSA_(cryptosystem)).\nIn Python this can be accomplished as follows:\n\n```python\ndef rsa_key_pair():\n from cryptography.hazmat.backends import default_backend\n from cryptography.hazmat.primitives import serialization\n from cryptography.hazmat.primitives.asymmetric import rsa\n\n key = rsa.generate_private_key(\n backend=default_backend(),\n public_exponent=65537,\n key_size=4096, # use at least 4096 in production\n )\n\n private_key = key.private_bytes(\n serialization.Encoding.PEM,\n serialization.PrivateFormat.PKCS8,\n serialization.NoEncryption(),\n ).decode(\"utf-8\")\n\n return private_key, key.public_key()\n```\n\nHaving the public/private key pair, the asymmetric TokenService can be used as follows:\n\n```python\nprivate_key, public_key = rsa_key_pair()\ntoken_service = AsymmetricJwtTokenService(\n issuer=\"example\", private_key=private_key, public_key=public_key\n)\n\ntoken = token_service.generate(\n {\n \"sub\": str(uuid.uuid4()), # any subject\n }\n)\n\npayload = token_service.verify(token)\n```\n\nSimilar to the dummy TokenService, also the asymmetric TokenService supports custom claims:\n\n```python\ntoken_service = AsymmetricJwtTokenService(\n issuer=\"example\", private_key=private_key, public_key=public_key, token_payload_cls=TokenPayloadRoles\n)\n```\n\n#### Extended Asymmetric JWT TokenService\n\nWhen using asymmetric encryption, where the public key can be shared with other applications, this enables a lot of possibilities.\nFor example, a consuming applications can have a list of public keys from several different token generating services, using different private keys.\nAlso, a token generating service may change to a different private key, for any reason.\nWhen multiple public/private key pairs are in play, it is useful to know which private key has been used to generate the token,\nto be able to pick the correct/corresponding public key to validate the token.\nTo do this, the public/private key pair can be annotated with a **key ID** and can be included in the header of the JWT token.\n\nThe `ExtendedAsymmetricJwtTokenService` is similar to the regular `AsymmetricJwtTokenService`, but adds a `kid` (key ID) to the header of the JWT token.\nThis `kid` refers to a generated key pair (public/private key).\n\n```python\nprivate_key, public_key = rsa_key_pair()\nkid = str(uuid.uuid4())\ntoken_service = ExtendedAsymmetricJwtTokenService(\n issuer=\"example\",\n private_key=private_key,\n public_key=public_key,\n kid=kid,\n)\n```\n\nIn the code above an example is shown for generating asymmetric tokens having a `kid` in the header.\nWhile this code can also be used to validate these tokens, this isn't common since the consuming application most probably doesn't know the private key.\n\nA consuming application would use the following code to validate tokens with a `kid`:\n\n```python\nheaders = jwt.get_unverified_headers(token)\nkid = headers.get(\"kid\", None)\npublic_key_str = find_public_key(kid) # find public key from a registry using `kid`\n\npublic_key = serialization.load_pem_public_key(\n public_key_str.encode(\"utf-8\"), backend=default_backend()\n)\ntoken_service = AsymmetricJwtTokenService(\n issuer=\"example\",\n private_key=\"\", # unknown\n public_key=public_key,\n)\n\ntoken_service.verify(token)\n```\n\nNote that we first need to fetch the `kid` from the header and then do a lookup in a registry to find the public key corresponding to the `kid`.\nOnce we have the public key, we can just use a regular `AsymmetricJwtTokenService` to validate the token, providing the public key.\nHowever, the function `find_public_key(kid)` is not implemented in this example. For this, Fractal Tokens comes with a JWK service.\n\nAlso note that this TokenService cannot be used to generate new tokens, as the private key is unknown (or just an empty string).\nIf you still try to do so, the TokenService will raise an exception.\n\n#### JWK Service\n\nA JWK is a JSON Web Key (https://www.rfc-editor.org/rfc/rfc7517) that refers to a specific encryption key (pair).\nThe JWK service holds, or interfaces with, a registry with public keys and their respective key IDs.\nFor a local registry the `LocalJwkService` can be used. The `LocalJwkService` contains an in-memory list of `Jwk` objects.\n\n```python\n@dataclass\nclass Jwk:\n id: str\n public_key: str\n```\n\nCreating a `LocalJwkService` would go as follows:\n\n```python\nprivate_key, public_key = rsa_key_pair()\n\njwk_service = LocalJwkService(\n [\n Jwk(\n id=kid,\n public_key=public_key.public_bytes(\n serialization.Encoding.PEM,\n serialization.PublicFormat.SubjectPublicKeyInfo,\n ).decode(\"utf-8\"),\n )\n ]\n)\n```\n\nUsing it in the `ExtendedAsymmetricJwtTokenService` would go as follows:\n\n```python\ntoken_service = ExtendedAsymmetricJwtTokenService(\n issuer=\"\",\n private_key=\"\",\n kid=\"\",\n jwk_service=jwk_service,\n)\n```\n\nThis TokenService is intended to be used _only_ to validate tokens. It cannot generate new tokens since it doesn't know any private key,\nnor kid and therefore the issuer is also not relevant. Hence, these three parameters are empty strings.\n\nWhen verifying a token with this TokenService, it will look up the `kid` from the token header and find a public key corresponding to the `kid`.\n\nSo just call:\n\n```python\ntoken_service.verify(token)\n```\n\nWhen all is fine a payload will be returned. When no public key can be found by `kid`, or `kid` is not present in the token header,\na `NotAllowedException` will be raised.\n\nIt might well be that the public key is invalidated/deleted in order to block all access from certain private key.\nFor example in the case of a security breach.\n\nThis might not be happening a lot in case of a `LocalJwkService`, but with a `RemoteJwkService` this makes more sense,\nas the public keys are managed in a separate remote service or application.\n\nThe `RemoteJwkService` opens an HTTP GET request on a certain endpoint upon calling `get_jwks(...)` on the JWK service.\nBy default, the `RemoteJwkService` assumes the endpoint will be `/public/keys` on a certain host, and the host is assumed to be in the `iss` (issuer) claim of the token.\nThe `iss` claim in the token to validate, should then start with (at least) `http`.\n\nBecause you can't know beforehand if a token to validate contains a host in the `iss` claim, you can also use the `AutomaticJwkService`\nwhich will check for the issuer to be a host or not and select the `RemoteJwkService` or `LocalJwkService` respectively.\n\n```python\nprivate_key, public_key = rsa_key_pair()\n\njwk_service = AutomaticJwkService(\n [\n Jwk(\n id=kid,\n public_key=public_key.public_bytes(\n serialization.Encoding.PEM,\n serialization.PublicFormat.SubjectPublicKeyInfo,\n ).decode(\"utf-8\"),\n )\n ]\n)\n```\n\n### Automatic JWT TokenService\n\nTo be fully resilient for multiple types of tokens coming at your consuming application, you can use the `AutomaticJwtTokenService`.\n\n```python\ntoken_service = AutomaticJwtTokenService(\n issuer=\"example\",\n secret_key=secret_key,\n jwk_service=jwk_service,\n auto_load_jwks_from_issuer=False,\n)\n```\n\nYou can supply it with a symmetric secret key and a JWK service.\n\nThe parameter `auto_load_jwks_from_issuer` can be used to preload any JWKs from the issuer.\nThis is only possible when the `issuer` parameter is a url and it provides JWK public keys.\nBy default `auto_load_jwks_from_issuer` is `False`.\n\nAn `AutomaticJwtTokenService` cannot be used for generating tokens. Token generation requires an explicit chosen TokenService to do so.\n\n#### How does it work?\n\nFirst, based on the algorithm used, which can be found in the `alg` claim of the token, either the `SymmetricJwtTokenService` or an `AsymmetricJwtTokenService` will be used.\nBased on the `kid` claim to be available in the token header the regular `AsymmetricJwtTokenService` will be used (in case of no `kid` claim) or the `ExtendedAsymmetricJwtTokenService` will be used.\nThe `ExtendedAsymmetricJwtTokenService` can be using an `AutomaticJwkService`, so even if the token contains a `kid` claim, it can still contain an `iss` claim which not represents a host.\n\nThis doesn't guarantee all tokens can be validated of course, it still depends on the right public key(s) and/or secret key to be available upon validating a token.\nBut of course this is the whole essence of security.\n\n### FastAPI implementation\n\nSince Fractal Tokens is designed in a generic way, it can be easily integrated in your FastAPI applications.\nYou can find the full example on a FastAPI demo in the examples in this repository.\n\nWe will make use of FastAPI's dependency injection system to verify tokens.\n\nA protected view may look like this:\n\n```python\n@app.get(\"/protected\")\ndef protected(\n payload: TokenPayload = Depends(get_token_payload(token_service=token_service)),\n):\n return {\"Protected\": \"Data\", \"TokenPayload\": payload}\n```\n\nA more complete example:\n\n```python\nfrom fastapi import Depends, FastAPI\nfrom fractal_tokens.services.generic import TokenPayload\nfrom fractal_tokens.services.jwt.asymmetric import AsymmetricJwtTokenService\n\nfrom fastapi_demo.utils import get_token_payload, rsa_key_pair\n\napp = FastAPI()\n\n# key pair will renew everytime you restart the app, store externally to reuse\nprivate_key, public_key = rsa_key_pair()\n\ntoken_service = AsymmetricJwtTokenService(\n issuer=\"example\", private_key=private_key, public_key=public_key\n)\n\n\n@app.get(\"/protected\")\ndef protected(\n payload: TokenPayload = Depends(get_token_payload(token_service=token_service)),\n):\n return {\"Protected\": \"Data\", \"TokenPayload\": payload}\n```\n\nThe most important function here is `get_token_payload`. This dependency will be injected by FastAPI upon calling this function/endpoint.\n\nThe `get_token_payload` could be implemented as follows:\n\n```python\nfrom fastapi import Depends\nfrom fastapi.security import OAuth2PasswordBearer\n\nfrom fractal_tokens.services.generic import TokenPayload, TokenService\n\noauth2_scheme = OAuth2PasswordBearer(tokenUrl=\"login\")\n\n\ndef get_token_payload(\n *,\n token_service: TokenService,\n typ: str = \"access\",\n):\n def _get_payload(token: str = Depends(oauth2_scheme)) -> TokenPayload:\n return token_service.verify(token, typ=typ)\n\n return _get_payload\n```\n\nThe trick is of course calling `verify` on the `token_service`.\n\n```python\nreturn token_service.verify(token, typ=typ)\n```\n\n#### Including Roles Verification\n\nI mentioned `get_token_payload` _could_ be implemented like this, because variations or extensions are also possible.\nConsider validating roles, as provided in the [**Fractal Roles**](https://github.com/douwevandermeij/fractal-roles) package.\n\nUsing Fractal Tokens together with Fractal Roles could result in a function like this:\n\n```python\ndef get_token_payload_roles(\n *,\n token_service: TokenService,\n roles_service: BaseRolesService,\n endpoint: str = \"\",\n method: str = \"get\",\n typ: str = \"access\",\n):\n def _get_payload(token: str = Depends(oauth2_scheme)) -> TokenPayloadRoles:\n payload = token_service.verify(token, typ=typ)\n payload = roles_service.verify(payload, endpoint, method)\n return payload\n\n return _get_payload\n```\n\nNotice the `roles_service` next to the `token_service` and the roles verification `roles_service.verify(payload, endpoint, method)`.\n\nThe protected view in this case may look like this:\n\n```python\n@app.get(\"/protected\")\ndef protected(\n payload: TokenPayloadRoles = Depends(\n get_token_payload_roles(\n token_service=token_service,\n roles_service=roles_service,\n endpoint=\"protected\",\n method=\"get\",\n )\n ),\n):\n return {\"Protected\": \"Data\", \"TokenPayload\": payload}\n```\nCheckout the examples for a more complete overview of using FastAPI with Fractal Tokens and Fractal Roles.\n\nFor more information on how to validate roles, please check out the [**Fractal Roles**](https://github.com/douwevandermeij/fractal-roles) package.\n",
"bugtrack_url": null,
"license": null,
"summary": "Fractal Tokens provides a flexible way to generate and verify JWT tokens for your Python applications.",
"version": "1.3.0",
"project_urls": {
"Homepage": "https://github.com/douwevandermeij/fractal-tokens"
},
"split_keywords": [],
"urls": [
{
"comment_text": null,
"digests": {
"blake2b_256": "e43ab8ca46d2a248271a2dc6a24607644acca847662fdf30afa151678462502a",
"md5": "fe2e1b40d25bd27590382e60ac32f56a",
"sha256": "0a18daf84dd4c90345e2cffef1bbe10eb0a762bcd883543488b002f9c0cd392a"
},
"downloads": -1,
"filename": "fractal_tokens-1.3.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "fe2e1b40d25bd27590382e60ac32f56a",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.8",
"size": 15346,
"upload_time": "2023-11-21T11:13:39",
"upload_time_iso_8601": "2023-11-21T11:13:39.066643Z",
"url": "https://files.pythonhosted.org/packages/e4/3a/b8ca46d2a248271a2dc6a24607644acca847662fdf30afa151678462502a/fractal_tokens-1.3.0-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": null,
"digests": {
"blake2b_256": "c8419e5d7ebd676519e47aa92ab0b90e1fd2029dfe1fb7ff6ab2153da79a7d9f",
"md5": "61d6ebad9b78a15bf61d8b765b9c2967",
"sha256": "311fb0d31220b4090db4db9e6f6ed003c0d2f4d9e1a847ae6781b2e9db9e6a4a"
},
"downloads": -1,
"filename": "fractal_tokens-1.3.0.tar.gz",
"has_sig": false,
"md5_digest": "61d6ebad9b78a15bf61d8b765b9c2967",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.8",
"size": 25083,
"upload_time": "2023-11-21T11:13:40",
"upload_time_iso_8601": "2023-11-21T11:13:40.983698Z",
"url": "https://files.pythonhosted.org/packages/c8/41/9e5d7ebd676519e47aa92ab0b90e1fd2029dfe1fb7ff6ab2153da79a7d9f/fractal_tokens-1.3.0.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2023-11-21 11:13:40",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "douwevandermeij",
"github_project": "fractal-tokens",
"travis_ci": false,
"coveralls": true,
"github_actions": true,
"tox": true,
"lcname": "fractal-tokens"
}