Skip to content
Draft
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions bases/renku_data_services/data_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,9 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
data_connector_secret_repo=dm.data_connector_secret_repo,
authenticator=dm.authenticator,
metrics=dm.metrics,
zenodo_client=dm.zenodo_client,
connected_services_repo=dm.connected_services_repo,
job_client=dm.job_client,
)
notifications = NotificationsBP(
name="notifications",
Expand Down
7 changes: 7 additions & 0 deletions bases/renku_data_services/data_api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@
DataConnectorRepository,
DataConnectorSecretRepository,
)
from renku_data_services.data_connectors.deposits.zenodo import ZenodoAPIClient
from renku_data_services.git.gitlab import DummyGitlabAPI, EmptyGitlabAPI, GitlabAPI
from renku_data_services.k8s.client_interfaces import K8sClient
from renku_data_services.k8s.clients import (
DepositUploadJobClient,
K8sClusterClientsPool,
K8sPriorityClassClient,
K8sResourceQuotaClient,
Expand Down Expand Up @@ -164,6 +166,8 @@ class DependencyManager:
occurrence_repo: OccurrenceRepository
resource_requests_repo: ResourceRequestsRepo
resource_usage_service: ResourceUsageService
zenodo_client: ZenodoAPIClient
job_client: DepositUploadJobClient

spec: dict[str, Any] = field(init=False, repr=False, default_factory=dict)
app_name: str = "renku_data_services"
Expand Down Expand Up @@ -249,6 +253,7 @@ def from_env(cls) -> DependencyManager:
),
)
quota_repo = QuotaRepository(K8sResourceQuotaClient(client), K8sPriorityClassClient(client))
job_client = DepositUploadJobClient(client)

if config.dummy_stores:
authenticator = DummyAuthenticator()
Expand Down Expand Up @@ -473,4 +478,6 @@ def from_env(cls) -> DependencyManager:
occurrence_repo=occurrence_repo,
resource_requests_repo=resource_requests_repo,
resource_usage_service=resource_usage_service,
zenodo_client=ZenodoAPIClient(),
job_client=job_client,
)
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ components:
- "github"
- "gitlab"
- "google"
- "zenodo"
example: "gitlab"
ApplicationSlug:
description: |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: api.spec.yaml
# timestamp: 2026-02-17T20:15:50+00:00
# timestamp: 2026-03-09T14:27:30+00:00

from __future__ import annotations

Expand Down Expand Up @@ -59,6 +59,7 @@ class ProviderKind(Enum):
github = "github"
gitlab = "gitlab"
google = "google"
zenodo = "zenodo"


class ConnectionStatus(Enum):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from ulid import ULID

from renku_data_services.users.db import APIUser
from renku_data_services.base_models import APIUser


class ProviderKind(StrEnum):
Expand All @@ -18,6 +18,7 @@ class ProviderKind(StrEnum):
github = "github"
gitlab = "gitlab"
google = "google"
zenodo = "zenodo"


class ConnectionStatus(StrEnum):
Expand Down
47 changes: 40 additions & 7 deletions components/renku_data_services/connected_services/oauth_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from contextlib import asynccontextmanager
from enum import StrEnum
from typing import Any, Protocol
from urllib.parse import urljoin
from urllib.parse import parse_qs, urljoin, urlparse

import httpx
import sqlalchemy as sa
import sqlalchemy.orm as sao
from authlib.integrations.base_client import InvalidTokenError, OAuthError
Expand All @@ -24,8 +25,8 @@
from renku_data_services.app_config import logging
from renku_data_services.base_api.pagination import PaginationRequest
from renku_data_services.connected_services import models
from renku_data_services.connected_services.models import OAuth2Client, OAuth2Connection, OAuth2TokenSet
from renku_data_services.connected_services.orm import OAuth2ConnectionORM
from renku_data_services.connected_services.models import OAuth2Client, OAuth2Connection, OAuth2TokenSet, ProviderKind
from renku_data_services.connected_services.orm import OAuth2ClientORM, OAuth2ConnectionORM
from renku_data_services.connected_services.provider_adapters import (
GitHubAdapter,
ProviderAdapter,
Expand All @@ -37,7 +38,6 @@
get_github_provider_type,
)
from renku_data_services.errors import SecretDecryptionError, errors
from renku_data_services.repositories.models import OAuth2ClientORM
from renku_data_services.users.db import APIUser
from renku_data_services.utils import cryptography as crypt

Expand Down Expand Up @@ -542,9 +542,42 @@ async def fetch_token(self, state: str, raw_url: str, callback_url: str) -> OAut
adapter=adapter,
token=None,
)
token = await oauth_client.fetch_token(
adapter.token_endpoint_url, authorization_response=raw_url, code_verifier=code_verifier
)
if client.kind == ProviderKind.zenodo:
# NOTE: Getting the token from the oauth_client.fetch_token method did not work
# with Zenodo. Probably because Zenodo is very sensitive to the encoding.
parsed_url = urlparse(raw_url)
q = parse_qs(parsed_url.query)
code = next(iter(q.get("code") or []), None)
if not code:
raise errors.InvalidTokenError(
message="The callback from zenodo did not contain a code.",
detail="Please retry, if this problem persist contact a Renku administrator.",
)
state_from_query = next(iter(q.get("state") or []), None)
if not state_from_query:
raise errors.InvalidTokenError(
message="The callback from zenodo did not contain a state parameter.",
detail="Please retry, if this problem persist contact a Renku administrator.",
)
state = state_from_query
body = {
"client_id": client.client_id,
"client_secret": client_secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": callback_url,
"scope": client.scope,
"state": state,
}
async with httpx.AsyncClient() as clnt:
res = await clnt.post(adapter.token_endpoint_url, data=body)
token = res.json()
else:
token = await oauth_client.fetch_token(
adapter.token_endpoint_url,
authorization_response=raw_url,
code_verifier=code_verifier,
)

logger.info(f"Token for client {client.id} has keys: {', '.join(token.keys())}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from renku_data_services import errors
from renku_data_services.connected_services import external_models, models
from renku_data_services.connected_services import orm as schemas
from renku_data_services.data_connectors.deposits.zenodo import DepositResponseList


class ProviderAdapter(ABC):
Expand Down Expand Up @@ -232,6 +233,49 @@ def api_validate_account_response(self, response: Response) -> models.ConnectedA
return external_models.DropboxConnectedAccount.model_validate(response.json()).to_connected_account()


class ZenodoAdapter(ProviderAdapter):
"""Adapter for Zenodo OAuth2 clients."""

# NOTE: Zenodo does not provide a userinfo or any kind of similar endpoint
# So the closest thing is to hit the deposits endpoint which responds with 200
# if the user is properly authenticated.
user_info_endpoint = "deposit/depositions"
user_info_method = "GET"

@property
def authorization_url(self) -> str:
"""The authorization URL for the OAuth2 protocol."""
return "https://zenodo.org/oauth/authorize"

@property
def token_endpoint_url(self) -> str:
"""The token endpoint URL for the OAuth2 protocol."""
return "https://zenodo.org/oauth/token"

@property
def api_url(self) -> str:
"""The URL used for API calls on the Resource Server."""
return "https://zenodo.org/api/"

@property
def api_common_headers(self) -> dict[str, str] | None:
"""The HTTP headers used for API calls on the Resource Server."""
return {
"Accept": "application/json",
"Content-Type": "application/json",
}

def api_validate_account_response(self, response: Response) -> models.ConnectedAccount:
"""Validates and returns the connected account response from the Resource Server."""
if response.status_code != 200:
raise errors.InvalidTokenError(message="Your zenodo credentials are expired or invalid, please reconnect.")
deposits = DepositResponseList.model_validate(response.json())
username: str = "Zenodo user"
if len(deposits.root) >= 1:
username = f"Zenodo user ID: {str(deposits.root[0].owner)}"
return models.ConnectedAccount(username=username, web_url="")


class GenericOidcAdapter(ProviderAdapter):
"""Adapter for generic OpenID Connect clients."""

Expand Down Expand Up @@ -316,6 +360,7 @@ def __get_httpx_client(cls) -> Client:
models.ProviderKind.github: GitHubAdapter,
models.ProviderKind.gitlab: GitLabAdapter,
models.ProviderKind.google: GoogleAdapter,
models.ProviderKind.zenodo: ZenodoAdapter,
}


Expand Down
11 changes: 0 additions & 11 deletions components/renku_data_services/crc/api.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1996,14 +1996,3 @@ components:
"application/json":
schema:
$ref: "#/components/schemas/ErrorResponse"
securitySchemes:
bearer:
scheme: bearer
type: http
oidc:
type: openIdConnect
openIdConnectUrl: /auth/realms/Renku/.well-known/openid-configuration
security:
- bearer: []
- oidc:
- openid
Loading
Loading