rfc9068 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rfc9068-0.1.0/PKG-INFO +127 -0
- rfc9068-0.1.0/README.md +118 -0
- rfc9068-0.1.0/pyproject.toml +36 -0
- rfc9068-0.1.0/src/rfc9068/__init__.py +62 -0
- rfc9068-0.1.0/src/rfc9068/core.py +1 -0
- rfc9068-0.1.0/src/rfc9068/parser.py +77 -0
- rfc9068-0.1.0/src/rfc9068/payload.py +78 -0
- rfc9068-0.1.0/src/rfc9068/py.typed +0 -0
- rfc9068-0.1.0/src/rfc9068/signature.py +77 -0
rfc9068-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: rfc9068
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Validates OIDC/OAuth2 access tokens following RC9068
|
|
5
|
+
Requires-Dist: pydantic>=2.11.9
|
|
6
|
+
Requires-Dist: pyjwt[crypto]>=2.10.1
|
|
7
|
+
Requires-Python: >=3.11, <4.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# RFC9068 Access Token Validator
|
|
11
|
+
This library provides a means to validate access tokens following the rules laid out in
|
|
12
|
+
[RFC9068](https://datatracker.ietf.org/doc/rfc9068/).
|
|
13
|
+
|
|
14
|
+
## Rationale
|
|
15
|
+
Both [OIDC](https://openid.net/specs/openid-authentication-2_0.html) and
|
|
16
|
+
[OAuth2](https://datatracker.ietf.org/doc/rfc6749/) do not clearly specify *how* to validate access tokens
|
|
17
|
+
as a resource server. This poses a risk as the implementation of choice might differ across applications and
|
|
18
|
+
may be insecure.
|
|
19
|
+
|
|
20
|
+
In order to resolve this issue [RFC7662](https://datatracker.ietf.org/doc/rfc7662/) was published in 2015.
|
|
21
|
+
This method involves the resource server sending the access token to the authorization server to verify it.
|
|
22
|
+
This comes with a big performance penalty, as the resource server needs to make its own request to the
|
|
23
|
+
authorization server everytime it needs to validate a token, which basically needs to happen for every
|
|
24
|
+
authenticated request.
|
|
25
|
+
|
|
26
|
+
To overcome the performance penalty, systems started using access tokens in the form of JSON Web Tokens, which
|
|
27
|
+
can be parsed and validated. However, considering there was no formal definition of what the contents of the
|
|
28
|
+
token should look like, tokens issued by one provider were not necessarily compatible with tokens provided by
|
|
29
|
+
another provider, leading to different implementations of token validation.
|
|
30
|
+
|
|
31
|
+
[RFC9068](https://datatracker.ietf.org/doc/rfc9068/) aims to overcome that obstacle by providing a specification
|
|
32
|
+
on what the contents of the access tokens should look like and how to validate the access tokens.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
Install using uv:
|
|
36
|
+
```shell
|
|
37
|
+
uv add rfc9068
|
|
38
|
+
```
|
|
39
|
+
or using pip:
|
|
40
|
+
```shell
|
|
41
|
+
pip install rfc9068
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
The validator itself is nothing more than a composition, its dependencies do the actual work.
|
|
46
|
+
That means that we need to construct the validator and it's recommended to use dependency injection to do that,
|
|
47
|
+
otherwise perhaps implement a factory.
|
|
48
|
+
|
|
49
|
+
### Factory example
|
|
50
|
+
```python
|
|
51
|
+
from rfc9068 import RFC9068AccessTokenValidator, RFC9068AccessTokenValidatorInterface
|
|
52
|
+
from rfc9068.parser import AccessTokenParser
|
|
53
|
+
from rfc9068.payload import (
|
|
54
|
+
IssuerValidator,
|
|
55
|
+
AudienceValidator,
|
|
56
|
+
ExpirationValidator
|
|
57
|
+
)
|
|
58
|
+
from rfc9068.signature import PyJwtSignatureValidator, PyJwtJWKResolver
|
|
59
|
+
from jwt import PyJWKClient, PyJWS
|
|
60
|
+
|
|
61
|
+
class ValidatorFactory:
|
|
62
|
+
def __call__(
|
|
63
|
+
self,
|
|
64
|
+
jwks_url: str,
|
|
65
|
+
issuer: str,
|
|
66
|
+
audience: str,
|
|
67
|
+
algorithms: list[str] = ["RS256"],
|
|
68
|
+
): RFC9068AccessTokenValidatorInterface:
|
|
69
|
+
return RFC9068AccessTokenValidator(
|
|
70
|
+
AccessTokenParser(),
|
|
71
|
+
PyJwtSignatureValidator(
|
|
72
|
+
PyJwtJWKResolver(
|
|
73
|
+
PyJWKClient(jwks_url),
|
|
74
|
+
),
|
|
75
|
+
PyJWS(),
|
|
76
|
+
),
|
|
77
|
+
IssuerValidator(),
|
|
78
|
+
AudienceValidator(),
|
|
79
|
+
ExpirationValidator(),
|
|
80
|
+
algorithms,
|
|
81
|
+
issuer,
|
|
82
|
+
audience,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
factory = ValidatorFactory()
|
|
86
|
+
validate = factory(
|
|
87
|
+
"http://keycloak:8002/realms/rfc9068/protocol/openid-connect/certs",
|
|
88
|
+
"http://keycloak:8002/realms/rfc9068",
|
|
89
|
+
"test-audience",
|
|
90
|
+
["RS256"],
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Validator
|
|
95
|
+
Now that we have a validator we can use it to validate access tokens.
|
|
96
|
+
```python
|
|
97
|
+
from rfc9068.core import InvalidTokenError
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
validate(access_token)
|
|
101
|
+
except InvalidTokenError as e:
|
|
102
|
+
# Token is not valid
|
|
103
|
+
print(e)
|
|
104
|
+
raise e
|
|
105
|
+
|
|
106
|
+
# When no exceptions are raised the token is valid
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
For development of this package we provide a container setup.
|
|
111
|
+
|
|
112
|
+
### Building
|
|
113
|
+
```shell
|
|
114
|
+
docker compose build
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Starting containers
|
|
118
|
+
```shell
|
|
119
|
+
docker compose run --rm validator sh
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This will also open a shell where we can run our dev tools:
|
|
123
|
+
```shell
|
|
124
|
+
uv run ruff check
|
|
125
|
+
uv run mypy .
|
|
126
|
+
uv run pytest -v --cov --cov-fail-under=100
|
|
127
|
+
```
|
rfc9068-0.1.0/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# RFC9068 Access Token Validator
|
|
2
|
+
This library provides a means to validate access tokens following the rules laid out in
|
|
3
|
+
[RFC9068](https://datatracker.ietf.org/doc/rfc9068/).
|
|
4
|
+
|
|
5
|
+
## Rationale
|
|
6
|
+
Both [OIDC](https://openid.net/specs/openid-authentication-2_0.html) and
|
|
7
|
+
[OAuth2](https://datatracker.ietf.org/doc/rfc6749/) do not clearly specify *how* to validate access tokens
|
|
8
|
+
as a resource server. This poses a risk as the implementation of choice might differ across applications and
|
|
9
|
+
may be insecure.
|
|
10
|
+
|
|
11
|
+
In order to resolve this issue [RFC7662](https://datatracker.ietf.org/doc/rfc7662/) was published in 2015.
|
|
12
|
+
This method involves the resource server sending the access token to the authorization server to verify it.
|
|
13
|
+
This comes with a big performance penalty, as the resource server needs to make its own request to the
|
|
14
|
+
authorization server everytime it needs to validate a token, which basically needs to happen for every
|
|
15
|
+
authenticated request.
|
|
16
|
+
|
|
17
|
+
To overcome the performance penalty, systems started using access tokens in the form of JSON Web Tokens, which
|
|
18
|
+
can be parsed and validated. However, considering there was no formal definition of what the contents of the
|
|
19
|
+
token should look like, tokens issued by one provider were not necessarily compatible with tokens provided by
|
|
20
|
+
another provider, leading to different implementations of token validation.
|
|
21
|
+
|
|
22
|
+
[RFC9068](https://datatracker.ietf.org/doc/rfc9068/) aims to overcome that obstacle by providing a specification
|
|
23
|
+
on what the contents of the access tokens should look like and how to validate the access tokens.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
Install using uv:
|
|
27
|
+
```shell
|
|
28
|
+
uv add rfc9068
|
|
29
|
+
```
|
|
30
|
+
or using pip:
|
|
31
|
+
```shell
|
|
32
|
+
pip install rfc9068
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
The validator itself is nothing more than a composition, its dependencies do the actual work.
|
|
37
|
+
That means that we need to construct the validator and it's recommended to use dependency injection to do that,
|
|
38
|
+
otherwise perhaps implement a factory.
|
|
39
|
+
|
|
40
|
+
### Factory example
|
|
41
|
+
```python
|
|
42
|
+
from rfc9068 import RFC9068AccessTokenValidator, RFC9068AccessTokenValidatorInterface
|
|
43
|
+
from rfc9068.parser import AccessTokenParser
|
|
44
|
+
from rfc9068.payload import (
|
|
45
|
+
IssuerValidator,
|
|
46
|
+
AudienceValidator,
|
|
47
|
+
ExpirationValidator
|
|
48
|
+
)
|
|
49
|
+
from rfc9068.signature import PyJwtSignatureValidator, PyJwtJWKResolver
|
|
50
|
+
from jwt import PyJWKClient, PyJWS
|
|
51
|
+
|
|
52
|
+
class ValidatorFactory:
|
|
53
|
+
def __call__(
|
|
54
|
+
self,
|
|
55
|
+
jwks_url: str,
|
|
56
|
+
issuer: str,
|
|
57
|
+
audience: str,
|
|
58
|
+
algorithms: list[str] = ["RS256"],
|
|
59
|
+
): RFC9068AccessTokenValidatorInterface:
|
|
60
|
+
return RFC9068AccessTokenValidator(
|
|
61
|
+
AccessTokenParser(),
|
|
62
|
+
PyJwtSignatureValidator(
|
|
63
|
+
PyJwtJWKResolver(
|
|
64
|
+
PyJWKClient(jwks_url),
|
|
65
|
+
),
|
|
66
|
+
PyJWS(),
|
|
67
|
+
),
|
|
68
|
+
IssuerValidator(),
|
|
69
|
+
AudienceValidator(),
|
|
70
|
+
ExpirationValidator(),
|
|
71
|
+
algorithms,
|
|
72
|
+
issuer,
|
|
73
|
+
audience,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
factory = ValidatorFactory()
|
|
77
|
+
validate = factory(
|
|
78
|
+
"http://keycloak:8002/realms/rfc9068/protocol/openid-connect/certs",
|
|
79
|
+
"http://keycloak:8002/realms/rfc9068",
|
|
80
|
+
"test-audience",
|
|
81
|
+
["RS256"],
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Validator
|
|
86
|
+
Now that we have a validator we can use it to validate access tokens.
|
|
87
|
+
```python
|
|
88
|
+
from rfc9068.core import InvalidTokenError
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
validate(access_token)
|
|
92
|
+
except InvalidTokenError as e:
|
|
93
|
+
# Token is not valid
|
|
94
|
+
print(e)
|
|
95
|
+
raise e
|
|
96
|
+
|
|
97
|
+
# When no exceptions are raised the token is valid
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
For development of this package we provide a container setup.
|
|
102
|
+
|
|
103
|
+
### Building
|
|
104
|
+
```shell
|
|
105
|
+
docker compose build
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Starting containers
|
|
109
|
+
```shell
|
|
110
|
+
docker compose run --rm validator sh
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This will also open a shell where we can run our dev tools:
|
|
114
|
+
```shell
|
|
115
|
+
uv run ruff check
|
|
116
|
+
uv run mypy .
|
|
117
|
+
uv run pytest -v --cov --cov-fail-under=100
|
|
118
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rfc9068"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Validates OIDC/OAuth2 access tokens following RC9068"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11,<4.0"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic>=2.11.9",
|
|
9
|
+
"pyjwt[crypto]>=2.10.1",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["uv_build>=0.8.19,<0.12.0"]
|
|
14
|
+
build-backend = "uv_build"
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = [
|
|
18
|
+
"httpx[http2]>=0.28.1",
|
|
19
|
+
"mypy>=1.18.1",
|
|
20
|
+
"pytest>=8.4.2",
|
|
21
|
+
"pytest-cov>=7.0.0",
|
|
22
|
+
"ruff>=0.13.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.mypy]
|
|
26
|
+
strict = true
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint]
|
|
29
|
+
select = ["ALL"]
|
|
30
|
+
ignore = ["D100", "D101", "D102", "D103", "D104", "D107", "D401"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint.per-file-ignores]
|
|
33
|
+
"tests/**" = ["S101", "PLR2004"]
|
|
34
|
+
|
|
35
|
+
[tool.coverage.run]
|
|
36
|
+
omit = ["tests/**"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
|
|
4
|
+
from rfc9068.parser import AccessTokenParserInterface, ParsedAccessToken
|
|
5
|
+
from rfc9068.payload import (
|
|
6
|
+
AudienceValidatorInterface,
|
|
7
|
+
ExpirationValidatorInterface,
|
|
8
|
+
IssuerValidatorInterface,
|
|
9
|
+
)
|
|
10
|
+
from rfc9068.signature import SignatureValidatorInterface
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RFC9068AccessTokenValidatorInterface(metaclass=ABCMeta):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def __call__(self, access_token: str) -> ParsedAccessToken: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RFC9068AccessTokenValidator(RFC9068AccessTokenValidatorInterface):
|
|
19
|
+
_parse_access_token: AccessTokenParserInterface
|
|
20
|
+
_validate_signature: SignatureValidatorInterface
|
|
21
|
+
_validate_issuer: IssuerValidatorInterface
|
|
22
|
+
_validate_audience: AudienceValidatorInterface
|
|
23
|
+
_validate_expiration: ExpirationValidatorInterface
|
|
24
|
+
_algorithms: Sequence[str]
|
|
25
|
+
_issuer: str
|
|
26
|
+
_audience: str
|
|
27
|
+
|
|
28
|
+
def __init__( # noqa: PLR0913
|
|
29
|
+
self,
|
|
30
|
+
access_token_parser: AccessTokenParserInterface,
|
|
31
|
+
signature_validator: SignatureValidatorInterface,
|
|
32
|
+
issuer_validator: IssuerValidatorInterface,
|
|
33
|
+
audience_validator: AudienceValidatorInterface,
|
|
34
|
+
expiration_validator: ExpirationValidatorInterface,
|
|
35
|
+
algorithms: Sequence[str],
|
|
36
|
+
issuer: str,
|
|
37
|
+
audience: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._parse_access_token = access_token_parser
|
|
40
|
+
self._validate_signature = signature_validator
|
|
41
|
+
self._validate_issuer = issuer_validator
|
|
42
|
+
self._validate_audience = audience_validator
|
|
43
|
+
self._validate_expiration = expiration_validator
|
|
44
|
+
self._algorithms = algorithms
|
|
45
|
+
self._issuer = issuer
|
|
46
|
+
self._audience = audience
|
|
47
|
+
|
|
48
|
+
def __call__(self, access_token: str) -> ParsedAccessToken:
|
|
49
|
+
parsed_token = self._parse_access_token(access_token)
|
|
50
|
+
|
|
51
|
+
self._validate_signature(
|
|
52
|
+
parsed_token.header,
|
|
53
|
+
parsed_token.raw_header,
|
|
54
|
+
parsed_token.raw_payload,
|
|
55
|
+
parsed_token.signature,
|
|
56
|
+
self._algorithms,
|
|
57
|
+
)
|
|
58
|
+
self._validate_issuer(parsed_token.payload, self._issuer)
|
|
59
|
+
self._validate_audience(parsed_token.payload, self._audience)
|
|
60
|
+
self._validate_expiration(parsed_token.payload)
|
|
61
|
+
|
|
62
|
+
return parsed_token
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class InvalidTokenError(Exception): ...
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from abc import ABCMeta, abstractmethod
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ValidationError
|
|
7
|
+
|
|
8
|
+
from rfc9068.core import InvalidTokenError
|
|
9
|
+
from rfc9068.payload import InvalidPayloadError, Payload
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidHeaderError(InvalidTokenError): ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ValidTypHeaderValues(StrEnum):
|
|
16
|
+
AT_JWT = "at+jwt"
|
|
17
|
+
APPLICATION_AT_JWT = "application/at+jwt"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidAlgHeaderValues(StrEnum):
|
|
21
|
+
RS256 = "RS256"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JWTHeader(BaseModel):
|
|
25
|
+
typ: ValidTypHeaderValues
|
|
26
|
+
alg: ValidAlgHeaderValues
|
|
27
|
+
kid: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ParsedAccessToken:
|
|
32
|
+
header: JWTHeader
|
|
33
|
+
raw_header: str
|
|
34
|
+
payload: Payload
|
|
35
|
+
raw_payload: str
|
|
36
|
+
signature: bytes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AccessTokenParserInterface(metaclass=ABCMeta):
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def __call__(self, access_token: str) -> ParsedAccessToken: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AccessTokenParser(AccessTokenParserInterface):
|
|
45
|
+
def __call__(self, access_token: str) -> ParsedAccessToken:
|
|
46
|
+
raw_header, raw_payload, signature = access_token.split(".")
|
|
47
|
+
|
|
48
|
+
padded_header = self._add_padding(raw_header)
|
|
49
|
+
padded_payload = self._add_padding(raw_payload)
|
|
50
|
+
padded_signature = self._add_padding(signature)
|
|
51
|
+
|
|
52
|
+
decoded_header = base64.urlsafe_b64decode(padded_header)
|
|
53
|
+
try:
|
|
54
|
+
header = JWTHeader.model_validate_json(decoded_header)
|
|
55
|
+
except ValidationError as e:
|
|
56
|
+
raise InvalidHeaderError(str(e)) from e
|
|
57
|
+
|
|
58
|
+
decoded_payload = base64.urlsafe_b64decode(padded_payload)
|
|
59
|
+
try:
|
|
60
|
+
payload = Payload.model_validate_json(decoded_payload)
|
|
61
|
+
except ValidationError as e:
|
|
62
|
+
raise InvalidPayloadError(str(e)) from e
|
|
63
|
+
|
|
64
|
+
decoded_signature = base64.urlsafe_b64decode(padded_signature)
|
|
65
|
+
|
|
66
|
+
return ParsedAccessToken(
|
|
67
|
+
header,
|
|
68
|
+
raw_header,
|
|
69
|
+
payload,
|
|
70
|
+
raw_payload,
|
|
71
|
+
decoded_signature,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _add_padding(self, value: str) -> str:
|
|
75
|
+
padding_required = 4 - (len(value) % 4)
|
|
76
|
+
value += "=" * padding_required
|
|
77
|
+
return value
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from rfc9068.core import InvalidTokenError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Payload(BaseModel):
|
|
10
|
+
model_config = {"extra": "allow"}
|
|
11
|
+
|
|
12
|
+
iss: str
|
|
13
|
+
exp: int
|
|
14
|
+
aud: str | list[str]
|
|
15
|
+
sub: str
|
|
16
|
+
client_id: str
|
|
17
|
+
iat: int
|
|
18
|
+
jti: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidPayloadError(InvalidTokenError): ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidIssuerError(InvalidPayloadError): ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IssuerValidatorInterface(metaclass=ABCMeta):
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def __call__(self, claims: Payload, expected_issuer: str) -> None:
|
|
30
|
+
"""Implementations should raise InvalidIssuerError if invalid."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IssuerValidator(IssuerValidatorInterface):
|
|
34
|
+
def __call__(self, claims: Payload, expected_issuer: str) -> None:
|
|
35
|
+
issuer = claims.iss
|
|
36
|
+
if issuer != expected_issuer:
|
|
37
|
+
msg = f"Expected issuer '{expected_issuer}', got '{issuer}'!"
|
|
38
|
+
raise InvalidIssuerError(msg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InvalidAudienceError(InvalidPayloadError): ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AudienceValidatorInterface(metaclass=ABCMeta):
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def __call__(self, claims: Payload, expected_audience: str) -> None:
|
|
47
|
+
"""Implementations should raise InvalidAudienceError if invalid."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AudienceValidator(AudienceValidatorInterface):
|
|
51
|
+
def __call__(self, claims: Payload, expected_audience: str) -> None:
|
|
52
|
+
audience = claims.aud
|
|
53
|
+
if isinstance(audience, str) and audience != expected_audience:
|
|
54
|
+
msg = f"Expected audience '{expected_audience}', got '{audience}'!"
|
|
55
|
+
raise InvalidAudienceError(msg)
|
|
56
|
+
|
|
57
|
+
if expected_audience not in audience:
|
|
58
|
+
msg = (f"Expected audience '{expected_audience}' not in "
|
|
59
|
+
f"'{', '.join(aud for aud in audience)}'")
|
|
60
|
+
raise InvalidAudienceError(msg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ExpiredTokenError(InvalidPayloadError): ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ExpirationValidatorInterface(metaclass=ABCMeta):
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def __call__(self, claims: Payload) -> None:
|
|
69
|
+
"""Implementations should raise ExpiredTokenError if invalid."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ExpirationValidator(ExpirationValidatorInterface):
|
|
73
|
+
def __call__(self, claims: Payload) -> None:
|
|
74
|
+
now = datetime.now(UTC).timestamp()
|
|
75
|
+
exp = claims.exp
|
|
76
|
+
if exp <= now:
|
|
77
|
+
msg = "The token is expired!"
|
|
78
|
+
raise ExpiredTokenError(msg)
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
|
|
4
|
+
from cryptography.hazmat.primitives._serialization import Encoding, PublicFormat
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
6
|
+
from jwt import InvalidSignatureError as PyJWTInvalidSignatureError
|
|
7
|
+
from jwt import PyJWKClient, PyJWS
|
|
8
|
+
|
|
9
|
+
from rfc9068.core import InvalidTokenError
|
|
10
|
+
from rfc9068.parser import JWTHeader
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JWKResolverInterface(metaclass=ABCMeta):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def __call__(self, kid: str) -> bytes: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PyJwtJWKResolver(JWKResolverInterface):
|
|
19
|
+
_jwks_client: PyJWKClient
|
|
20
|
+
|
|
21
|
+
def __init__(self, jwks_client: PyJWKClient) -> None:
|
|
22
|
+
self._jwks_client = jwks_client
|
|
23
|
+
|
|
24
|
+
def __call__(self, kid: str) -> bytes:
|
|
25
|
+
key = self._jwks_client.get_signing_key(kid).key
|
|
26
|
+
if not isinstance(key, RSAPublicKey):
|
|
27
|
+
msg = "Key should be an RSA public key!"
|
|
28
|
+
raise TypeError(msg)
|
|
29
|
+
|
|
30
|
+
return key.public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidSignatureError(InvalidTokenError): ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SignatureValidatorInterface(metaclass=ABCMeta):
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def __call__(
|
|
39
|
+
self,
|
|
40
|
+
header: JWTHeader,
|
|
41
|
+
raw_header: str,
|
|
42
|
+
raw_payload: str,
|
|
43
|
+
signature: bytes,
|
|
44
|
+
algorithms: Sequence[str],
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Implementations should raise InvalidSignatureError if invalid."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PyJwtSignatureValidator(SignatureValidatorInterface):
|
|
50
|
+
_get_signing_key: JWKResolverInterface
|
|
51
|
+
_jws: PyJWS
|
|
52
|
+
|
|
53
|
+
def __init__(self, jwk_resolver: JWKResolverInterface, jws: PyJWS) -> None:
|
|
54
|
+
self._get_signing_key = jwk_resolver
|
|
55
|
+
self._jws = jws
|
|
56
|
+
|
|
57
|
+
def __call__(
|
|
58
|
+
self,
|
|
59
|
+
header: JWTHeader,
|
|
60
|
+
raw_header: str,
|
|
61
|
+
raw_payload: str,
|
|
62
|
+
signature: bytes,
|
|
63
|
+
algorithms: Sequence[str],
|
|
64
|
+
) -> None:
|
|
65
|
+
signing_key = self._get_signing_key(header.kid)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
self._jws._verify_signature( # noqa: SLF001
|
|
69
|
+
f"{raw_header}.{raw_payload}".encode(),
|
|
70
|
+
header.model_dump(),
|
|
71
|
+
signature,
|
|
72
|
+
signing_key,
|
|
73
|
+
algorithms,
|
|
74
|
+
)
|
|
75
|
+
except PyJWTInvalidSignatureError as e:
|
|
76
|
+
msg = "Invalid signature, the token may have been tampered with!"
|
|
77
|
+
raise InvalidSignatureError(msg) from e
|