Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
762 changes: 393 additions & 369 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "authutils"
version = "6.2.7"
version = "7.1.0"
description = "Gen3 auth utility functions"
authors = ["CTDS UChicago <cdis@uchicago.edu>"]
license = "Apache-2.0"
Expand All @@ -11,7 +11,7 @@ cached-property = "~=1.4"
cdiserrors = "<2.0.0"
xmltodict = "~=0.9"

authlib = ">=1.1.0"
authlib = ">=1.6.4"
httpx = ">=0.23.0,<1.0.0"
pyjwt = {version = ">=2.4.0,<3.0", extras = ["crypto"]}
# specify cryptography (dependency of authlib and pyjwt) for snyk issue
Expand All @@ -26,7 +26,7 @@ fastapi = {version = "^0.65.2", optional = true}
flask = ["Flask"]
fastapi = ["fastapi"]

[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
pytest = "^5.4.2"
fastapi = ">=0.0.0,<1.0.0"
mock = "^4.0.2"
Expand Down
39 changes: 24 additions & 15 deletions src/authutils/token/core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import httpx
import jwt

from cdislogging import get_logger

from ..errors import (
JWTAudienceError,
JWTExpiredError,
Expand Down Expand Up @@ -70,7 +72,9 @@ def validate_purpose(claims, pur):
)


def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}):
def validate_jwt(
encoded_token, public_key, aud, scope, issuers, options={}, logger=None
):
"""
Validate the encoded JWT ``encoded_token``, which must satisfy the
scopes ``scope``.
Expand All @@ -80,9 +84,10 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}):

- Decode JWT using public key; PyJWT will fail if iat or exp fields are
invalid
- PyJWT will also fail if the aud field is present in the JWT but no
- PyJWT will NOT 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
the items in the token aud field, because the audience is not validated
anymore
- 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
Expand All @@ -91,11 +96,8 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}):
Args:
encoded_token (str): encoded JWT
public_key (str): public key to validate the JWT signature
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, then the JWT must
not have an ``aud`` claim, or validation will fail.
aud (Optional[str]): parameter present for backwards compatibility; the
audience is not validated anymore
scope (Optional[Iterable[str]]):
set of scopes, each of which the JWT must satisfy in its
``scope`` claim. Optional.
Expand All @@ -112,14 +114,9 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}):
JWTScopeError: if scope validation fails
JWTError: if some other token validation step fails
"""
logger = logger or get_logger(__name__, log_level="info")

# 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)
)
)
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(
Expand All @@ -133,12 +130,24 @@ def validate_jwt(encoded_token, public_key, aud, scope, issuers, options={}):
)
)

# Skip audience validation.
# Background: authutils is used by internal Gen3 services asking if they can use a Gen3 Fence
# token. Each Gen3 service was setting `aud=<Fence URL>` which is not the way the audience
# field is supposed to be used: we were checking that fence was in the list, which it always
# was if fence generated it, so this provided no further protection beyond general JWT / public
# key verification and validation. The validation of which Gen3 instance the token is meant for
# is already done by using the issuer (`iss` field) to get public keys and verify the signature.
if aud is not None:
logger.warning(
f"Authutils no longer validates the token's `aud` field. Received {aud=} which will be ignored."
)
options["verify_aud"] = False

try:
token = jwt.decode(
encoded_token,
key=public_key,
algorithms=["RS256"],
audience=aud,
options=options,
)
except jwt.InvalidAudienceError as e:
Expand Down
4 changes: 2 additions & 2 deletions src/authutils/token/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


def access_token(
*scopes, audience=None, issuer=None, allowed_issuers=None, purpose=None
*scopes, audience=None, issuer=None, allowed_issuers=None, purpose=None, force_issuer=None
):
"""
Validate and return the JWT bearer token in HTTP header::
Expand Down Expand Up @@ -75,7 +75,7 @@ async def getter(token: HTTPAuthorizationCredentials = Security(bearer)):
pub_keys = _jwt_public_keys[issuer] = Future()
try:
async with httpx.AsyncClient() as client:
resp = await client.get(core.get_keys_url(issuer))
resp = await client.get(core.get_keys_url(issuer, force_issuer))
resp.raise_for_status()
pub_keys.set_result(
OrderedDict(get_pem_key(key) for key in resp.json()["keys"])
Expand Down
2 changes: 1 addition & 1 deletion src/authutils/token/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def get_public_key(kid, iss=None, attempt_refresh=True, pkey_cache=None, logger=
if need_refresh and attempt_refresh:
refresh_jwt_public_keys(iss, pkey_cache=pkey_cache, logger=logger)
elif need_refresh and not attempt_refresh:
logger.warn(
logger.warning(
"Public key {} not cached, but application is not attempting refresh.".format(
kid
)
Expand Down
19 changes: 5 additions & 14 deletions src/authutils/token/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,8 @@ def validate_jwt(
Args:
encoded_token (str): the base64 encoding of the token
aud (Optional[str]):
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}
parameter present for backwards compatibility; the audience
is not validated anymore
scope (Optional[Iterable[str]]):
scopes that the token must satisfy
purpose (Optional[str]):
Expand Down Expand Up @@ -116,19 +112,14 @@ def validate_jwt(
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
)

claims = core.validate_jwt(encoded_token, public_key, aud, scope, issuers, options)
claims = core.validate_jwt(
encoded_token, public_key, aud, scope, issuers, options, logger=logger
)
if purpose:
core.validate_purpose(claims, purpose)
return claims
Expand Down
10 changes: 0 additions & 10 deletions src/authutils/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,6 @@


def set_current_user(**kwargs):
default_expected_audience = flask.current_app.config.get("USER_API")
# Gen3 services use both USER_API and BASE_URL
if not default_expected_audience:
default_expected_audience = flask.current_app.config.get("BASE_URL")

# 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
Expand Down
16 changes: 8 additions & 8 deletions tests/test_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,27 +66,27 @@ def test_invalid_scope_rejected(encoded_jwt, rsa_public_key, default_audience, i
)


def test_missing_aud_rejected(encoded_jwt, rsa_public_key, default_scopes, iss):
def test_missing_aud_not_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.
appear in the token, a ``JWTError`` is NOT raised, because the audience is not
validated anymore.
"""
with pytest.raises(JWTError):
validate_jwt(encoded_jwt, rsa_public_key, "not-in-aud", default_scopes, [iss])
validate_jwt(encoded_jwt, rsa_public_key, "not-in-aud", default_scopes, [iss])


def test_unexpected_aud_rejected(
def test_unexpected_aud_not_rejected(
encoded_jwt,
rsa_public_key,
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.
to ``validate_jwt``, a ``JWTAudienceError`` is NOT raised, because the audience is
not validated anymore.
"""
with pytest.raises(JWTAudienceError):
validate_jwt(encoded_jwt, rsa_public_key, None, default_scopes, [iss])
validate_jwt(encoded_jwt, rsa_public_key, None, default_scopes, [iss])


def test_expected_missing_aud_accepted(
Expand Down