From c723d4bb5b1a6ea0745e023d3d54eebc4b61957d Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Thu, 3 Sep 2020 17:01:37 -0500 Subject: [PATCH 01/27] feat(scope): Add class JWTScopeError(JWTError) --- src/authutils/errors.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/authutils/errors.py b/src/authutils/errors.py index 82ff82e..6ce5d97 100644 --- a/src/authutils/errors.py +++ b/src/authutils/errors.py @@ -24,3 +24,8 @@ class JWTPurposeError(JWTError): class JWTAudienceError(JWTError): pass + + +class JWTScopeError(JWTError): + + pass From d2a4a779fb2f1e1036d51b138d3ca9af5cc5792f Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Thu, 3 Sep 2020 17:14:01 -0500 Subject: [PATCH 02/27] fix(aud): Validate aud claim the normal way, validate custom scopes claim * import new JWTScopeError * add new scope arg to core.validate_jwt * add new scope validation to core.validate_jwt * remove custom aud validation from core.validate_jwt * remove random_aud hack in core.validate_jwt; type(aud) now string-or-None, not set-or-list * pass aud through to PyJWT for normal validation * update docstring for core.validate_jwt * allow empty aud arg in validate.validate_jwt; cease raising ValueError * add new optional scope arg in validate.validate_jwt; pass through to core.validate_jwt * update docstring for validate.validate_jwt --- src/authutils/token/core.py | 71 +++++++++++++++++++-------------- src/authutils/token/validate.py | 22 +++++----- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index 094f719..23f41ad 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -1,6 +1,6 @@ import jwt -from ..errors import JWTAudienceError, JWTExpiredError, JWTPurposeError, JWTError +from ..errors import JWTAudienceError, JWTExpiredError, JWTPurposeError, JWTScopeError, JWTError def get_keys_url(issuer): @@ -47,23 +47,35 @@ def validate_purpose(claims, pur): ) -def validate_jwt(encoded_token, public_key, aud, issuers): +def validate_jwt(encoded_token, public_key, aud, scope, issuers): """ Validate the encoded JWT ``encoded_token``, which must satisfy the - audiences ``aud``. + scopes ``scope``. This is just a slightly lower-level function to decode the token and perform the most basic checks on the token. - Decode JWT using public key; PyJWT will fail if iat or exp fields are invalid - - Check audiences: token audiences must be a superset of required audiences - (the ``aud`` argument); fail if not satisfied + - PyJWT will also fail if the aud field is present in the JWT but no + ``aud`` arg is passed, or if the ``aud`` arg does not match one of + the items in the token aud field + - Check issuers: token iss field must match one of the items in the + ``issuers`` arg + - Check scopes: token scopes must be a superset of required scopes + (the ``scope`` argument); fail if not satisfied Args: encoded_token (str): encoded JWT public_key (str): public key to validate the JWT signature - aud (set): non-empty set of audiences the JWT must satisfy + aud (Optional[str]): + audience with which the app identifies, usually an OIDC + client id, which the JWT will be expected to include in its ``aud`` + claim. Optional; if no ``aud`` argument given and the JWT has no + ``aud`` claim, validation will pass. + scope (Optional[Iterable[str]]): + set of scopes, each of which the JWT must satisfy in its + ``scope`` claim. Optional. issuers (list or set): allowed issuers whitelist Return: @@ -71,26 +83,23 @@ def validate_jwt(encoded_token, public_key, aud, issuers): Raises: ValueError: if receiving an incorrectly-typed argument - JWTValidationError: if any step of the validation fails + JWTExpiredError: if token is expired + JWTAudienceError: if aud validation fails + JWTScopeError: if scope validation fails + JWTError: if some other token validation step fails """ + # Typecheck arguments. - if not isinstance(aud, set) and not isinstance(aud, list): - raise ValueError("aud must be set or list") + if not isinstance(aud, str) and not aud is None: + raise ValueError("aud must be string or None. Instead received aud of type {}".format(type(aud))) + if not isinstance(scope, set) and not isinstance(scope, list) and not scope is None: + raise ValueError("scope must be set or list or None. Instead received scope of type {}".format(type(scope))) if not isinstance(issuers, set) and not isinstance(issuers, list): - raise ValueError("issuers must be set or list") - - # To satisfy PyJWT, since the token will contain an aud field, decode has - # to be passed one of the audiences to check here (so PyJWT doesn't raise - # an InvalidAudienceError). Per the JWT specification, if the token - # contains an aud field, the validator MUST identify with one of the - # audiences listed in that field. This implementation is more strict, and - # allows the validator to demand multiple audiences which must all be - # satisfied by the token (see below). - aud = set(aud) - random_aud = list(aud)[0] + raise ValueError("issuers must be set or list. Instead received issuers of type {}".format(type(issuers))) + try: token = jwt.decode( - encoded_token, key=public_key, algorithms=["RS256"], audience=random_aud + encoded_token, key=public_key, algorithms=["RS256"], audience=aud, ) except jwt.InvalidAudienceError as e: raise JWTAudienceError(e) @@ -99,7 +108,7 @@ def validate_jwt(encoded_token, public_key, aud, issuers): except jwt.InvalidTokenError as e: raise JWTError(e) - # PyJWT validates iat and exp fields (and aud...sort of); everything else + # PyJWT validates iat, exp, and aud fields; everything else # must happen here. # iss @@ -108,12 +117,16 @@ def validate_jwt(encoded_token, public_key, aud, issuers): msg = "invalid issuer {}; expected: {}".format(token["iss"], issuers) raise JWTError(msg) - # aud - # The audiences listed in the token must completely satisfy all the - # required audiences provided. Note that this is stricter than the - # specification suggested in RFC 7519. - missing = aud - set(token["aud"]) - if missing: - raise JWTAudienceError("missing audiences: " + str(missing)) + # scope + # Check that if scope arg was non-empty then the token includes each given scope in its scope claim + if scope: + token_scopes = token.get("scope", []) + if isinstance(token_scopes, str): + token_scopes = [token_scopes] + if not isinstance(token_scopes, list): + raise JWTError("invalid format in scope claim: {}; expected list".format(token["scopes"])) + missing_scopes = set(scope) - set(token_scopes) + if missing_scopes: + raise JWTScopeError("token is missing required scopes: " + str(missing_scopes)) return token diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index 5357307..88a862d 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -67,7 +67,8 @@ def get_session_token(): def validate_jwt( encoded_token, - aud, + aud=None, + scope=None, purpose="access", issuers=None, public_key=None, @@ -79,20 +80,25 @@ def validate_jwt( Args: encoded_token (str): the base64 encoding of the token - aud (Optional[Iterable[str]]): - list of audiences that the token must satisfy; defaults to - ``{'openid'}`` (minimum expected by OpenID provider) + aud (Optional[str]): + audience with which the app identifies, usually an OIDC + client id, which the JWT will be expected to include in its ``aud`` + claim. Optional; if no ``aud`` argument given and the JWT has no + ``aud`` claim, validation will pass. + scope (Optional[Iterable[str]]): + scopes that the token must satisfy purpose (Optional[str]): which purpose the token is supposed to be used for (access, refresh, or id) issuers (Iterable[str]): list of allowed token issuers public_key (Optional[str]): public key to vaidate JWT with + attempt_refresh (Optional[bool]): + whether to attempt refresh of public keys if not found in cache Return: dict: dictionary of claims from the validated JWT Raises: - ValueError: if ``aud`` is empty JWTError: if auth header is missing, decoding fails, or the JWT fails to satisfy any expectation @@ -108,10 +114,8 @@ def validate_jwt( public_key = get_public_key_for_token( encoded_token, attempt_refresh=attempt_refresh, logger=logger ) - if not aud: - raise ValueError("must provide at least one audience") - aud = set(aud) - claims = core.validate_jwt(encoded_token, public_key, aud, issuers) + + claims = core.validate_jwt(encoded_token, public_key, aud, scope, issuers) if purpose: core.validate_purpose(claims, purpose) return claims From 397d06208de1bd9577624f6da2a95529604a909a Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Fri, 11 Sep 2020 16:53:17 -0500 Subject: [PATCH 03/27] fix(aud): allow passthrough of options arg to pyjwt --- src/authutils/token/core.py | 5 +++-- src/authutils/token/validate.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index 23f41ad..5396377 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -47,7 +47,7 @@ def validate_purpose(claims, pur): ) -def validate_jwt(encoded_token, public_key, aud, scope, issuers): +def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}): """ Validate the encoded JWT ``encoded_token``, which must satisfy the scopes ``scope``. @@ -77,6 +77,7 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers): set of scopes, each of which the JWT must satisfy in its ``scope`` claim. Optional. issuers (list or set): allowed issuers whitelist + options (Optional[dict]): options to pass through to pyjwt's decode Return: dict: the decoded and validated JWT @@ -99,7 +100,7 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers): try: token = jwt.decode( - encoded_token, key=public_key, algorithms=["RS256"], audience=aud, + encoded_token, key=public_key, algorithms=["RS256"], audience=aud, options=options, ) except jwt.InvalidAudienceError as e: raise JWTAudienceError(e) diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index 88a862d..9d8d754 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -74,6 +74,7 @@ def validate_jwt( public_key=None, attempt_refresh=True, logger=None, + options={}, ): """ Validate a JWT and return the claims. @@ -94,6 +95,7 @@ def validate_jwt( public_key (Optional[str]): public key to vaidate JWT with attempt_refresh (Optional[bool]): whether to attempt refresh of public keys if not found in cache + options (Optional[dict]): options to pass through to pyjwt's decode Return: dict: dictionary of claims from the validated JWT @@ -115,7 +117,7 @@ def validate_jwt( encoded_token, attempt_refresh=attempt_refresh, logger=logger ) - claims = core.validate_jwt(encoded_token, public_key, aud, scope, issuers) + claims = core.validate_jwt(encoded_token, public_key, aud, scope, issuers, options) if purpose: core.validate_purpose(claims, purpose) return claims From 66dd781ccfd6da6152356a1b97027904dfe410e0 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Mon, 14 Sep 2020 15:21:21 -0500 Subject: [PATCH 04/27] fix(aud-scope): switch require_auth_header to checking scopes not aud --- src/authutils/token/validate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index 9d8d754..0bdbaf5 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -123,7 +123,7 @@ def validate_jwt( return claims -def validate_request(aud, purpose="access", logger=None): +def validate_request(scope, purpose="access", logger=None): """ Validate a ``flask.request`` by checking the JWT contained in the request headers. @@ -138,13 +138,13 @@ def validate_request(aud, purpose="access", logger=None): raise JWTError("no authorization header provided") # Pass token to ``validate_jwt``. - return validate_jwt(encoded_token, aud, purpose, logger=logger) + return validate_jwt(encoded_token, scope=scope, purpose=purpose, logger=logger) -def require_auth_header(aud, purpose=None, logger=None): +def require_auth_header(scope, purpose=None, logger=None): """ Return a decorator which adds request validation to check the given - audiences and (optionally) purpose. + scopes and (optionally) purpose. """ logger = logger or get_logger(__name__, log_level="info") @@ -162,7 +162,7 @@ def wrapper(*args, **kwargs): the code inside the function can use the ``LocalProxy`` for the token (see top of this file). """ - set_current_token(validate_request(aud=aud, purpose=purpose, logger=logger)) + set_current_token(validate_request(scope=scope, purpose=purpose, logger=logger)) return f(*args, **kwargs) return wrapper From 93cfe408ca5b07cc543cbee006583fc22c5e9b03 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Wed, 16 Sep 2020 11:30:18 -0500 Subject: [PATCH 05/27] fix(aud-scope): Skip aud validation in require_auth_header/validate_request --- src/authutils/token/validate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index 0bdbaf5..e301680 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -127,6 +127,8 @@ def validate_request(scope, purpose="access", logger=None): """ Validate a ``flask.request`` by checking the JWT contained in the request headers. + SKIPS aud validation: See "Not validating aud claim in Bearer tokens" in the + Fence repo's TECHDEBT file. """ logger = logger or get_logger(__name__, log_level="info") # Get token from the headers. @@ -138,13 +140,15 @@ def validate_request(scope, purpose="access", logger=None): raise JWTError("no authorization header provided") # Pass token to ``validate_jwt``. - return validate_jwt(encoded_token, scope=scope, purpose=purpose, logger=logger) + return validate_jwt(encoded_token, scope=scope, purpose=purpose, logger=logger, options={'verify_aud': False}) def require_auth_header(scope, purpose=None, logger=None): """ Return a decorator which adds request validation to check the given scopes and (optionally) purpose. + SKIPS aud validation (via validate-request): See "Not validating + aud claim in Bearer tokens" in the Fence repo's TECHDEBT file. """ logger = logger or get_logger(__name__, log_level="info") From 29f84200e9357612c91ee8ce91ba888552c7c1af Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Tue, 22 Sep 2020 14:31:24 -0500 Subject: [PATCH 06/27] test(aud-scope): Change default_audiences fixture to default_scopes; rm aud from generic claims --- tests/conftest.py | 14 +++++++------- tests/test_fastapi.py | 8 ++++---- tests/test_jwt.py | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index aa4d899..62d343b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,17 +38,17 @@ def iss(): @pytest.fixture(scope="session") -def default_audiences(): +def default_scopes(): """ - Return some default audiences to put in the claims of a JWT. + Return some default scopes to put in the claims of a JWT. """ - # Note that ``test_aud`` here is the audience expected on the test endpoint + # Note that ``test_scope`` here is the scope expected on the test endpoint # in the test application. - return ["openid", "access", "user", "test_aud"] + return ["openid", "access", "user", "test_scope"] @pytest.fixture(scope="session") -def claims(default_audiences, iss): +def claims(default_scopes, iss): """ Return some generic claims to put in a JWT. @@ -60,12 +60,12 @@ def claims(default_audiences, iss): exp = int((now + timedelta(seconds=600)).strftime("%s")) return { "pur": "access", - "aud": default_audiences, "sub": "1234", "iss": iss, "iat": iat, "exp": exp, "jti": str(uuid.uuid4()), + "scope": default_scopes, "context": {"user": {"name": "test-user", "projects": []}}, } @@ -143,7 +143,7 @@ def app(): app.config["USER_API"] = USER_API @app.route("/test") - @require_auth_header({"test_aud"}, "access") + @require_auth_header({"test_scope"}, "access") def test_endpoint(): """ Define a simple endpoint for testing which requires a JWT header for diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 4af26c6..9445e4f 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -7,21 +7,21 @@ @pytest.fixture(scope="function") -def async_client(default_audiences, mock_async_get, iss): +def async_client(default_scopes, mock_async_get, iss): mock_async_get() app = fastapi.FastAPI() @app.get("/whoami") def whoami( - token=fastapi.Depends(access_token(*default_audiences, purpose="access")) + token=fastapi.Depends(access_token(*default_scopes, purpose="access")) ): return token @app.get("/force_issuer") def force_issuer( token=fastapi.Depends( - access_token(*default_audiences, issuer=iss, purpose="access") + access_token(*default_scopes, issuer=iss, purpose="access") ) ): return token @@ -30,7 +30,7 @@ def force_issuer( def whitelist( token=fastapi.Depends( access_token( - *default_audiences, + *default_scopes, allowed_issuers=["https://right.example.com"], purpose="access" ) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index a5eb70d..8c4fed1 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -14,32 +14,32 @@ from tests.utils import TEST_RESPONSE_JSON -def test_valid_signature(claims, encoded_jwt, rsa_public_key, default_audiences, iss): +def test_valid_signature(claims, encoded_jwt, rsa_public_key, default_scopes, iss): """ Do a basic test of the expected functionality with the sample payload in the fence README. """ - decoded_token = validate_jwt(encoded_jwt, rsa_public_key, default_audiences, [iss]) + decoded_token = validate_jwt(encoded_jwt, rsa_public_key, default_scopes, [iss]) assert decoded_token assert decoded_token == claims def test_expired_token_rejected( - encoded_jwt_expired, rsa_public_key, default_audiences, iss + encoded_jwt_expired, rsa_public_key, default_scopes, iss ): with pytest.raises(JWTExpiredError): - validate_jwt(encoded_jwt_expired, rsa_public_key, default_audiences, [iss]) + validate_jwt(encoded_jwt_expired, rsa_public_key, default_scopes, [iss]) def test_invalid_signature_rejected( - encoded_jwt, rsa_public_key_2, default_audiences, iss + encoded_jwt, rsa_public_key_2, default_scopes, iss ): """ Test that ``validate_jwt`` rejects JWTs signed with a private key not corresponding to the public key it is given. """ with pytest.raises(JWTError): - validate_jwt(encoded_jwt, rsa_public_key_2, default_audiences, [iss]) + validate_jwt(encoded_jwt, rsa_public_key_2, default_scopes, [iss]) def test_invalid_aud_rejected(encoded_jwt, rsa_public_key, iss): From ee69572fffd0ae18693aaf043f9601ff96720dbd Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Tue, 22 Sep 2020 15:06:15 -0500 Subject: [PATCH 07/27] fix(aud-scope): chg aud to scope in FastAPI access_token dependency --- src/authutils/token/fastapi.py | 12 ++++++------ tests/test_fastapi.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/authutils/token/fastapi.py b/src/authutils/token/fastapi.py index 8523d35..6ccd9ff 100644 --- a/src/authutils/token/fastapi.py +++ b/src/authutils/token/fastapi.py @@ -14,7 +14,7 @@ _jwt_public_keys = {} -def access_token(*audiences, issuer=None, allowed_issuers=None, purpose=None): +def access_token(*scopes, issuer=None, allowed_issuers=None, purpose=None): """ Validate and return the JWT bearer token in HTTP header:: @@ -25,7 +25,7 @@ def whoami(token=Depends(access_token("user", "openapi", purpose="access"))): return token["iss"] Args: - *audiences: Required, all must occur in ``aud``. + *scopes: Required, all must occur in ``scope``. issuer: Force to use this issuer to validate the token if provided. allowed_issuers: Optional allowed issuers whitelist, default: allow all. purpose: Optional, must match ``pur`` if provided. @@ -34,9 +34,9 @@ def whoami(token=Depends(access_token("user", "openapi", purpose="access"))): Decoded JWT claims as a :class:`dict`. """ - if not audiences: - raise ValueError("Missing parameter: audiences") - audiences = set(audiences) + if not scopes: + raise ValueError("Missing parameter: scopes") + scopes = set(scopes) if not allowed_issuers and issuer: allowed_issuers = [issuer] @@ -93,7 +93,7 @@ async def getter(token: HTTPAuthorizationCredentials = Security(bearer)): # decode and validate the token try: claims = await loop.run_in_executor( - None, core.validate_jwt, token, pub_key, audiences, allowed_issuers + None, core.validate_jwt, token, pub_key, None, scopes, allowed_issuers ) if purpose: diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 9445e4f..992ad19 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -42,8 +42,8 @@ def whitelist( yield client -def test_no_audience(): - with pytest.raises(ValueError, match="audiences"): +def test_no_scopes(): + with pytest.raises(ValueError, match="scopes"): access_token() From 950a1aad57ee404e7b9943fdd7921a74fd8accc4 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Wed, 23 Sep 2020 12:30:58 -0500 Subject: [PATCH 08/27] test(aud-scope): Upd tests to reflect new aud/scope usage --- tests/conftest.py | 7 ++++ tests/test_jwt.py | 100 ++++++++++++++++++++++++---------------------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 62d343b..6a30385 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,13 @@ def iss(): return USER_API +@pytest.fixture(scope="session") +def default_audiences(): + """ + Return default audiences to pass to core.validate_jwt calls. + """ + return None + @pytest.fixture(scope="session") def default_scopes(): """ diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 8c4fed1..75cc411 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -1,12 +1,13 @@ # pylint: disable=unused-argument from collections import OrderedDict +import jwt import flask import pytest import httpx -from authutils.errors import JWTError, JWTAudienceError, JWTExpiredError +from authutils.errors import JWTError, JWTAudienceError, JWTExpiredError, JWTScopeError from authutils.token.keys import get_public_key from authutils.token.core import validate_jwt from authutils.token.validate import require_auth_header @@ -14,51 +15,74 @@ from tests.utils import TEST_RESPONSE_JSON -def test_valid_signature(claims, encoded_jwt, rsa_public_key, default_scopes, iss): +def test_valid_signature(claims, encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss): """ Do a basic test of the expected functionality with the sample payload in the fence README. """ - decoded_token = validate_jwt(encoded_jwt, rsa_public_key, default_scopes, [iss]) + decoded_token = validate_jwt(encoded_jwt, rsa_public_key, default_audiences, default_scopes, [iss]) assert decoded_token assert decoded_token == claims def test_expired_token_rejected( - encoded_jwt_expired, rsa_public_key, default_scopes, iss + encoded_jwt_expired, rsa_public_key, default_audiences, default_scopes, iss ): with pytest.raises(JWTExpiredError): - validate_jwt(encoded_jwt_expired, rsa_public_key, default_scopes, [iss]) + validate_jwt(encoded_jwt_expired, rsa_public_key, default_audiences, default_scopes, [iss]) def test_invalid_signature_rejected( - encoded_jwt, rsa_public_key_2, default_scopes, iss + encoded_jwt, rsa_public_key_2, default_audiences, default_scopes, iss ): """ Test that ``validate_jwt`` rejects JWTs signed with a private key not corresponding to the public key it is given. """ with pytest.raises(JWTError): - validate_jwt(encoded_jwt, rsa_public_key_2, default_scopes, [iss]) + validate_jwt(encoded_jwt, rsa_public_key_2, default_audiences, default_scopes, [iss]) -def test_invalid_aud_rejected(encoded_jwt, rsa_public_key, iss): +def test_invalid_scope_rejected(encoded_jwt, rsa_public_key, default_audiences, iss): """ - Test that if ``validate_jwt`` is passed values for ``aud`` which do not - appear in the token, a ``JWTAudienceError`` is raised. + Test that if ``validate_jwt`` is passed values for ``scope`` which do not + appear in the token, a ``JWTScopeError`` is raised. """ + with pytest.raises(JWTScopeError): + validate_jwt(encoded_jwt, rsa_public_key, default_audiences, {"not-in-scopes"}, [iss]) + + +def test_missing_aud_rejected(encoded_jwt, rsa_public_key, default_scopes, iss): + """ + Test that if ``validate_jwt`` is passed a value for ``aud`` which does not + appear in the token, a ``JWTError`` is raised. + """ + with pytest.raises(JWTError): + validate_jwt(encoded_jwt, rsa_public_key, "not-in-aud", default_scopes, [iss]) + + +def test_unexpected_aud_rejected(claims, token_headers, rsa_private_key, rsa_public_key, default_audiences, default_scopes, iss): + """ + Test that if the token contains an ``aud`` claim and no ``aud`` arg is passed + to ``validate_jwt``, a ``JWTAudienceError`` is raised. + """ + claims = claims.copy() + claims["aud"] = "garbage-aud" + encoded_token = jwt.encode( + claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" + ) with pytest.raises(JWTAudienceError): - validate_jwt(encoded_jwt, rsa_public_key, {"not-in-aud"}, [iss]) + validate_jwt(encoded_token, rsa_public_key, default_audiences, default_scopes, [iss]) -def test_invalid_iss_rejected(encoded_jwt, rsa_public_key, iss): +def test_invalid_iss_rejected(encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss): """ Test that if ``validate_jwt`` receives a token whose value for ``iss`` does not match the expected value, a ``JWTValidationError`` is raised. """ wrong_iss = iss + "garbage" with pytest.raises(JWTError): - validate_jwt(encoded_jwt, rsa_public_key, {"not-in-aud"}, [wrong_iss]) + validate_jwt(encoded_jwt, rsa_public_key, default_audiences, default_scopes, [wrong_iss]) def test_get_public_key(app, example_keys_response, mock_get): @@ -114,56 +138,38 @@ def test_validate_request_jwt_bad_header(client, mock_get, encoded_jwt): client.get("/test", headers=incorrect_headers) -def test_validate_request_jwt_incorrect_usage(app, client, auth_header, mock_get): +def test_validate_request_jwt_missing_all_scopes(app, client, auth_header, mock_get): """ - Test that if a ``require_auth_header`` caller does not give it any - audiences, a JWTAudienceError is raised. + Test that if the JWT is completely missing a scope which is required by + an endpoint, a ``JWTScopeError`` is raised. """ mock_get() - # This should raise a ValueError, since no audiences are provided. - @require_auth_header({}, "access") - def bad(): - return flask.jsonify({"foo": "bar"}) - - app.add_url_rule("/test_incorrect_usage", "bad", bad) - - with pytest.raises(ValueError): - client.get("/test_incorrect_usage", headers=auth_header) - - -def test_validate_request_jwt_missing(app, client, auth_header, mock_get): - """ - Test that if the JWT is completely missing an audience which is required by - an endpoint, a ``jwt.InvalidAudienceError`` is raised. - """ - mock_get() - - # This should raise jwt.InvalidAudienceError, since the audience it + # This should raise a JWTScopeError, since the scope it # requires does not appear in the default JWT anywhere. - @app.route("/test_missing_audience") - @require_auth_header({"missing_audience"}, "access") + @app.route("/test_missing_scope") + @require_auth_header({"missing_scope"}, "access") def bad(): return flask.jsonify({"foo": "bar"}) - with pytest.raises(JWTAudienceError): - client.get("/test_missing_audience", headers=auth_header) + with pytest.raises(JWTScopeError): + client.get("/test_missing_scope", headers=auth_header) -def test_validate_request_jwt_missing_some(app, client, auth_header, mock_get): +def test_validate_request_jwt_missing_some_scopes(app, client, auth_header, mock_get): """ - Test that if the JWT satisfies some audiences but is missing at least one - audience which is required by an endpoint, a ``jwt.InvalidAudienceError`` + Test that if the JWT satisfies some scopes but is missing at least one + scope which is required by an endpoint, a ``JWTScopeError`` is raised. """ mock_get() - # This should raise JWTAudienceError, since the audience it requires does + # This should raise JWTScopeError, since the scope it requires does # not appear in the default JWT anywhere. - @app.route("/test_missing_audience") - @require_auth_header({"access", "missing_audience"}, "access") + @app.route("/test_missing_scope") + @require_auth_header({"access", "missing_scope"}, "access") def bad(): return flask.jsonify({"foo": "bar"}) - with pytest.raises(JWTAudienceError): - client.get("/test_missing_audience", headers=auth_header) + with pytest.raises(JWTScopeError): + client.get("/test_missing_scope", headers=auth_header) From e404a590116d8286e55c385511b8f20417f7ad0c Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Wed, 23 Sep 2020 13:07:47 -0500 Subject: [PATCH 09/27] fix(aud-scope): chg aud to scope in CurrentUser call to validate_request --- src/authutils/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/authutils/user.py b/src/authutils/user.py index 33dffcf..6dae606 100644 --- a/src/authutils/user.py +++ b/src/authutils/user.py @@ -41,8 +41,8 @@ class CurrentUser(object): def __init__(self, claims=None, jwt_kwargs=None): jwt_kwargs = jwt_kwargs or {} - if "aud" not in jwt_kwargs: - jwt_kwargs["aud"] = {"openid"} + if "scope" not in jwt_kwargs: + jwt_kwargs["scope"] = {"openid"} self._claims = claims or validate_request(**jwt_kwargs) self.id = self._claims["sub"] self.username = self._get_user_info("name") From c64b190d054079f0a228b8e87878594a395a0487 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Wed, 23 Sep 2020 13:29:31 -0500 Subject: [PATCH 10/27] test(aud): add happy-path test for aud validation --- tests/test_jwt.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 75cc411..bb5abf1 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -75,6 +75,19 @@ def test_unexpected_aud_rejected(claims, token_headers, rsa_private_key, rsa_pub validate_jwt(encoded_token, rsa_public_key, default_audiences, default_scopes, [iss]) +def test_valid_aud_accepted(claims, token_headers, rsa_private_key, rsa_public_key, default_scopes, iss): + """ + Test that if the token contains multiple audience values in its ``aud`` claim + and one of those values is passed to ``validate_jwt`` then validation passes. + """ + claims = claims.copy() + claims["aud"] = ["foo", "bar", "baz"] + encoded_token = jwt.encode( + claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" + ) + validate_jwt(encoded_token, rsa_public_key, "baz", default_scopes, [iss]) + + def test_invalid_iss_rejected(encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss): """ Test that if ``validate_jwt`` receives a token whose value for ``iss`` From 99b153194d3eefe04c0154fe6887e4a494ea7d41 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Wed, 23 Sep 2020 15:09:37 -0500 Subject: [PATCH 11/27] style(black): Blacken, and update black rev in precommit config --- .pre-commit-config.yaml | 2 +- src/authutils/token/core.py | 42 ++++++++++++++++++++----- src/authutils/token/validate.py | 12 ++++++-- tests/conftest.py | 1 + tests/test_fastapi.py | 4 +-- tests/test_jwt.py | 54 +++++++++++++++++++++++++-------- 6 files changed, 90 insertions(+), 25 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e960ea..e58284b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,6 @@ repos: - id: no-commit-to-branch args: [--branch, develop, --branch, master, --pattern, release/.*] - repo: https://github.com/psf/black - rev: 19.10b0 + rev: 20.8b1 hooks: - id: black diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index 5396377..4a013f5 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -1,6 +1,12 @@ import jwt -from ..errors import JWTAudienceError, JWTExpiredError, JWTPurposeError, JWTScopeError, JWTError +from ..errors import ( + JWTAudienceError, + JWTExpiredError, + JWTPurposeError, + JWTScopeError, + JWTError, +) def get_keys_url(issuer): @@ -92,15 +98,31 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}): # Typecheck arguments. if not isinstance(aud, str) and not aud is None: - raise ValueError("aud must be string or None. Instead received aud of type {}".format(type(aud))) + raise ValueError( + "aud must be string or None. Instead received aud of type {}".format( + type(aud) + ) + ) if not isinstance(scope, set) and not isinstance(scope, list) and not scope is None: - raise ValueError("scope must be set or list or None. Instead received scope of type {}".format(type(scope))) + raise ValueError( + "scope must be set or list or None. Instead received scope of type {}".format( + type(scope) + ) + ) if not isinstance(issuers, set) and not isinstance(issuers, list): - raise ValueError("issuers must be set or list. Instead received issuers of type {}".format(type(issuers))) + raise ValueError( + "issuers must be set or list. Instead received issuers of type {}".format( + type(issuers) + ) + ) try: token = jwt.decode( - encoded_token, key=public_key, algorithms=["RS256"], audience=aud, options=options, + encoded_token, + key=public_key, + algorithms=["RS256"], + audience=aud, + options=options, ) except jwt.InvalidAudienceError as e: raise JWTAudienceError(e) @@ -125,9 +147,15 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}): if isinstance(token_scopes, str): token_scopes = [token_scopes] if not isinstance(token_scopes, list): - raise JWTError("invalid format in scope claim: {}; expected list".format(token["scopes"])) + raise JWTError( + "invalid format in scope claim: {}; expected list".format( + token["scopes"] + ) + ) missing_scopes = set(scope) - set(token_scopes) if missing_scopes: - raise JWTScopeError("token is missing required scopes: " + str(missing_scopes)) + raise JWTScopeError( + "token is missing required scopes: " + str(missing_scopes) + ) return token diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index e301680..dacbc0d 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -140,7 +140,13 @@ def validate_request(scope, purpose="access", logger=None): raise JWTError("no authorization header provided") # Pass token to ``validate_jwt``. - return validate_jwt(encoded_token, scope=scope, purpose=purpose, logger=logger, options={'verify_aud': False}) + return validate_jwt( + encoded_token, + scope=scope, + purpose=purpose, + logger=logger, + options={"verify_aud": False}, + ) def require_auth_header(scope, purpose=None, logger=None): @@ -166,7 +172,9 @@ def wrapper(*args, **kwargs): the code inside the function can use the ``LocalProxy`` for the token (see top of this file). """ - set_current_token(validate_request(scope=scope, purpose=purpose, logger=logger)) + set_current_token( + validate_request(scope=scope, purpose=purpose, logger=logger) + ) return f(*args, **kwargs) return wrapper diff --git a/tests/conftest.py b/tests/conftest.py index 6a30385..4a0acba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,7 @@ def default_audiences(): """ return None + @pytest.fixture(scope="session") def default_scopes(): """ diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 992ad19..8db458d 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -13,9 +13,7 @@ def async_client(default_scopes, mock_async_get, iss): app = fastapi.FastAPI() @app.get("/whoami") - def whoami( - token=fastapi.Depends(access_token(*default_scopes, purpose="access")) - ): + def whoami(token=fastapi.Depends(access_token(*default_scopes, purpose="access"))): return token @app.get("/force_issuer") diff --git a/tests/test_jwt.py b/tests/test_jwt.py index bb5abf1..495464f 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -15,12 +15,16 @@ from tests.utils import TEST_RESPONSE_JSON -def test_valid_signature(claims, encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss): +def test_valid_signature( + claims, encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss +): """ Do a basic test of the expected functionality with the sample payload in the fence README. """ - decoded_token = validate_jwt(encoded_jwt, rsa_public_key, default_audiences, default_scopes, [iss]) + decoded_token = validate_jwt( + encoded_jwt, rsa_public_key, default_audiences, default_scopes, [iss] + ) assert decoded_token assert decoded_token == claims @@ -29,7 +33,13 @@ def test_expired_token_rejected( encoded_jwt_expired, rsa_public_key, default_audiences, default_scopes, iss ): with pytest.raises(JWTExpiredError): - validate_jwt(encoded_jwt_expired, rsa_public_key, default_audiences, default_scopes, [iss]) + validate_jwt( + encoded_jwt_expired, + rsa_public_key, + default_audiences, + default_scopes, + [iss], + ) def test_invalid_signature_rejected( @@ -40,7 +50,9 @@ def test_invalid_signature_rejected( corresponding to the public key it is given. """ with pytest.raises(JWTError): - validate_jwt(encoded_jwt, rsa_public_key_2, default_audiences, default_scopes, [iss]) + validate_jwt( + encoded_jwt, rsa_public_key_2, default_audiences, default_scopes, [iss] + ) def test_invalid_scope_rejected(encoded_jwt, rsa_public_key, default_audiences, iss): @@ -49,7 +61,9 @@ def test_invalid_scope_rejected(encoded_jwt, rsa_public_key, default_audiences, appear in the token, a ``JWTScopeError`` is raised. """ with pytest.raises(JWTScopeError): - validate_jwt(encoded_jwt, rsa_public_key, default_audiences, {"not-in-scopes"}, [iss]) + validate_jwt( + encoded_jwt, rsa_public_key, default_audiences, {"not-in-scopes"}, [iss] + ) def test_missing_aud_rejected(encoded_jwt, rsa_public_key, default_scopes, iss): @@ -61,7 +75,15 @@ def test_missing_aud_rejected(encoded_jwt, rsa_public_key, default_scopes, iss): validate_jwt(encoded_jwt, rsa_public_key, "not-in-aud", default_scopes, [iss]) -def test_unexpected_aud_rejected(claims, token_headers, rsa_private_key, rsa_public_key, default_audiences, default_scopes, iss): +def test_unexpected_aud_rejected( + claims, + token_headers, + rsa_private_key, + rsa_public_key, + default_audiences, + default_scopes, + iss, +): """ Test that if the token contains an ``aud`` claim and no ``aud`` arg is passed to ``validate_jwt``, a ``JWTAudienceError`` is raised. @@ -69,13 +91,17 @@ def test_unexpected_aud_rejected(claims, token_headers, rsa_private_key, rsa_pub claims = claims.copy() claims["aud"] = "garbage-aud" encoded_token = jwt.encode( - claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" + claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" ) with pytest.raises(JWTAudienceError): - validate_jwt(encoded_token, rsa_public_key, default_audiences, default_scopes, [iss]) + validate_jwt( + encoded_token, rsa_public_key, default_audiences, default_scopes, [iss] + ) -def test_valid_aud_accepted(claims, token_headers, rsa_private_key, rsa_public_key, default_scopes, iss): +def test_valid_aud_accepted( + claims, token_headers, rsa_private_key, rsa_public_key, default_scopes, iss +): """ Test that if the token contains multiple audience values in its ``aud`` claim and one of those values is passed to ``validate_jwt`` then validation passes. @@ -83,19 +109,23 @@ def test_valid_aud_accepted(claims, token_headers, rsa_private_key, rsa_public_k claims = claims.copy() claims["aud"] = ["foo", "bar", "baz"] encoded_token = jwt.encode( - claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" + claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" ) validate_jwt(encoded_token, rsa_public_key, "baz", default_scopes, [iss]) -def test_invalid_iss_rejected(encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss): +def test_invalid_iss_rejected( + encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss +): """ Test that if ``validate_jwt`` receives a token whose value for ``iss`` does not match the expected value, a ``JWTValidationError`` is raised. """ wrong_iss = iss + "garbage" with pytest.raises(JWTError): - validate_jwt(encoded_jwt, rsa_public_key, default_audiences, default_scopes, [wrong_iss]) + validate_jwt( + encoded_jwt, rsa_public_key, default_audiences, default_scopes, [wrong_iss] + ) def test_get_public_key(app, example_keys_response, mock_get): From 13d8a3b0d877426785cecafdb49dc270520b960b Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Fri, 9 Oct 2020 16:03:48 -0500 Subject: [PATCH 12/27] test(aud): Explicitly pass None instead of default_audiences * because default_audience may change to not None in future * and because this better reflects the intention of the test --- tests/test_jwt.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 495464f..2d40560 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -80,7 +80,6 @@ def test_unexpected_aud_rejected( token_headers, rsa_private_key, rsa_public_key, - default_audiences, default_scopes, iss, ): @@ -94,9 +93,7 @@ def test_unexpected_aud_rejected( claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" ) with pytest.raises(JWTAudienceError): - validate_jwt( - encoded_token, rsa_public_key, default_audiences, default_scopes, [iss] - ) + validate_jwt(encoded_token, rsa_public_key, None, default_scopes, [iss]) def test_valid_aud_accepted( From e25302d0f54807ecb0ce405466f8f2a089a9b7b6 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Mon, 12 Oct 2020 12:10:16 -0500 Subject: [PATCH 13/27] fix(aud): Re-enable aud claim validation in require_auth_header --- src/authutils/token/validate.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index dacbc0d..bc1a4ee 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -127,8 +127,6 @@ def validate_request(scope, purpose="access", logger=None): """ Validate a ``flask.request`` by checking the JWT contained in the request headers. - SKIPS aud validation: See "Not validating aud claim in Bearer tokens" in the - Fence repo's TECHDEBT file. """ logger = logger or get_logger(__name__, log_level="info") # Get token from the headers. @@ -145,7 +143,6 @@ def validate_request(scope, purpose="access", logger=None): scope=scope, purpose=purpose, logger=logger, - options={"verify_aud": False}, ) @@ -153,8 +150,6 @@ def require_auth_header(scope, purpose=None, logger=None): """ Return a decorator which adds request validation to check the given scopes and (optionally) purpose. - SKIPS aud validation (via validate-request): See "Not validating - aud claim in Bearer tokens" in the Fence repo's TECHDEBT file. """ logger = logger or get_logger(__name__, log_level="info") From 40ef1a492864e8204676f899565842b23435b540 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Thu, 8 Oct 2020 17:17:16 -0500 Subject: [PATCH 14/27] fix(aud): Expect iss in aud claim by default in token.validate_jwt... * ...if a value for iss is avbl, from app cfg BASE_URL or USER_API. * Also clarify core.validate_jwt docstring. --- src/authutils/token/core.py | 4 ++-- src/authutils/token/validate.py | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index 4a013f5..0bac44d 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -77,8 +77,8 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}): aud (Optional[str]): audience with which the app identifies, usually an OIDC client id, which the JWT will be expected to include in its ``aud`` - claim. Optional; if no ``aud`` argument given and the JWT has no - ``aud`` claim, validation will pass. + claim. Optional; if no ``aud`` argument given, then the JWT must + not have an ``aud`` claim, or validation will fail. scope (Optional[Iterable[str]]): set of scopes, each of which the JWT must satisfy in its ``scope`` claim. Optional. diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index bc1a4ee..48a4625 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -82,10 +82,12 @@ def validate_jwt( Args: encoded_token (str): the base64 encoding of the token aud (Optional[str]): - audience with which the app identifies, usually an OIDC - client id, which the JWT will be expected to include in its ``aud`` - claim. Optional; if no ``aud`` argument given and the JWT has no - ``aud`` claim, validation will pass. + audience as which the app identifies, which the JWT will be + expected to include in its ``aud`` claim. + Optional; will default to issuer from flask.current_app.config + if available (either BASE_URL or USER_API). + To skip aud validation, pass the following in the options arg: + options={"verify_aud": False} scope (Optional[Iterable[str]]): scopes that the token must satisfy purpose (Optional[str]): @@ -106,12 +108,21 @@ def validate_jwt( satisfy any expectation """ logger = logger or get_logger(__name__, log_level="info") + if not issuers: issuers = [] for config_var in ["OIDC_ISSUER", "USER_API", "BASE_URL"]: value = flask.current_app.config.get(config_var) if value: issuers.append(value) + + # Can't set arg default to config[x] in fn def, so doing it this way. + if aud is None: + aud = flask.current_app.config.get("BASE_URL") + # Some Gen3 apps use BASE_URL and some use USER_API, so fall back on USER_API + if aud is None: + aud = flask.current_app.config.get("USER_API") + if public_key is None: public_key = get_public_key_for_token( encoded_token, attempt_refresh=attempt_refresh, logger=logger From 71e5bba0a8b5d1ebe0e2ce54bad964e7f3857079 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Mon, 12 Oct 2020 17:06:08 -0500 Subject: [PATCH 15/27] test(app-fixture): Set app.config['BASE_URL'] as well as ['USER_API'] --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 4a0acba..9421890 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -148,7 +148,9 @@ def app(): """ app = flask.Flask(__name__) app.debug = True + # Gen3 services use both USER_API and BASE_URL app.config["USER_API"] = USER_API + app.config["BASE_URL"] = USER_API @app.route("/test") @require_auth_header({"test_scope"}, "access") From 25a3162bf6db9590d2777df37c2638aebf30c04a Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Mon, 12 Oct 2020 17:35:39 -0500 Subject: [PATCH 16/27] fix(aud): Allow passing expected audience to FastAPI access_token dependency --- src/authutils/token/fastapi.py | 12 ++++++++++-- tests/test_fastapi.py | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/authutils/token/fastapi.py b/src/authutils/token/fastapi.py index 6ccd9ff..aaabb05 100644 --- a/src/authutils/token/fastapi.py +++ b/src/authutils/token/fastapi.py @@ -14,7 +14,9 @@ _jwt_public_keys = {} -def access_token(*scopes, issuer=None, allowed_issuers=None, purpose=None): +def access_token( + *scopes, audience=None, issuer=None, allowed_issuers=None, purpose=None +): """ Validate and return the JWT bearer token in HTTP header:: @@ -93,7 +95,13 @@ async def getter(token: HTTPAuthorizationCredentials = Security(bearer)): # decode and validate the token try: claims = await loop.run_in_executor( - None, core.validate_jwt, token, pub_key, None, scopes, allowed_issuers + None, + core.validate_jwt, + token, + pub_key, + audience, + scopes, + allowed_issuers, ) if purpose: diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 8db458d..f64c50c 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -13,13 +13,17 @@ def async_client(default_scopes, mock_async_get, iss): app = fastapi.FastAPI() @app.get("/whoami") - def whoami(token=fastapi.Depends(access_token(*default_scopes, purpose="access"))): + def whoami( + token=fastapi.Depends( + access_token(*default_scopes, audience=iss, purpose="access") + ) + ): return token @app.get("/force_issuer") def force_issuer( token=fastapi.Depends( - access_token(*default_scopes, issuer=iss, purpose="access") + access_token(*default_scopes, audience=iss, issuer=iss, purpose="access") ) ): return token From 57f9bd0f1a8b7c352065809f43f7123a6399c81e Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Mon, 12 Oct 2020 18:27:09 -0500 Subject: [PATCH 17/27] test(aud): Include aud claim in default claims test fixture * Update default_audience fixture accordingly * Update tests to account for new default claims --- tests/conftest.py | 5 +++-- tests/test_jwt.py | 11 ++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9421890..0d30224 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,9 +40,9 @@ def iss(): @pytest.fixture(scope="session") def default_audiences(): """ - Return default audiences to pass to core.validate_jwt calls. + Return default audience to pass to core.validate_jwt calls. """ - return None + return USER_API @pytest.fixture(scope="session") @@ -70,6 +70,7 @@ def claims(default_scopes, iss): "pur": "access", "sub": "1234", "iss": iss, + "aud": iss, "iat": iat, "exp": exp, "jti": str(uuid.uuid4()), diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 2d40560..f7f9ae0 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -76,9 +76,7 @@ def test_missing_aud_rejected(encoded_jwt, rsa_public_key, default_scopes, iss): def test_unexpected_aud_rejected( - claims, - token_headers, - rsa_private_key, + encoded_jwt, rsa_public_key, default_scopes, iss, @@ -87,13 +85,8 @@ def test_unexpected_aud_rejected( Test that if the token contains an ``aud`` claim and no ``aud`` arg is passed to ``validate_jwt``, a ``JWTAudienceError`` is raised. """ - claims = claims.copy() - claims["aud"] = "garbage-aud" - encoded_token = jwt.encode( - claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" - ) with pytest.raises(JWTAudienceError): - validate_jwt(encoded_token, rsa_public_key, None, default_scopes, [iss]) + validate_jwt(encoded_jwt, rsa_public_key, None, default_scopes, [iss]) def test_valid_aud_accepted( From 38326bf9cd15bebc4b86a4b8e121c99369e40f56 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Mon, 12 Oct 2020 18:32:19 -0500 Subject: [PATCH 18/27] fix(aud): Allow passing expected audience to require_auth_header and validate_request * Also let scope={} by default * Update calls to require_auth_header --- src/authutils/token/validate.py | 9 ++++++--- tests/conftest.py | 2 +- tests/test_jwt.py | 12 ++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index 48a4625..7ed6403 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -134,7 +134,7 @@ def validate_jwt( return claims -def validate_request(scope, purpose="access", logger=None): +def validate_request(scope={}, audience=None, purpose="access", logger=None): """ Validate a ``flask.request`` by checking the JWT contained in the request headers. @@ -151,13 +151,14 @@ def validate_request(scope, purpose="access", logger=None): # Pass token to ``validate_jwt``. return validate_jwt( encoded_token, + aud=audience, scope=scope, purpose=purpose, logger=logger, ) -def require_auth_header(scope, purpose=None, logger=None): +def require_auth_header(scope={}, audience=None, purpose=None, logger=None): """ Return a decorator which adds request validation to check the given scopes and (optionally) purpose. @@ -179,7 +180,9 @@ def wrapper(*args, **kwargs): token (see top of this file). """ set_current_token( - validate_request(scope=scope, purpose=purpose, logger=logger) + validate_request( + scope=scope, audience=audience, purpose=purpose, logger=logger + ) ) return f(*args, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 0d30224..28b2b6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -154,7 +154,7 @@ def app(): app.config["BASE_URL"] = USER_API @app.route("/test") - @require_auth_header({"test_scope"}, "access") + @require_auth_header({"test_scope"}, USER_API, "access") def test_endpoint(): """ Define a simple endpoint for testing which requires a JWT header for diff --git a/tests/test_jwt.py b/tests/test_jwt.py index f7f9ae0..12eb07e 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -171,7 +171,9 @@ def test_validate_request_jwt_bad_header(client, mock_get, encoded_jwt): client.get("/test", headers=incorrect_headers) -def test_validate_request_jwt_missing_all_scopes(app, client, auth_header, mock_get): +def test_validate_request_jwt_missing_all_scopes( + app, client, auth_header, default_audiences, mock_get +): """ Test that if the JWT is completely missing a scope which is required by an endpoint, a ``JWTScopeError`` is raised. @@ -181,7 +183,7 @@ def test_validate_request_jwt_missing_all_scopes(app, client, auth_header, mock_ # This should raise a JWTScopeError, since the scope it # requires does not appear in the default JWT anywhere. @app.route("/test_missing_scope") - @require_auth_header({"missing_scope"}, "access") + @require_auth_header({"missing_scope"}, default_audiences, "access") def bad(): return flask.jsonify({"foo": "bar"}) @@ -189,7 +191,9 @@ def bad(): client.get("/test_missing_scope", headers=auth_header) -def test_validate_request_jwt_missing_some_scopes(app, client, auth_header, mock_get): +def test_validate_request_jwt_missing_some_scopes( + app, client, auth_header, default_audiences, mock_get +): """ Test that if the JWT satisfies some scopes but is missing at least one scope which is required by an endpoint, a ``JWTScopeError`` @@ -200,7 +204,7 @@ def test_validate_request_jwt_missing_some_scopes(app, client, auth_header, mock # This should raise JWTScopeError, since the scope it requires does # not appear in the default JWT anywhere. @app.route("/test_missing_scope") - @require_auth_header({"access", "missing_scope"}, "access") + @require_auth_header({"access", "missing_scope"}, default_audiences, "access") def bad(): return flask.jsonify({"foo": "bar"}) From 0178eb42532a3bc67a3002bf78213447ab096b74 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Mon, 12 Oct 2020 18:38:23 -0500 Subject: [PATCH 19/27] fix(aud): Update set_current_user proxy fn to pass in expected aud * based on flask.current_app.config * Since this already assumes Flask request ctx, I think OK to look in Flask app cfg in this case --- src/authutils/user.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/authutils/user.py b/src/authutils/user.py index 6dae606..a3b86ab 100644 --- a/src/authutils/user.py +++ b/src/authutils/user.py @@ -11,7 +11,12 @@ def set_current_user(**kwargs): - flask.g.user = CurrentUser(**kwargs) + expected_audience = flask.current_app.config.get("USER_API") + # Gen3 services use both USER_API and BASE_URL + if not expected_audience: + expected_audience = flask.current_app.config.get("BASE_URL") + + flask.g.user = CurrentUser(jwt_kwargs={"audience": expected_audience}, **kwargs) set_current_token(flask.g.user._claims) return flask.g.user From e0be0f14324fea79efd70113ffe7445fd78e74f0 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Tue, 13 Oct 2020 11:10:43 -0500 Subject: [PATCH 20/27] test(aud): Add test: no aud arg provided and no aud claim in token --- tests/test_jwt.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 12eb07e..59e26dc 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -89,6 +89,26 @@ def test_unexpected_aud_rejected( validate_jwt(encoded_jwt, rsa_public_key, None, default_scopes, [iss]) +def test_expected_missing_aud_accepted( + claims, + token_headers, + rsa_private_key, + rsa_public_key, + default_scopes, + iss, +): + """ + Test that if no ``aud`` arg is passed to ``validate_jwt`` and the token does NOT + contain an ``aud`` claim then validation passes. + """ + claims = claims.copy() + claims.pop("aud") + encoded_token = jwt.encode( + claims, headers=token_headers, key=rsa_private_key, algorithm="RS256" + ) + validate_jwt(encoded_token, rsa_public_key, None, default_scopes, [iss]) + + def test_valid_aud_accepted( claims, token_headers, rsa_private_key, rsa_public_key, default_scopes, iss ): From ba5ffbfd5ab6c9c55a62ceecd97ab695bf4237be Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Tue, 13 Oct 2020 11:12:26 -0500 Subject: [PATCH 21/27] test(aud): Rename fixture default_audiences to default_audience --- tests/conftest.py | 2 +- tests/test_jwt.py | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 28b2b6f..5121bbd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,7 @@ def iss(): @pytest.fixture(scope="session") -def default_audiences(): +def default_audience(): """ Return default audience to pass to core.validate_jwt calls. """ diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 59e26dc..9bc0ccd 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -16,34 +16,34 @@ def test_valid_signature( - claims, encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss + claims, encoded_jwt, rsa_public_key, default_audience, default_scopes, iss ): """ Do a basic test of the expected functionality with the sample payload in the fence README. """ decoded_token = validate_jwt( - encoded_jwt, rsa_public_key, default_audiences, default_scopes, [iss] + encoded_jwt, rsa_public_key, default_audience, default_scopes, [iss] ) assert decoded_token assert decoded_token == claims def test_expired_token_rejected( - encoded_jwt_expired, rsa_public_key, default_audiences, default_scopes, iss + encoded_jwt_expired, rsa_public_key, default_audience, default_scopes, iss ): with pytest.raises(JWTExpiredError): validate_jwt( encoded_jwt_expired, rsa_public_key, - default_audiences, + default_audience, default_scopes, [iss], ) def test_invalid_signature_rejected( - encoded_jwt, rsa_public_key_2, default_audiences, default_scopes, iss + encoded_jwt, rsa_public_key_2, default_audience, default_scopes, iss ): """ Test that ``validate_jwt`` rejects JWTs signed with a private key not @@ -51,18 +51,18 @@ def test_invalid_signature_rejected( """ with pytest.raises(JWTError): validate_jwt( - encoded_jwt, rsa_public_key_2, default_audiences, default_scopes, [iss] + encoded_jwt, rsa_public_key_2, default_audience, default_scopes, [iss] ) -def test_invalid_scope_rejected(encoded_jwt, rsa_public_key, default_audiences, iss): +def test_invalid_scope_rejected(encoded_jwt, rsa_public_key, default_audience, iss): """ Test that if ``validate_jwt`` is passed values for ``scope`` which do not appear in the token, a ``JWTScopeError`` is raised. """ with pytest.raises(JWTScopeError): validate_jwt( - encoded_jwt, rsa_public_key, default_audiences, {"not-in-scopes"}, [iss] + encoded_jwt, rsa_public_key, default_audience, {"not-in-scopes"}, [iss] ) @@ -125,7 +125,7 @@ def test_valid_aud_accepted( def test_invalid_iss_rejected( - encoded_jwt, rsa_public_key, default_audiences, default_scopes, iss + encoded_jwt, rsa_public_key, default_audience, default_scopes, iss ): """ Test that if ``validate_jwt`` receives a token whose value for ``iss`` @@ -134,7 +134,7 @@ def test_invalid_iss_rejected( wrong_iss = iss + "garbage" with pytest.raises(JWTError): validate_jwt( - encoded_jwt, rsa_public_key, default_audiences, default_scopes, [wrong_iss] + encoded_jwt, rsa_public_key, default_audience, default_scopes, [wrong_iss] ) @@ -192,7 +192,7 @@ def test_validate_request_jwt_bad_header(client, mock_get, encoded_jwt): def test_validate_request_jwt_missing_all_scopes( - app, client, auth_header, default_audiences, mock_get + app, client, auth_header, default_audience, mock_get ): """ Test that if the JWT is completely missing a scope which is required by @@ -203,7 +203,7 @@ def test_validate_request_jwt_missing_all_scopes( # This should raise a JWTScopeError, since the scope it # requires does not appear in the default JWT anywhere. @app.route("/test_missing_scope") - @require_auth_header({"missing_scope"}, default_audiences, "access") + @require_auth_header({"missing_scope"}, default_audience, "access") def bad(): return flask.jsonify({"foo": "bar"}) @@ -212,7 +212,7 @@ def bad(): def test_validate_request_jwt_missing_some_scopes( - app, client, auth_header, default_audiences, mock_get + app, client, auth_header, default_audience, mock_get ): """ Test that if the JWT satisfies some scopes but is missing at least one @@ -224,7 +224,7 @@ def test_validate_request_jwt_missing_some_scopes( # This should raise JWTScopeError, since the scope it requires does # not appear in the default JWT anywhere. @app.route("/test_missing_scope") - @require_auth_header({"access", "missing_scope"}, default_audiences, "access") + @require_auth_header({"access", "missing_scope"}, default_audience, "access") def bad(): return flask.jsonify({"foo": "bar"}) From 692c9756350c1a1a52097bccd5d7fbf253596b77 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Thu, 22 Apr 2021 15:17:06 -0500 Subject: [PATCH 22/27] chore(precommit): pre-commit autoupdate --- .pre-commit-config.yaml | 4 +-- .secrets.baseline | 64 ++++++++++++++++++++++++++++++----------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e58284b..9b96f9d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: git@github.com:Yelp/detect-secrets - rev: v0.13.1 + rev: v1.1.0 hooks: - id: detect-secrets args: ['--baseline', '.secrets.baseline'] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 + rev: v3.4.0 hooks: - id: end-of-file-fixer - id: no-commit-to-branch diff --git a/.secrets.baseline b/.secrets.baseline index f9a1b4d..85b4bb3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,8 +1,4 @@ { - "exclude": { - "files": null, - "lines": null - }, "generated_at": "2021-01-19T16:35:59Z", "plugins_used": [ { @@ -12,8 +8,8 @@ "name": "ArtifactoryDetector" }, { - "base64_limit": 4.5, - "name": "Base64HighEntropyString" + "name": "Base64HighEntropyString", + "limit": 4.5 }, { "name": "BasicAuthDetector" @@ -22,8 +18,8 @@ "name": "CloudantDetector" }, { - "hex_limit": 3, - "name": "HexHighEntropyString" + "name": "HexHighEntropyString", + "limit": 3 }, { "name": "IbmCloudIamDetector" @@ -60,26 +56,60 @@ "results": { "src/authutils/oauth2/client/blueprint.py": [ { + "type": "Secret Keyword", + "filename": "src/authutils/oauth2/client/blueprint.py", "hashed_secret": "6eae3a5b062c6d0d79f070c26e6d62486b40cb46", - "is_secret": false, "is_verified": false, "line_number": 15, - "type": "Secret Keyword" + "is_secret": false } ], "src/authutils/testing/fixtures/keys.py": [ { + "type": "Private Key", + "filename": "src/authutils/testing/fixtures/keys.py", "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, "is_verified": false, "line_number": 83, - "type": "Private Key" + "is_secret": false } ] }, - "version": "0.13.1", - "word_list": { - "file": null, - "hash": null - } + "version": "1.1.0", + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + } + ] } From db80a215423876199bc937596d22aa11ead3c648 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Thu, 22 Apr 2021 18:50:16 -0500 Subject: [PATCH 23/27] fix(aud): fix incorrect kwargs logic --- src/authutils/user.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/authutils/user.py b/src/authutils/user.py index a3b86ab..d6169b2 100644 --- a/src/authutils/user.py +++ b/src/authutils/user.py @@ -11,12 +11,17 @@ def set_current_user(**kwargs): - expected_audience = flask.current_app.config.get("USER_API") + default_expected_audience = flask.current_app.config.get("USER_API") # Gen3 services use both USER_API and BASE_URL - if not expected_audience: - expected_audience = flask.current_app.config.get("BASE_URL") + if not default_expected_audience: + default_expected_audience = flask.current_app.config.get("BASE_URL") - flask.g.user = CurrentUser(jwt_kwargs={"audience": expected_audience}, **kwargs) + # If not already passed an aud to expect, default to the application's url + kwargs.setdefault("jwt_kwargs", {}).setdefault( + "audience", default_expected_audience + ) + + flask.g.user = CurrentUser(**kwargs) set_current_token(flask.g.user._claims) return flask.g.user From 2d07c843d85085634f93bfac2cc58a09c8036b57 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Thu, 22 Apr 2021 18:51:06 -0500 Subject: [PATCH 24/27] docs(aud): add missing audience arg to docstring --- src/authutils/token/fastapi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/authutils/token/fastapi.py b/src/authutils/token/fastapi.py index aaabb05..4488cfa 100644 --- a/src/authutils/token/fastapi.py +++ b/src/authutils/token/fastapi.py @@ -28,7 +28,10 @@ def whoami(token=Depends(access_token("user", "openapi", purpose="access"))): Args: *scopes: Required, all must occur in ``scope``. - issuer: Force to use this issuer to validate the token if provided. + audience: Optional; if provided, JWT validation will require that the token's + ``aud`` value contains the arg value; if not provided, validation will require + that the token not have an aud field. + issuer: Optional; force to use this issuer to validate the token if provided. allowed_issuers: Optional allowed issuers whitelist, default: allow all. purpose: Optional, must match ``pur`` if provided. From c0dfb06b5a2f6ba808245590afd017d26de2c28b Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Thu, 22 Apr 2021 18:51:31 -0500 Subject: [PATCH 25/27] test(aud): use default_audience instead of iss in claims fixture --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5121bbd..b4b3a85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,7 +56,7 @@ def default_scopes(): @pytest.fixture(scope="session") -def claims(default_scopes, iss): +def claims(default_audience, default_scopes, iss): """ Return some generic claims to put in a JWT. @@ -70,7 +70,7 @@ def claims(default_scopes, iss): "pur": "access", "sub": "1234", "iss": iss, - "aud": iss, + "aud": default_audience, "iat": iat, "exp": exp, "jti": str(uuid.uuid4()), From 5272cfcd2b55801ef0540dcf07d0bd26299f4c61 Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Fri, 23 Apr 2021 09:23:52 -0500 Subject: [PATCH 26/27] fix(aud-scope): error message Co-authored-by: Pauline Ribeyre --- src/authutils/token/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authutils/token/core.py b/src/authutils/token/core.py index 0bac44d..210940e 100644 --- a/src/authutils/token/core.py +++ b/src/authutils/token/core.py @@ -148,7 +148,7 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}): token_scopes = [token_scopes] if not isinstance(token_scopes, list): raise JWTError( - "invalid format in scope claim: {}; expected list".format( + "invalid format in scope claim: {}; expected string or list".format( token["scopes"] ) ) From 229f586e3412c3eeb1668532c3b6a9e88bfe846b Mon Sep 17 00:00:00 2001 From: vpsx <19900057+vpsx@users.noreply.github.com> Date: Fri, 23 Apr 2021 09:24:25 -0500 Subject: [PATCH 27/27] docs(aud-scope): Fix docstring Co-authored-by: Pauline Ribeyre --- src/authutils/token/validate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authutils/token/validate.py b/src/authutils/token/validate.py index 7ed6403..ce6342b 100644 --- a/src/authutils/token/validate.py +++ b/src/authutils/token/validate.py @@ -161,7 +161,7 @@ def validate_request(scope={}, audience=None, purpose="access", logger=None): def require_auth_header(scope={}, audience=None, purpose=None, logger=None): """ Return a decorator which adds request validation to check the given - scopes and (optionally) purpose. + scopes, audience and purpose (all optional). """ logger = logger or get_logger(__name__, log_level="info")