Skip to content
Open
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
162 changes: 160 additions & 2 deletions zenodo_jupyterlab/server/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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://<host>/user/<name>/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')
Expand All @@ -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)
web_app.add_handlers(".*$", handlers)

Loading