From f6f34fe50aa60d210ad4a37740a2fa856fd16604 Mon Sep 17 00:00:00 2001 From: garciagenrique Date: Fri, 5 Sep 2025 17:09:47 +0200 Subject: [PATCH 1/2] feat: add zenodo Oauth login handlers --- zenodo_jupyterlab/server/handlers.py | 159 ++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/zenodo_jupyterlab/server/handlers.py b/zenodo_jupyterlab/server/handlers.py index 9ba4501..25c99fa 100644 --- a/zenodo_jupyterlab/server/handlers.py +++ b/zenodo_jupyterlab/server/handlers.py @@ -4,11 +4,14 @@ from jupyter_server.base.handlers import APIHandler, JupyterHandler from jupyter_server.utils import url_path_join import os +import secrets +import urllib.parse from .upload import upload from .testConnection import checkZenodoConnection from .search import searchRecords, searchCommunities, recordInformation #from eossr.api.zenodo import ZenodoAPI +from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPClientError class EnvHandler(APIHandler): @@ -133,6 +136,156 @@ async def get(self): self.finish({'root_dir': home_dir}) +class ZenodoOAuthLoginHandler(JupyterHandler): + async def get(self): + """ + Initiate Zenodo OAuth by redirecting the user to the consent page. + Requires env vars: + - ZENODO_CLIENT_ID + - ZENODO_REDIRECT_URI (must match the app config, e.g., https:///user//zenodo-jupyterlab/oauth/callback) + Optional env vars (with sensible defaults): + - ZENODO_AUTHORIZE_URL (default https://zenodo.org/oauth/authorize) + - ZENODO_SCOPES (default "deposit:write deposit:actions") + """ + client_id = os.getenv('ZENODO_CLIENT_ID') + # Use provided redirect_uri or compute it from current base_url + redirect_uri = os.getenv('ZENODO_REDIRECT_URI') + if not redirect_uri: + redirect_uri = f"{self.request.protocol}://{self.request.host}{url_path_join(self.base_url, 'zenodo-jupyterlab', 'oauth', 'callback')}" + authorize_url = os.getenv('ZENODO_AUTHORIZE_URL', 'https://zenodo.org/oauth/authorize') + scopes = os.getenv('ZENODO_SCOPES', 'deposit:write deposit:actions') + + if not client_id or not redirect_uri: + self.set_status(500) + self.finish({ + 'error': 'Missing configuration', + 'details': 'ZENODO_CLIENT_ID and ZENODO_REDIRECT_URI must be set' + }) + return + + # CSRF protection via state parameter stored in a secure cookie + state = secrets.token_urlsafe(32) + # Use secure cookie if cookie_secret is configured (default in Jupyter Server) + try: + self.set_secure_cookie('zenodo_oauth_state', state, httponly=True, samesite='lax') + except Exception: + # Fallback to a regular cookie if secure cookie is unavailable + self.set_cookie('zenodo_oauth_state', state, httponly=True) + + params = { + 'client_id': client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': scopes, + 'state': state, + } + # If not explicitly set, infer sandbox flag from authorize_url + if os.getenv('ZENODO_SANDBOX') is None: + os.environ['ZENODO_SANDBOX'] = 'true' if 'sandbox.zenodo.org' in authorize_url else 'false' + + url = authorize_url + '?' + urllib.parse.urlencode(params) + self.redirect(url) + + +class ZenodoOAuthCallbackHandler(JupyterHandler): + async def get(self): + """ + Handle Zenodo OAuth callback, exchange code for token, and store it in process env as ZENODO_API_KEY + so the rest of the server extension can use it. + Requires env vars: + - ZENODO_CLIENT_ID + - ZENODO_CLIENT_SECRET + - ZENODO_REDIRECT_URI + Optional env vars: + - ZENODO_TOKEN_URL (default https://zenodo.org/oauth/token) + """ + error = self.get_argument('error', None) + if error: + self.set_status(400) + self.finish({'error': error}) + return + + code = self.get_argument('code', None) + state = self.get_argument('state', None) + + expected_state = None + try: + cookie_val = self.get_secure_cookie('zenodo_oauth_state') + if cookie_val is not None: + expected_state = cookie_val.decode('utf-8') if isinstance(cookie_val, bytes) else cookie_val + except Exception: + expected_state = self.get_cookie('zenodo_oauth_state') + + if not code or not state or not expected_state or state != expected_state: + self.set_status(400) + self.finish({'error': 'Invalid OAuth state or missing code'}) + return + + client_id = os.getenv('ZENODO_CLIENT_ID') + client_secret = os.getenv('ZENODO_CLIENT_SECRET') + # Use provided redirect_uri or compute it from current base_url + redirect_uri = os.getenv('ZENODO_REDIRECT_URI') + if not redirect_uri: + redirect_uri = f"{self.request.protocol}://{self.request.host}{url_path_join(self.base_url, 'zenodo-jupyterlab', 'oauth', 'callback')}" + token_url = os.getenv('ZENODO_TOKEN_URL', 'https://zenodo.org/oauth/token') + + if not client_id or not client_secret or not redirect_uri: + self.set_status(500) + self.finish({'error': 'Missing configuration', 'details': 'ZENODO_CLIENT_ID, ZENODO_CLIENT_SECRET and ZENODO_REDIRECT_URI must be set'}) + return + + body = urllib.parse.urlencode({ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'client_secret': client_secret, + }) + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + http = AsyncHTTPClient() + try: + response = await http.fetch(HTTPRequest(url=token_url, method='POST', headers=headers, body=body)) + data = json.loads(response.body.decode('utf-8')) + except HTTPClientError as e: + self.set_status(502) + self.finish({'error': 'Token exchange failed', 'details': str(e)}) + return + except Exception as e: + self.set_status(500) + self.finish({'error': 'Unexpected error during token exchange', 'details': str(e)}) + return + + access_token = data.get('access_token') + if not access_token: + self.set_status(502) + self.finish({'error': 'No access_token in response'}) + return + + # Store token for server-side use by existing endpoints + os.environ['ZENODO_API_KEY'] = access_token + + # Optionally persist other metadata + result = { + 'status': 'linked', + 'token_type': data.get('token_type'), + 'scope': data.get('scope'), + 'expires_in': data.get('expires_in'), + } + self.finish(result) + + +class ZenodoOAuthLogoutHandler(JupyterHandler): + async def post(self): + # Clear server-side token and state cookie + os.environ.pop('ZENODO_API_KEY', None) + try: + self.clear_cookie('zenodo_oauth_state') + except Exception: + pass + self.finish({'status': 'unlinked'}) + + def setup_handlers(web_app): base_path = web_app.settings['base_url'] base_path = url_path_join(base_path, 'zenodo-jupyterlab') @@ -147,7 +300,11 @@ def setup_handlers(web_app): (url_path_join(base_path, 'record-info'), RecordInfoHandler), (url_path_join(base_path, 'files'), FileBrowserHandler), (url_path_join(base_path, 'server-info'), ServerInfoHandler), - (url_path_join(base_path, 'zenodo-api'), ZenodoAPIHandler) + (url_path_join(base_path, 'zenodo-api'), ZenodoAPIHandler), + # OAuth endpoints + (url_path_join(base_path, 'oauth/login'), ZenodoOAuthLoginHandler), + (url_path_join(base_path, 'oauth/callback'), ZenodoOAuthCallbackHandler), + (url_path_join(base_path, 'oauth/logout'), ZenodoOAuthLogoutHandler), ] web_app.add_handlers(".*$", handlers) \ No newline at end of file From 21fcde738ecbdc5726f2299b684134c1dd62518e Mon Sep 17 00:00:00 2001 From: garciagenrique Date: Fri, 5 Sep 2025 17:09:47 +0200 Subject: [PATCH 2/2] feat: add zenodo Oauth login handlers --- zenodo_jupyterlab/server/handlers.py | 162 ++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/zenodo_jupyterlab/server/handlers.py b/zenodo_jupyterlab/server/handlers.py index 9ba4501..1c18daf 100644 --- a/zenodo_jupyterlab/server/handlers.py +++ b/zenodo_jupyterlab/server/handlers.py @@ -4,11 +4,14 @@ from jupyter_server.base.handlers import APIHandler, JupyterHandler from jupyter_server.utils import url_path_join import os +import secrets +import urllib.parse from .upload import upload from .testConnection import checkZenodoConnection from .search import searchRecords, searchCommunities, recordInformation #from eossr.api.zenodo import ZenodoAPI +from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPClientError class EnvHandler(APIHandler): @@ -133,6 +136,156 @@ async def get(self): self.finish({'root_dir': home_dir}) +class ZenodoOAuthLoginHandler(JupyterHandler): + async def get(self): + """ + Initiate Zenodo OAuth by redirecting the user to the consent page. + Requires env vars: + - ZENODO_CLIENT_ID + - ZENODO_REDIRECT_URI (must match the app config, e.g., https:///user//zenodo-jupyterlab/oauth/callback) + Optional env vars (with sensible defaults): + - ZENODO_AUTHORIZE_URL (default https://zenodo.org/oauth/authorize) + - ZENODO_SCOPES (default "deposit:write deposit:actions") + """ + client_id = os.getenv('ZENODO_CLIENT_ID') + # Use provided redirect_uri or compute it from current base_url + redirect_uri = os.getenv('ZENODO_REDIRECT_URI') + if not redirect_uri: + redirect_uri = f"{self.request.protocol}://{self.request.host}{url_path_join(self.base_url, 'zenodo-jupyterlab', 'oauth', 'callback')}" + authorize_url = os.getenv('ZENODO_AUTHORIZE_URL', 'https://zenodo.org/oauth/authorize') + scopes = os.getenv('ZENODO_SCOPES', 'deposit:write deposit:actions') + + if not client_id or not redirect_uri: + self.set_status(500) + self.finish({ + 'error': 'Missing configuration', + 'details': 'ZENODO_CLIENT_ID and ZENODO_REDIRECT_URI must be set' + }) + return + + # CSRF protection via state parameter stored in a secure cookie + state = secrets.token_urlsafe(32) + # Use secure cookie if cookie_secret is configured (default in Jupyter Server) + try: + self.set_secure_cookie('zenodo_oauth_state', state, httponly=True, samesite='lax') + except Exception: + # Fallback to a regular cookie if secure cookie is unavailable + self.set_cookie('zenodo_oauth_state', state, httponly=True) + + params = { + 'client_id': client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': scopes, + 'state': state, + } + # If not explicitly set, infer sandbox flag from authorize_url + if os.getenv('ZENODO_SANDBOX') is None: + os.environ['ZENODO_SANDBOX'] = 'true' if 'sandbox.zenodo.org' in authorize_url else 'false' + + url = authorize_url + '?' + urllib.parse.urlencode(params) + self.redirect(url) + + +class ZenodoOAuthCallbackHandler(JupyterHandler): + async def get(self): + """ + Handle Zenodo OAuth callback, exchange code for token, and store it in process env as ZENODO_API_KEY + so the rest of the server extension can use it. + Requires env vars: + - ZENODO_CLIENT_ID + - ZENODO_CLIENT_SECRET + - ZENODO_REDIRECT_URI + Optional env vars: + - ZENODO_TOKEN_URL (default https://zenodo.org/oauth/token) + """ + error = self.get_argument('error', None) + if error: + self.set_status(400) + self.finish({'error': error}) + return + + code = self.get_argument('code', None) + state = self.get_argument('state', None) + + expected_state = None + try: + cookie_val = self.get_secure_cookie('zenodo_oauth_state') + if cookie_val is not None: + expected_state = cookie_val.decode('utf-8') if isinstance(cookie_val, bytes) else cookie_val + except Exception: + expected_state = self.get_cookie('zenodo_oauth_state') + + if not code or not state or not expected_state or state != expected_state: + self.set_status(400) + self.finish({'error': 'Invalid OAuth state or missing code'}) + return + + client_id = os.getenv('ZENODO_CLIENT_ID') + client_secret = os.getenv('ZENODO_CLIENT_SECRET') + # Use provided redirect_uri or compute it from current base_url + redirect_uri = os.getenv('ZENODO_REDIRECT_URI') + if not redirect_uri: + redirect_uri = f"{self.request.protocol}://{self.request.host}{url_path_join(self.base_url, 'zenodo-jupyterlab', 'oauth', 'callback')}" + token_url = os.getenv('ZENODO_TOKEN_URL', 'https://zenodo.org/oauth/token') + + if not client_id or not client_secret or not redirect_uri: + self.set_status(500) + self.finish({'error': 'Missing configuration', 'details': 'ZENODO_CLIENT_ID, ZENODO_CLIENT_SECRET and ZENODO_REDIRECT_URI must be set'}) + return + + body = urllib.parse.urlencode({ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'client_secret': client_secret, + }) + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + http = AsyncHTTPClient() + try: + response = await http.fetch(HTTPRequest(url=token_url, method='POST', headers=headers, body=body)) + data = json.loads(response.body.decode('utf-8')) + except HTTPClientError as e: + self.set_status(502) + self.finish({'error': 'Token exchange failed', 'details': str(e)}) + return + except Exception as e: + self.set_status(500) + self.finish({'error': 'Unexpected error during token exchange', 'details': str(e)}) + return + + access_token = data.get('access_token') + if not access_token: + self.set_status(502) + self.finish({'error': 'No access_token in response'}) + return + + # Store token for server-side use by existing endpoints + os.environ['ZENODO_API_KEY'] = access_token + + # Optionally persist other metadata + result = { + 'status': 'linked', + 'token_type': data.get('token_type'), + 'scope': data.get('scope'), + 'expires_in': data.get('expires_in'), + } + self.finish(result) + + +class ZenodoOAuthLogoutHandler(JupyterHandler): + async def post(self): + # Clear server-side token and state cookie + os.environ.pop('ZENODO_API_KEY', None) + try: + self.clear_cookie('zenodo_oauth_state') + except Exception: + pass + self.finish({'status': 'unlinked'}) + + def setup_handlers(web_app): base_path = web_app.settings['base_url'] base_path = url_path_join(base_path, 'zenodo-jupyterlab') @@ -147,7 +300,12 @@ def setup_handlers(web_app): (url_path_join(base_path, 'record-info'), RecordInfoHandler), (url_path_join(base_path, 'files'), FileBrowserHandler), (url_path_join(base_path, 'server-info'), ServerInfoHandler), - (url_path_join(base_path, 'zenodo-api'), ZenodoAPIHandler) + (url_path_join(base_path, 'zenodo-api'), ZenodoAPIHandler), + # OAuth endpoints + (url_path_join(base_path, 'oauth/login'), ZenodoOAuthLoginHandler), + (url_path_join(base_path, 'oauth/callback'), ZenodoOAuthCallbackHandler), + (url_path_join(base_path, 'oauth/logout'), ZenodoOAuthLogoutHandler), ] - web_app.add_handlers(".*$", handlers) \ No newline at end of file + web_app.add_handlers(".*$", handlers) + \ No newline at end of file