Skip to content

Commit

Permalink
fixes #199, adds built-in support for JWT Bearer authentication 🛡️
Browse files Browse the repository at this point in the history
  • Loading branch information
RobertoPrevato committed Oct 31, 2021
1 parent 3bf3049 commit 3dac548
Show file tree
Hide file tree
Showing 11 changed files with 346 additions and 8 deletions.
22 changes: 20 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,28 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- uses: actions/cache@v1
id: depcache
with:
path: deps
key: requirements-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}

- name: Download dependencies
if: steps.depcache.outputs.cache-hit != 'true'
run: |
pip download --dest=deps -r requirements.txt
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install black==20.8b1 isort==5.9.1
PYVER=`python -V 2>&1`
if [ "$PYVER" == "Python 3.10.0" ]; then
pip install -r requirements.txt
else
pip install -U --no-index --find-links=deps deps/*
fi
pip install black isort==5.9.1
- name: Compile Cython extensions
run: |
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.1] - 2021-10-31 :shield:
- Adds built-in support to `JWT` bearer authentication, and validation
of `JWTs` issued by identity providers implementing **OpenID Connect (OIDC)**
discovery `/.well-known/openid-configuration` (more in general, for JWTs
signed using asymmetric encryption and verified using public RSA keys)
- Fixes #199
- Downgrades `httptools` dependency to version `>=0.2,<0.4`

## [1.2.0] - 2021-10-24 📦
- Includes `Python 3.10` in the CI/CD matrix
- Includes `Python 3.10` wheel in the distribution package
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ async def only_for_authenticated_users():
exceptions](https://www.neoteroi.dev/blacksheep/application/#configuring-exceptions-handlers)
* [Strategy to handle authentication and
authorization](https://www.neoteroi.dev/blacksheep/authentication/)
* [Built-in support for JWT Bearer authentication using OIDC discovery and
other sources of
JWKS](https://www.neoteroi.dev/blacksheep/authentication/#jwt-bearer)
* [Handlers
normalization](https://www.neoteroi.dev/blacksheep/request-handlers/)
* [Serving static
Expand Down
File renamed without changes.
120 changes: 120 additions & 0 deletions blacksheep/server/authentication/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import logging
from typing import Optional, Sequence

from guardpost.asynchronous.authentication import AuthenticationHandler
from guardpost.authentication import Identity, User
from guardpost.jwks import KeysProvider
from guardpost.jwts import InvalidAccessToken, JWTValidator
from jwt.exceptions import InvalidTokenError

from blacksheep.messages import Request


def get_logger():
return logging.getLogger("blacksheep.server")


class JWTBearerAuthentication(AuthenticationHandler):
"""
AuthenticationHandler that can parse and verify JWT Bearer access tokens to identify
users.
JWTs are validated using public RSA keys, and keys can be fetched automatically from
OpenID Connect (OIDC) discovery, if an `authority` is provided.
It is possible to use several instances of this class, to various authentication
through several identity providers (e.g. Azure Active Directory, Auth0, Azure Active
Directory B2C).
"""

def __init__(
self,
*,
valid_issuers: Sequence[str],
valid_audiences: Sequence[str],
authority: Optional[str] = None,
require_kid: bool = True,
keys_provider: Optional[KeysProvider] = None,
keys_url: Optional[str] = None,
cache_time: float = 10800,
auth_mode: str = "JWT Bearer"
):
"""
Creates a new instance of JWTBearerAuthentication, which tries to
obtains the identity of the user from the "Authorization" request header,
handling JWT Bearer tokens. Only standard authorization headers starting
with the `Bearer ` string are handled.
Parameters
----------
valid_issuers : Sequence[str]
Sequence of acceptable issuers (iss).
valid_audiences : Sequence[str]
Sequence of acceptable audiences (aud).
authority : Optional[str], optional
If provided, keys are obtained from a standard well-known endpoint.
This parameter is ignored if `keys_provider` is given.
algorithms : Sequence[str], optional
Sequence of acceptable algorithms, by default ["RS256"].
require_kid : bool, optional
According to the specification, a key id is optional in JWK. However,
this parameter lets control whether access tokens missing `kid` in their
headers should be handled or rejected. By default True, thus only JWTs
having `kid` header are accepted.
keys_provider : Optional[KeysProvider], optional
If provided, the exact `KeysProvider` to be used when fetching keys.
By default None
keys_url : Optional[str], optional
If provided, keys are obtained from the given URL through HTTP GET.
This parameter is ignored if `keys_provider` is given.
cache_time : float, optional
If >= 0, JWKS are cached in memory and stored for the given amount in
seconds. By default 10800 (3 hours).
auth_mode : str, optional
When authentication succeeds, the declared authentication mode. By default,
"JWT Bearer".
"""
self.logger = get_logger()

self._validator = JWTValidator(
authority=authority,
require_kid=require_kid,
keys_provider=keys_provider,
keys_url=keys_url,
valid_issuers=valid_issuers,
valid_audiences=valid_audiences,
cache_time=cache_time,
)
self.auth_mode = auth_mode
self._validator.logger = self.logger

async def authenticate(self, context: Request) -> Optional[Identity]:
authorization_value = context.get_first_header(b"Authorization")

if not authorization_value:
context.identity = User({})
return None

if not authorization_value.startswith(b"Bearer "):
self.logger.debug(
"Invalid Authorization header, not starting with `Bearer `, "
"the header is ignored."
)
context.identity = User({})
return None

token = authorization_value[7:].decode()

try:
decoded = await self._validator.validate_jwt(token)
except (InvalidAccessToken, InvalidTokenError) as ex:
# pass, because the application might support more than one
# authentication method
self.logger.error("JWT Bearer - invalid access token: %s", str(ex))
pass
else:
context.identity = User(decoded, self.auth_mode)
return context.identity

context.identity = User({})
return None
File renamed without changes.
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ essentials==1.1.4
essentials-openapi==0.1.4
flake8==3.7.9
Flask==1.1.1
guardpost==0.0.7
guardpost~=0.0.8
h11==0.11.0
httptools==0.3.0
httptools>=0.2,<0.4
idna==2.8
importlib-metadata==1.3.0
itsdangerous==1.1.0
Expand Down Expand Up @@ -58,3 +58,4 @@ Werkzeug==0.16.0
zipp==0.6.0
uvloop==0.15.2; platform_system != "Windows"
Hypercorn==0.11.2
PyJWT~=2.3.0
9 changes: 6 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def readme():

setup(
name="blacksheep",
version="1.2.0",
version="1.2.1",
description="Fast web framework and HTTP client for Python asyncio",
long_description=readme(),
long_description_content_type="text/markdown",
Expand All @@ -37,6 +37,8 @@ def readme():
"blacksheep",
"blacksheep.plugins",
"blacksheep.server",
"blacksheep.server.authentication",
"blacksheep.server.authorization",
"blacksheep.server.files",
"blacksheep.server.res",
"blacksheep.server.openapi",
Expand Down Expand Up @@ -88,11 +90,11 @@ def readme():
),
],
install_requires=[
"httptools~=0.3.0",
"httptools>=0.2,<0.4",
"Jinja2~=3.0.2",
"certifi>=2020.12.5",
"cchardet~=2.1.5",
"guardpost~=0.0.7",
"guardpost~=0.0.8",
"rodi~=1.1.1",
"essentials>=1.1.4,<2.0",
"essentials-openapi>=0.1.4,<1.0",
Expand All @@ -103,6 +105,7 @@ def readme():
extras_require={
"full": [
"cryptography~=3.4.6",
"PyJWT~=2.3.0",
]
},
include_package_data=True,
Expand Down
28 changes: 28 additions & 0 deletions tests/res/0.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHM7vHSAQxuS26
7lEtSqIAEk2q3iR2b/m5ywqBJ0dR1RxuQbbx0sCKSgVOSL1rCbJqlJs7zjOUoxof
bihs57jiF3fax14w51CnRPGnET3kd9hUW4BZ18IzgHP/HfZKrnV7vHqhl3T284MU
VvBOUwnbss8AS4jkbpRVArGEgH/ADYU4+Rsd4OdwHuNi/2QuJ2+k2Cnk8L5oocq/
1rLqCLsz7BRASl5AyOnIMU3an+rXYFZJRuxHA6OFi0K5KaYE2HTsyLPNUhCOmZlT
l/qqZkfD76IlIEPnbCwjzuK1hsHBFwM3l98KiWQWkDy9rSEOVKEjapWbC1DctSll
WK8q4k5LAgMBAAECggEATxXv8D9cQu11BWkGW4fs50BdC4BkU41DRQsiYYJZo1iL
kA6Q9lMo0/5tOtZQNYXFCuFy+/xyqAlVHrNaY1pgIYsVr4tFjv7XG4GYuy5yNxmJ
jnxBaenqFQ5jfx7DIIVA6V48BZme+0hUeyfFAiOfn1TPMBvM/nwUcee+2I83qORB
u5uCnquDzhKEXYIriZ+4c6M+0oPYKeMrwty5vH7uwalDNvkGvKLoGoRounH0X2tV
Yi5xzxUVC+KOopIk3fIWwdTSEJuQzoKo1gnZvt07f8sOh36dMCMONpxhM1GMX0Pg
TgrJEg80UqDSxc3gYoAy0NpX4ttaWU4AziAIcf4XOQKBgQDtpHJg6Ya0WDsCuOnf
IyGVPxoo6tTlLjP9ujpbt8ohzKTSnIc28DtKO4HQVqdNegJ0JsBV8VNAkhEh32/l
i1Mde6F8uK4wt78yAslx256+t3Jjn59vcF3Tm8AaR8GB4CdG5PtIVKEUqXH4Rfkn
87YHSmPL6VhN+JL04B4DSty/fQKBgQDWlxoIuj1Y2Xf1pAwpkn42YN10V1iY8/L5
hfIDLd6UC5G1hUxrePScOEWhZk9Ov2o2qAXBhchugx64H5CSTK9lbw1C0HWyL4v7
zup1omfqrjn7XXoX512LyTIYyV0oBWCm3V6kOJN3Ea7tALvVIQdfe4Z4fb9HSP/M
FqGIOm+/ZwKBgQCi7VAN6Y2VL7ilkSmm9msb6/t/eiEkT50NpBRGtac7rRaD3xVF
MUc1Cb9im0Zw8+miwL61LZMqffqJAquw8Oi3GgAJhoTGmfPX0dlS2oPntdYTP2kL
+joZznrSice9x3SmQm+Vk5Asnk+pLDA6l/iA3xu0vfLw4i+++7kYAMd/8QKBgQCw
34bD3s4l58mqnHax5V9GbvzZog0StTB2XuMln68wE4EcPyzIAMCN6wvphqyj2b4w
Irnr0ttry4OMe+frzm1bi/dANRZtsicNfHVgVGaW1thPybKS9U7zovg52e+Axz3t
C9WwQjm6EMc/7jTj7P9owiYKNotstEyy6Yxm/tOQzQKBgQCz1j1d3cLUEKY9IFJ1
Ew28dwwuL9pGFPHCXcDTqAAAKME9V3U8F7ZtYThE9u6pRfgXKLJWEY+qpH6FhuHQ
DJdfiREM84kh/99Y6ZNjvHM7CK/5EZFTaEaqZGVlgR93Hkrs5LbKuucYffmlF69/
PDRlkWb0YatyOxBKznBgNJsz5A==
-----END PRIVATE KEY-----
10 changes: 10 additions & 0 deletions tests/res/jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"keys": [
{
"kty": "RSA",
"kid": "0",
"n": "xzO7x0gEMbktuu5RLUqiABJNqt4kdm_5ucsKgSdHUdUcbkG28dLAikoFTki9awmyapSbO84zlKMaH24obOe44hd32sdeMOdQp0TxpxE95HfYVFuAWdfCM4Bz_x32Sq51e7x6oZd09vODFFbwTlMJ27LPAEuI5G6UVQKxhIB_wA2FOPkbHeDncB7jYv9kLidvpNgp5PC-aKHKv9ay6gi7M-wUQEpeQMjpyDFN2p_q12BWSUbsRwOjhYtCuSmmBNh07MizzVIQjpmZU5f6qmZHw--iJSBD52wsI87itYbBwRcDN5ffColkFpA8va0hDlShI2qVmwtQ3LUpZVivKuJOSw==",
"e": "AQAB"
}
]
}
Loading

0 comments on commit 3dac548

Please sign in to comment.