From 3ee8254a35ab2beafb2d1d95be67f562a0e5bee1 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Mon, 15 Jul 2024 09:52:43 +0100 Subject: [PATCH 01/13] Add refresh token from exchange --- egi_notebooks_hub/egiauthenticator.py | 34 ++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 04cafc9..1a4d5e5 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -18,6 +18,35 @@ class JWTHandler(BaseHandler): + async def exchange_for_refresh_token(self, access_token): + http_client = AsyncHTTPClient() + headers = { + "Accept": "application/json", + "User-Agent": "JupyterHub", + } + body = urlencode( + dict( + grant_type="urn:ietf:params:oauth:grant-type:token-exchange", + requested_token_type="urn:ietf:params:oauth:token-type:refresh_token", + subject_token=access_token, + scope=" ".join(self.scope), + ) + ) + req = HTTPRequest( + self.authenticator.token_url, + auth_username=self.authenticator.client_id, + auth_password=self.authenricator.client_secret, + headers=headers, + method="POST", + body=body, + ) + try: + resp = await http_client.fetch(req) + except HTTPClientError as e: + self.log.warning(f"Unable to get refresh token: {e}") + return None + return resp.json().get("refresh_token", None) + async def get(self): auth_header = self.request.headers.get("Authorization", "") if auth_header: @@ -42,7 +71,10 @@ async def get(self): auth_state = await user.get_auth_state() if auth_state and "refresh_token" not in auth_state: # TODO: decide how to deal with the refresh token - self.log.debug("Refresh token is not there...") + self.log.debug("Refresh token is not available, requesting") + refresh_token = await self.exchange_for_refresh_token(token) + if refresh_token: + auth_state["refresh_token"] = refresh_token # extract from the jwt token (without verification!) decoded_token = jwt.decode(token, options={"verify_signature": False}) From 33f46d14794e1650119c755619d80a3c70491bb6 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Mon, 15 Jul 2024 12:46:27 +0100 Subject: [PATCH 02/13] Find the right path all times --- egi_notebooks_hub/services/api_wrapper.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/egi_notebooks_hub/services/api_wrapper.py b/egi_notebooks_hub/services/api_wrapper.py index 15ed9eb..f8cfeaa 100644 --- a/egi_notebooks_hub/services/api_wrapper.py +++ b/egi_notebooks_hub/services/api_wrapper.py @@ -58,7 +58,11 @@ async def api_wrapper(request: Request, svc_path: str): status_code=exc.response.status_code, detail=exc.response.text ) content = await request.body() - api_path = svc_path.removeprefix(settings.jupyterhub_service_prefix) + api_path = ( + svc_path.removeprefix(settings.jupyterhub_service_prefix.rstrip("/")) + if svc_path + else "" + ) async with httpx.AsyncClient() as client: # which headers do we need to preserve? headers = dict(request.headers) From db4dac4725f941b80b4376afdbea4d881d14a9c3 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Mon, 15 Jul 2024 12:47:06 +0100 Subject: [PATCH 03/13] Fix the token exchange --- egi_notebooks_hub/egiauthenticator.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 1a4d5e5..f984485 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -19,6 +19,7 @@ class JWTHandler(BaseHandler): async def exchange_for_refresh_token(self, access_token): + self.log.debug("Exchanging access token for refresh") http_client = AsyncHTTPClient() headers = { "Accept": "application/json", @@ -29,13 +30,16 @@ async def exchange_for_refresh_token(self, access_token): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", requested_token_type="urn:ietf:params:oauth:token-type:refresh_token", subject_token=access_token, - scope=" ".join(self.scope), + # beware that this requires the "offline_access" or similar + # to be included, otherwise the refresh token will not be + # released. Also the access token must have this scope. + scope=" ".join(self.authenticator.scope), ) ) req = HTTPRequest( self.authenticator.token_url, auth_username=self.authenticator.client_id, - auth_password=self.authenricator.client_secret, + auth_password=self.authenticator.client_secret, headers=headers, method="POST", body=body, @@ -45,7 +49,8 @@ async def exchange_for_refresh_token(self, access_token): except HTTPClientError as e: self.log.warning(f"Unable to get refresh token: {e}") return None - return resp.json().get("refresh_token", None) + token_info = json.loads(resp.body.decode("utf8", "replace")) + return token_info.get("refresh_token", None) async def get(self): auth_header = self.request.headers.get("Authorization", "") @@ -69,11 +74,11 @@ async def get(self): if user is None: raise web.HTTPError(403, self.authenticator.custom_403_message) auth_state = await user.get_auth_state() - if auth_state and "refresh_token" not in auth_state: - # TODO: decide how to deal with the refresh token - self.log.debug("Refresh token is not available, requesting") + if auth_state and not auth_state.get("refresh_token", None): + self.log.debug("Refresh token is not available") refresh_token = await self.exchange_for_refresh_token(token) if refresh_token: + self.log.debug("Got refresh token from exchange") auth_state["refresh_token"] = refresh_token # extract from the jwt token (without verification!) From 04fa11efb9e7cf67720d0609b8ad755e2fc05f7e Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Wed, 17 Jul 2024 10:00:28 +0100 Subject: [PATCH 04/13] Add some debugging --- egi_notebooks_hub/egiauthenticator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index f984485..83baca7 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -48,6 +48,7 @@ async def exchange_for_refresh_token(self, access_token): resp = await http_client.fetch(req) except HTTPClientError as e: self.log.warning(f"Unable to get refresh token: {e}") + self.log.debug(resp.body) return None token_info = json.loads(resp.body.decode("utf8", "replace")) return token_info.get("refresh_token", None) From 4997bbbc98c8276fe769713581276cfa2b656691 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Wed, 17 Jul 2024 11:21:42 +0100 Subject: [PATCH 05/13] Add the subject token type --- egi_notebooks_hub/egiauthenticator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 83baca7..b1a6a8e 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -29,6 +29,7 @@ async def exchange_for_refresh_token(self, access_token): dict( grant_type="urn:ietf:params:oauth:grant-type:token-exchange", requested_token_type="urn:ietf:params:oauth:token-type:refresh_token", + subject_token_type="urn:ietf:params:oauth:token-type:access_token", subject_token=access_token, # beware that this requires the "offline_access" or similar # to be included, otherwise the refresh token will not be From c8c963f918f2e0394b18fee0c878db9a30cec3d1 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Wed, 17 Jul 2024 11:25:41 +0100 Subject: [PATCH 06/13] Show the response error if available --- egi_notebooks_hub/egiauthenticator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index b1a6a8e..4046fff 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -49,7 +49,8 @@ async def exchange_for_refresh_token(self, access_token): resp = await http_client.fetch(req) except HTTPClientError as e: self.log.warning(f"Unable to get refresh token: {e}") - self.log.debug(resp.body) + if e.response: + self.log.debug(e.response.body) return None token_info = json.loads(resp.body.decode("utf8", "replace")) return token_info.get("refresh_token", None) From 76d52204a0b81f7e581f6bfcbc08de248f97e1f3 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Wed, 17 Jul 2024 15:33:27 +0100 Subject: [PATCH 07/13] Cache last used token --- egi_notebooks_hub/egiauthenticator.py | 102 +++++++++++++++++--------- 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 4046fff..5c15b59 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -10,6 +10,7 @@ from urllib.parse import urlencode import jwt +import jwt.exceptions from jupyterhub.handlers import BaseHandler from oauthenticator.generic import GenericOAuthenticator from tornado import web @@ -55,11 +56,23 @@ async def exchange_for_refresh_token(self, access_token): token_info = json.loads(resp.body.decode("utf8", "replace")) return token_info.get("refresh_token", None) - async def get(self): + async def _get_previous_hub_token(self, user, jwt_token): + if not user: + return None + auth_state = await user.get_auth_state() + if auth_state and auth_state.get("access_token", None) == jwt_token: + self.log.debug("JWT previously validated, reusing API token if available") + print("*" * 80) + print("*" * 80) + print("*" * 80) + print("*" * 80) + return auth_state.get("jwt_api_token", None) + + def _get_token(self): auth_header = self.request.headers.get("Authorization", "") if auth_header: try: - bearer, token = auth_header.split() + bearer, jwt_token = auth_header.split() if bearer.lower() != "bearer": self.log.debug("Unexpected authorization header format") raise HTTPError(401) @@ -69,38 +82,59 @@ async def get(self): else: self.log.debug("No authorization header") raise HTTPError(401) - token_info = { - "access_token": token, - "token_type": "bearer", - } - user = await self.login_user(token_info) - if user is None: - raise web.HTTPError(403, self.authenticator.custom_403_message) - auth_state = await user.get_auth_state() - if auth_state and not auth_state.get("refresh_token", None): - self.log.debug("Refresh token is not available") - refresh_token = await self.exchange_for_refresh_token(token) - if refresh_token: - self.log.debug("Got refresh token from exchange") - auth_state["refresh_token"] = refresh_token - - # extract from the jwt token (without verification!) - decoded_token = jwt.decode(token, options={"verify_signature": False}) - # default: 1h token - expires_in = 3600 - if "exp" in decoded_token and "iat" in decoded_token: - expires_in = decoded_token["exp"] - decoded_token["iat"] - - # Possible optimisation here: instead of creating a new token every time, - # go through user.api_tokens and get one from there - api_token = user.new_api_token( - note="JWT auth token", - expires_in=expires_in, - # TODO: this may be tuned, but should be a post - # call with a body specifying the roles and scopes - # roles=token_roles, - # scopes=token_scopes, - ) + try: + decoded_token = jwt.decode( + jwt_token, + options=dict(verify_signature=False, verify_exp=True), + ) + except jwt.exceptions.InvalidTokenError as e: + self.log.debug(f"Invalid token {e}") + raise web.HTTPError(401) + return jwt_token, decoded_token + + async def get(self): + user = None + jwt_token, decoded_token = self._get_token() + try: + username = self.authenticator.user_info_to_username(decoded_token) + user = self.find_user(username) + except ValueError as e: + self.log.debug(f"Unable to get username from token: {e}") + api_token = await self._get_previous_hub_token(user, jwt_token) + if not api_token: + self.log.debug("Authenticating user") + token_info = { + "access_token": jwt_token, + "token_type": "bearer", + } + user = await self.login_user(token_info) + if user is None: + raise web.HTTPError(403, self.authenticator.custom_403_message) + auth_state = await user.get_auth_state() + if auth_state and not auth_state.get("refresh_token", None): + self.log.debug("Refresh token is not available") + refresh_token = await self.exchange_for_refresh_token(jwt_token) + if refresh_token: + self.log.debug("Got refresh token from exchange") + auth_state["refresh_token"] = refresh_token + + # default: 1h token + expires_in = 3600 + if "exp" in decoded_token and "iat" in decoded_token: + expires_in = decoded_token["exp"] - decoded_token["iat"] + + # Possible optimisation here: instead of creating a new token every time, + # go through user.api_tokens and get one from there + api_token = user.new_api_token( + note="JWT auth token", + expires_in=expires_in, + # TODO: this may be tuned, but should be a post + # call with a body specifying the roles and scopes + # roles=token_roles, + # scopes=token_scopes, + ) + auth_state["jwt_api_token"] = api_token + await user.save_auth_state(auth_state) self.finish({"token": api_token, "user": user.name}) From 98c90fa93cb64e3a130373782d7a7a6493bcdf01 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Wed, 17 Jul 2024 15:36:14 +0100 Subject: [PATCH 08/13] Do not print --- egi_notebooks_hub/egiauthenticator.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 5c15b59..241dac1 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -62,10 +62,6 @@ async def _get_previous_hub_token(self, user, jwt_token): auth_state = await user.get_auth_state() if auth_state and auth_state.get("access_token", None) == jwt_token: self.log.debug("JWT previously validated, reusing API token if available") - print("*" * 80) - print("*" * 80) - print("*" * 80) - print("*" * 80) return auth_state.get("jwt_api_token", None) def _get_token(self): From d7fc074b3dcdfe263739833b0a64804b345249d7 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Wed, 17 Jul 2024 16:40:05 +0100 Subject: [PATCH 09/13] Access is refresh, refresh is access EOSC AAI decided to return the refresh token in the access token field --- egi_notebooks_hub/egiauthenticator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 241dac1..518575a 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -54,7 +54,10 @@ async def exchange_for_refresh_token(self, access_token): self.log.debug(e.response.body) return None token_info = json.loads(resp.body.decode("utf8", "replace")) - return token_info.get("refresh_token", None) + if "refresh_token" in token_info: + return token_info.get("refresh_token") + # EOSC AAI returns the token into "access_token" field, so be it + return token_info.get("access_token", None) async def _get_previous_hub_token(self, user, jwt_token): if not user: From bbb765c89d3cae4ac447b1029f50e4ff516c0c99 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Thu, 18 Jul 2024 07:34:57 +0100 Subject: [PATCH 10/13] Reuse existing code from jupyterhub --- egi_notebooks_hub/egiauthenticator.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 518575a..c212ed2 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -12,6 +12,7 @@ import jwt import jwt.exceptions from jupyterhub.handlers import BaseHandler +from jupyterhub import orm from oauthenticator.generic import GenericOAuthenticator from tornado import web from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPError, HTTPRequest @@ -65,21 +66,13 @@ async def _get_previous_hub_token(self, user, jwt_token): auth_state = await user.get_auth_state() if auth_state and auth_state.get("access_token", None) == jwt_token: self.log.debug("JWT previously validated, reusing API token if available") - return auth_state.get("jwt_api_token", None) + api_token = auth_state.get("jwt_api_token", None) + return api_token def _get_token(self): - auth_header = self.request.headers.get("Authorization", "") - if auth_header: - try: - bearer, jwt_token = auth_header.split() - if bearer.lower() != "bearer": - self.log.debug("Unexpected authorization header format") - raise HTTPError(401) - except ValueError: - self.log.debug("Unexpected authorization header format") - raise HTTPError(401) - else: - self.log.debug("No authorization header") + jwt_token = self.get_auth_token() + if not jwt_token: + self.log.debug("No token found in header") raise HTTPError(401) try: decoded_token = jwt.decode( From e94c76ad898d997073bed86c13642e3414cc6c60 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Thu, 18 Jul 2024 07:52:57 +0100 Subject: [PATCH 11/13] Make sure token is still good --- egi_notebooks_hub/egiauthenticator.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index c212ed2..93dcd68 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -65,8 +65,13 @@ async def _get_previous_hub_token(self, user, jwt_token): return None auth_state = await user.get_auth_state() if auth_state and auth_state.get("access_token", None) == jwt_token: - self.log.debug("JWT previously validated, reusing API token if available") api_token = auth_state.get("jwt_api_token", None) + if api_token is None: + return None + orm_token = orm.APIToken.find(self.db, api_token) + if not orm_token or orm_token.expires_in <= 0: + return None + self.log.debug("Reusing previously available API token for this JWT") return api_token def _get_token(self): From 1f23123728552c17c83524f463b9fb235be1ce1c Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Thu, 18 Jul 2024 08:03:50 +0100 Subject: [PATCH 12/13] Sort imports --- egi_notebooks_hub/egiauthenticator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 93dcd68..6732826 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -11,8 +11,8 @@ import jwt import jwt.exceptions -from jupyterhub.handlers import BaseHandler from jupyterhub import orm +from jupyterhub.handlers import BaseHandler from oauthenticator.generic import GenericOAuthenticator from tornado import web from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPError, HTTPRequest From 93f00bffe5040cd666260174f242f01e1644da70 Mon Sep 17 00:00:00 2001 From: Enol Fernandez Date: Thu, 18 Jul 2024 10:15:26 +0100 Subject: [PATCH 13/13] Adjust the Personal Project re --- egi_notebooks_hub/egiauthenticator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/egi_notebooks_hub/egiauthenticator.py b/egi_notebooks_hub/egiauthenticator.py index 6732826..8466849 100644 --- a/egi_notebooks_hub/egiauthenticator.py +++ b/egi_notebooks_hub/egiauthenticator.py @@ -372,7 +372,7 @@ class EOSCNodeAuthenticator(EGICheckinAuthenticator): login_service = "EOSC AAI" personal_project_re = Unicode( - r"^urn:geant:eosc-federation.eu:group:pp:Personal%20Project%20Name-(.*)$", + r"^urn:geant:eosc-federation.eu:group:(pp-.*)$", config=True, help="""Regular expression to match the personal groups. If the regular expression contains a group and matches, it will be