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 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
+ ```
@@ -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