Skip to content

Commit 6fdda39

Browse files
committed
feat: add JWT authorization
* Add support for JWT token-based authentication * Implement JWK fetching and caching mechanism from identity provider * Add JWT validation using Authlib * Modify signin_required decorator to handle JWT bearer tokens * Use user idp_id to map idp user ids to reana user ids * Add test cases for decorator with request context This change allows users to authenticate with JWT tokens issued by an external identity provider, with token validation performed against the provider's JWK set.
1 parent e04d1fd commit 6fdda39

File tree

5 files changed

+128
-4
lines changed

5 files changed

+128
-4
lines changed

reana_server/decorators.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
_get_user_from_invenio_user,
2121
get_user_from_token,
2222
get_quota_excess_message,
23+
_get_user_from_jwt,
2324
)
2425

2526

26-
def signin_required(include_gitlab_login=False, token_required=True):
27+
def signin_required(include_gitlab_login=False, token_required=True, include_jwt=False):
2728
"""Check if the user is signed in or the access token is valid and return the user."""
2829

2930
def decorator(func):
@@ -37,6 +38,9 @@ def wrapper(*args, **kwargs):
3738
user = get_user_from_token(request.headers["X-Gitlab-Token"])
3839
elif "access_token" in request.args:
3940
user = get_user_from_token(request.args.get("access_token"))
41+
elif include_jwt and request.headers['Authorization']:
42+
user = _get_user_from_jwt(request.headers['Authorization'])
43+
4044
if not user:
4145
return jsonify(message="User not signed in"), 401
4246
if token_required and not user.active_token:

reana_server/utils.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import secrets
1818
import sys
1919
import shutil
20+
from authlib.jose import jwt, JoseError
21+
from authlib.jose.rfc7517.jwk import JsonWebKey
2022
from typing import Any, Dict, List, Optional, Union, Generator
2123
from uuid import UUID, uuid4
2224

@@ -432,6 +434,11 @@ def _get_user_from_invenio_user(id):
432434
raise ValueError("User access token revoked.")
433435
return user
434436

437+
def _get_user_by_idpid(idp_id):
438+
user = Session.query(User).filter_by(idp_id=idp_id).one_or_none()
439+
if not user:
440+
raise ValueError("No users registered with this id")
441+
return user
435442

436443
def _get_reana_yaml_from_gitlab(webhook_data, user_id):
437444
reana_yaml = "reana.yaml"
@@ -658,3 +665,65 @@ def render_template(template_path, **kwargs):
658665
"""Render template replacing kwargs appropriately."""
659666
template = JinjaEnv._get().get_template(template_path)
660667
return template.render(**kwargs)
668+
669+
670+
def fetch_and_parse_jwk():
671+
"""Fetch and return specific JWK from an identity provider.
672+
673+
Returns:
674+
dict: JWK matching the kid
675+
676+
Raises:
677+
ValueError: If JWK fetch fails or no matching key found
678+
"""
679+
if not hasattr(fetch_and_parse_jwk, '_cache'):
680+
fetch_and_parse_jwk._cache = None
681+
682+
if not fetch_and_parse_jwk._cache:
683+
response = requests.get("https://iam-escape.cloud.cnaf.infn.it/jwk")
684+
if response.status_code != 200:
685+
raise ValueError(f"Failed to fetch JWK: {response.status_code}")
686+
fetch_and_parse_jwk._cache = response.json()
687+
jwks = fetch_and_parse_jwk._cache.get("keys", [])
688+
if not jwks:
689+
raise ValueError("No JWKs found in the response")
690+
return jwks
691+
692+
693+
694+
def _get_user_from_jwt(header: str) -> User:
695+
"""Get user from JWT token.
696+
697+
Args:
698+
header: JWT Authorization header in the format "Bearer <token>"
699+
700+
Returns:
701+
User: REANA user if token is valid
702+
703+
Raises:
704+
ValueError: If token is invalid or user not found
705+
"""
706+
from authlib.jose import jwt, JoseError
707+
from authlib.jose.rfc7517.jwk import JsonWebKey
708+
709+
try:
710+
if not header.startswith('Bearer '):
711+
raise ValueError("Invalid authorization header format")
712+
713+
token = header.split(" ")[1]
714+
715+
jwks = fetch_and_parse_jwk()
716+
key_set = JsonWebKey.import_key_set(jwks)
717+
718+
claims = jwt.decode(token, key_set)
719+
claims.validate()
720+
721+
idp_id = claims.get('sub')
722+
if not idp_id:
723+
raise ValueError("Token missing subject claim")
724+
725+
return _get_user_by_idpid(idp_id)
726+
except JoseError as e:
727+
raise ValueError(f"Invalid token: {str(e)}")
728+
except Exception as e:
729+
raise ValueError(f"Error processing token: {str(e)}")

requirements.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ argparse-dataclass==2.0.0 # via snakemake-interface-common, snakemake-interface
1313
arrow==1.3.0 # via isoduration
1414
asttokens==3.0.0 # via stack-data
1515
attrs==25.1.0 # via jsonschema
16+
authlib==1.6.0 # via reana-server (setup.py)
1617
babel==2.17.0 # via flask-babel, invenio-i18n
1718
bagit==1.8.1 # via cwltool
1819
billiard==4.2.1 # via celery
@@ -37,7 +38,7 @@ commonmark==0.9.1 # via rich
3738
conda-inject==1.3.2 # via snakemake
3839
configargparse==1.7 # via snakemake, snakemake-interface-common
3940
connection-pool==0.0.3 # via snakemake
40-
cryptography==44.0.0 # via invenio-accounts, pyjwt, sqlalchemy-utils
41+
cryptography==44.0.0 # via authlib, invenio-accounts, pyjwt, sqlalchemy-utils
4142
cwltool==3.1.20210628163208 # via reana-commons
4243
datrie==0.8.2 # via snakemake
4344
decorator==5.1.1 # via ipython, jsonpath-rw
@@ -166,7 +167,7 @@ pytz==2024.1 # via bravado-core, flask-babel, invenio-i18n
166167
pywebpack==2.1.0 # via flask-webpackext
167168
pyyaml==6.0.2 # via bravado, bravado-core, conda-inject, kubernetes, packtivity, reana-commons, snakemake, swagger-spec-validator, yadage, yadage-schemas, yte
168169
rdflib==5.0.0 # via cwltool, prov, schema-salad
169-
reana-commons[cwl,kubernetes,snakemake,yadage]==0.95.0a7 # via reana-db, reana-server (setup.py)
170+
reana-commons[cwl,kubernetes,snakemake,yadage]==0.95.0a9 # via reana-db, reana-server (setup.py)
170171
reana-db==0.95.0a5 # via reana-server (setup.py)
171172
redis==5.2.1 # via invenio-celery
172173
requests[security]==2.32.3 # via bravado, bravado-core, cachecontrol, cwltool, github3-py, kubernetes, packtivity, reana-server (setup.py), requests-oauthlib, schema-salad, snakemake, yadage, yadage-schemas

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
"invenio-theme>=1.4.7",
8383
"invenio-i18n>=1.3.3",
8484
"invenio-access>=2.0.0",
85+
# JWT Auth support
86+
"authlib>=1.6.0",
8587
# Invenio database
8688
"invenio-db[postgresql]>=1.0.5",
8789
"six>=1.12.0", # required by Flask-Breadcrumbs

tests/test_decorators.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""REANA-Server decorators tests."""
99

1010
import json
11-
from unittest.mock import Mock, patch
11+
from unittest.mock import Mock, patch, MagicMock
1212

1313
from flask import jsonify
1414
from reana_db.models import User, UserToken
@@ -31,3 +31,51 @@ def test_signing_required_with_token(user0: User):
3131
mock_endpoint.assert_not_called()
3232
assert code == 401
3333
assert error["message"] == "User has no active tokens"
34+
35+
def test_signin_required_with_jwt(user0: User):
36+
"""Test `signin_required` with JWT token authentication."""
37+
mock_endpoint = Mock(return_value=(jsonify(message="Success"), 200))
38+
mock_current_user = Mock()
39+
mock_current_user.is_authenticated = False
40+
mock_current_user.email = user0.email
41+
42+
mock_request = Mock()
43+
mock_request.headers = {"Authorization": "Bearer token123"}
44+
mock_request.args = {}
45+
mock_request_context = MagicMock()
46+
mock_request_context.__enter__.return_value = mock_request
47+
48+
with patch("reana_server.decorators._get_user_from_jwt", mock_current_user):
49+
with patch("reana_server.decorators.current_user", mock_current_user):
50+
with patch("reana_server.decorators.request", mock_request):
51+
decorated_endpoint = signin_required(include_jwt=True)(mock_endpoint)
52+
response, code = decorated_endpoint()
53+
54+
# Should call the endpoint since authentication succeeded
55+
mock_endpoint.assert_called_once()
56+
assert code == 200
57+
assert json.loads(response.get_data(as_text=True))["message"] == "Success"
58+
59+
def test_signin_required_with_invalid_jwt(user0: User):
60+
"""Test `signin_required` with invalid JWT token."""
61+
mock_endpoint = Mock(return_value=(jsonify(message="Success"), 200))
62+
mock_current_user = Mock()
63+
mock_current_user.is_authenticated = False
64+
mock_current_user.email = user0.email
65+
66+
mock_request = Mock()
67+
mock_request.headers = {"Authorization": "Bearer invalid_token"}
68+
mock_request.args = {}
69+
mock_request_context = MagicMock()
70+
mock_request_context.__enter__.return_value = mock_request
71+
72+
with patch("reana_server.decorators.current_user", mock_current_user):
73+
with patch("reana_server.decorators._get_user_from_jwt", side_effect=ValueError("Invalid token")):
74+
with patch("reana_server.decorators.request", mock_request):
75+
decorated_endpoint = signin_required(include_jwt=True)(mock_endpoint)
76+
response, code = decorated_endpoint()
77+
78+
# Should not call the endpoint since authentication failed
79+
mock_endpoint.assert_not_called()
80+
assert code == 403
81+
assert json.loads(response.get_data(as_text=True))["message"] == "Invalid token"

0 commit comments

Comments
 (0)