Skip to content

Commit 2600f1b

Browse files
committed
feat: add JWT authorization
* Add support for JWT via Authlib and JWK
1 parent e04d1fd commit 2600f1b

File tree

4 files changed

+77
-3
lines changed

4 files changed

+77
-3
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: 67 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

@@ -433,6 +435,13 @@ def _get_user_from_invenio_user(id):
433435
return user
434436

435437

438+
def _get_user_by_idpid(idp_id):
439+
user = Session.query(User).filter_by(idp_id=idp_id).one_or_none()
440+
if not user:
441+
raise ValueError("No users registered with this id")
442+
return user
443+
444+
436445
def _get_reana_yaml_from_gitlab(webhook_data, user_id):
437446
reana_yaml = "reana.yaml"
438447
if webhook_data["object_kind"] == "push":
@@ -658,3 +667,61 @@ def render_template(template_path, **kwargs):
658667
"""Render template replacing kwargs appropriately."""
659668
template = JinjaEnv._get().get_template(template_path)
660669
return template.render(**kwargs)
670+
671+
672+
def fetch_and_parse_jwk():
673+
"""Fetch and return specific JWK from an identity provider.
674+
675+
Returns:
676+
dict: JWK matching the kid
677+
678+
Raises:
679+
ValueError: If JWK fetch fails or no matching key found
680+
"""
681+
if not hasattr(fetch_and_parse_jwk, "_cache"):
682+
fetch_and_parse_jwk._cache = None
683+
684+
if not fetch_and_parse_jwk._cache:
685+
response = requests.get("https://iam-escape.cloud.cnaf.infn.it/jwk")
686+
if response.status_code != 200:
687+
raise ValueError(f"Failed to fetch JWK: {response.status_code}")
688+
fetch_and_parse_jwk._cache = response.json()
689+
jwks = fetch_and_parse_jwk._cache.get("keys", [])
690+
if not jwks:
691+
raise ValueError("No JWKs found in the response")
692+
return jwks
693+
694+
695+
def _get_user_from_jwt(header: str) -> User:
696+
"""Get user from JWT token.
697+
698+
Args:
699+
header: JWT Authorization header in the format "Bearer <token>"
700+
701+
Returns:
702+
User: REANA user if token is valid
703+
704+
Raises:
705+
ValueError: If token is invalid or user not found
706+
"""
707+
try:
708+
if not header.startswith("Bearer "):
709+
raise ValueError("Invalid authorization header format")
710+
711+
token = header.split(" ")[1]
712+
713+
jwks = fetch_and_parse_jwk()
714+
key_set = JsonWebKey.import_key_set(jwks)
715+
716+
claims = jwt.decode(token, key_set)
717+
claims.validate()
718+
719+
idp_id = claims.get("sub")
720+
if not idp_id:
721+
raise ValueError("Token missing subject claim")
722+
723+
return _get_user_by_idpid(idp_id)
724+
except JoseError as e:
725+
raise ValueError(f"Invalid token: {str(e)}")
726+
except Exception as e:
727+
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

0 commit comments

Comments
 (0)