Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
3f50830
feat: update OAuth 2.0 providers
leafty Jan 14, 2026
2410b80
add migration
leafty Jan 14, 2026
6cf939e
fix
leafty Jan 14, 2026
d255b8b
Merge branch 'main' into leafty/update-oauth-providers
leafty Jan 15, 2026
db0496e
adjust error when not implemented
leafty Jan 15, 2026
cc51d07
reactivate google adapters
leafty Jan 15, 2026
1794569
oops
leafty Jan 15, 2026
b439034
adjust
leafty Jan 15, 2026
a105f20
wip: placeholder to handle oauth phase 1
leafty Jan 15, 2026
ad792d7
wip: handle access token (no refresh)
leafty Jan 15, 2026
c6678ff
fixes
leafty Jan 15, 2026
1b46775
exp: token_endpoint
leafty Jan 16, 2026
e384564
exp: log more
leafty Jan 16, 2026
56f2b0b
exp: try validation
leafty Jan 16, 2026
7c64346
exp: comments
leafty Jan 16, 2026
17b6617
exp: try to do auth step 1
leafty Jan 16, 2026
5037a7e
try to log other attemtps
leafty Jan 16, 2026
1242786
fix?
leafty Jan 16, 2026
dbcfff9
fix? sanic quirks...
leafty Jan 16, 2026
f71ac6b
try to return a valid response
leafty Jan 16, 2026
72a0bfe
fix, oops
leafty Jan 16, 2026
83d4b0d
fix
leafty Jan 16, 2026
30b1917
wip: handle renku token refresh
leafty Jan 19, 2026
d1aa796
exp: use refresh token
leafty Jan 19, 2026
4f7dddd
fix
leafty Jan 19, 2026
51cd1b0
cleanup
leafty Jan 19, 2026
c2888a3
try: refresh method
leafty Jan 19, 2026
22f39b0
fix token_url
leafty Jan 19, 2026
2634b6d
wip: handle dropbox
leafty Jan 21, 2026
47ea52a
fix get_provider_adapter
leafty Jan 21, 2026
81cc5f6
fix token_access_type
leafty Jan 21, 2026
2431ff1
fix post
leafty Jan 21, 2026
bfdf53c
handle dropbox data connectors
leafty Jan 21, 2026
91ded2e
quick & dirty fix
leafty Jan 21, 2026
d4ff99d
add test_provider_adapters.py
leafty Jan 22, 2026
d3261fc
refactor: cleanup handling code
leafty Jan 22, 2026
a145298
fix async issue
leafty Jan 22, 2026
66f05d3
experimental: patch oauth2 configs
leafty Jan 22, 2026
3ab743f
try to read existing dc secrets
leafty Jan 23, 2026
b1be5e8
fix ULID oopsie
leafty Jan 23, 2026
96eec53
wip: decode existing rclone config
leafty Jan 23, 2026
9e9088a
wip: decode existing rclone config
leafty Jan 23, 2026
59a506d
exp: try patching
leafty Jan 23, 2026
2eb59af
debug?
leafty Jan 23, 2026
9d48d73
try other new secret
leafty Jan 23, 2026
b4573b5
fix?
leafty Jan 23, 2026
5c1b15d
some code cleanup
leafty Jan 27, 2026
1d33d3e
refactor some code
leafty Jan 27, 2026
6f07220
simplify is_patching_enabled()
leafty Jan 27, 2026
3663a54
Merge remote-tracking branch 'origin/main' into leafty/update-oauth-p…
leafty Jan 27, 2026
2b32ef3
fix urlPath
leafty Jan 27, 2026
8655953
cleanup post_token_endpoint()
leafty Jan 27, 2026
b57a67a
feat: handle testing data connector with OAuth 2.0
leafty Jan 29, 2026
9119eef
fix type annotation
leafty Jan 29, 2026
f41b22b
fix: add promt=consent for Google
leafty Feb 2, 2026
48ff1b2
Merge remote-tracking branch 'origin/main' into leafty/update-oauth-p…
leafty Feb 2, 2026
77d05a4
removeme: merge migrations heads
leafty Feb 16, 2026
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
9 changes: 8 additions & 1 deletion bases/renku_data_services/data_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,12 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
storage_repo=dm.storage_repo,
authenticator=dm.gitlab_authenticator,
)
storage_schema = StorageSchemaBP(name="storage_schema", url_prefix=url_prefix)
storage_schema = StorageSchemaBP(
name="storage_schema",
url_prefix=url_prefix,
data_source_repo=dm.data_source_repo,
authenticator=dm.authenticator,
)
user_preferences = UserPreferencesBP(
name="user_preferences",
url_prefix=url_prefix,
Expand Down Expand Up @@ -185,6 +190,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
connected_services_repo=dm.connected_services_repo,
oauth_client_factory=dm.oauth_http_client_factory,
authenticator=dm.authenticator,
nb_config=dm.config.nb_config,
)
repositories = RepositoriesBP(
name="repositories",
Expand All @@ -202,6 +208,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
data_connector_repo=dm.data_connector_repo,
data_connector_secret_repo=dm.data_connector_secret_repo,
git_provider_helper=dm.git_provider_helper,
data_source_repo=dm.data_source_repo,
image_check_repo=dm.image_check_repo,
internal_gitlab_authenticator=dm.gitlab_authenticator,
metrics=dm.metrics,
Expand Down
8 changes: 8 additions & 0 deletions bases/renku_data_services/data_api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from renku_data_services.notebooks.api.classes.data_service import DummyGitProviderHelper, GitProviderHelper
from renku_data_services.notebooks.config import GitProviderHelperProto, get_clusters
from renku_data_services.notebooks.constants import AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK
from renku_data_services.notebooks.data_sources import DataSourceRepository
from renku_data_services.notebooks.image_check import ImageCheckRepository
from renku_data_services.notifications.db import NotificationsRepository
from renku_data_services.platform.db import PlatformRepository, UrlRedirectRepository
Expand Down Expand Up @@ -144,6 +145,7 @@ class DependencyManager:
data_connector_repo: DataConnectorRepository
data_connector_secret_repo: DataConnectorSecretRepository
cluster_repo: ClusterRepository
data_source_repo: DataSourceRepository
image_check_repo: ImageCheckRepository
metrics_repo: MetricsRepository
metrics: StagingMetricsService
Expand Down Expand Up @@ -382,6 +384,11 @@ def from_env(cls) -> DependencyManager:
secret_service_public_key=config.secrets.public_key,
authz=authz,
)
data_source_repo = DataSourceRepository(
nb_config=config.nb_config,
connected_services_repo=connected_services_repo,
oauth_client_factory=oauth_http_client_factory,
)
image_check_repo = ImageCheckRepository(
nb_config=config.nb_config,
connected_services_repo=connected_services_repo,
Expand Down Expand Up @@ -429,6 +436,7 @@ def from_env(cls) -> DependencyManager:
data_connector_repo=data_connector_repo,
data_connector_secret_repo=data_connector_secret_repo,
cluster_repo=cluster_repo,
data_source_repo=data_source_repo,
image_check_repo=image_check_repo,
metrics_repo=metrics_repo,
metrics=metrics,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,11 @@ components:
ProviderKind:
type: string
enum:
- "gitlab"
- "github"
- "drive"
- "onedrive"
- "dropbox"
- "generic_oidc"
- "github"
- "gitlab"
- "google"
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: 2025-09-05T11:16:18+00:00
# timestamp: 2026-01-21T11:52:43+00:00

from __future__ import annotations

Expand Down Expand Up @@ -29,12 +29,11 @@ class AppInstallation(BaseAPISpec):


class ProviderKind(Enum):
gitlab = "gitlab"
github = "github"
drive = "drive"
onedrive = "onedrive"
dropbox = "dropbox"
generic_oidc = "generic_oidc"
github = "github"
gitlab = "gitlab"
google = "google"


class ConnectionStatus(Enum):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Extra definitions for the API spec."""

from __future__ import annotations

import base64
from enum import StrEnum
from typing import Self

from pydantic import ConfigDict, Field

from renku_data_services.connected_services.apispec_base import BaseAPISpec


class PostTokenGrantType(StrEnum):
"""Grant type for token refresh."""

refresh_token = "refresh_token" # nosec B105


class PostTokenRequest(BaseAPISpec):
"""Body for a refresh token request."""

model_config = ConfigDict(
extra="allow",
)
grant_type: PostTokenGrantType
refresh_token: str
client_id: str | None = Field(None)
client_secret: str | None = Field(None)


class PostTokenResponse(BaseAPISpec):
"""Response for a refresh token request."""

model_config = ConfigDict(
extra="allow",
)
access_token: str
token_type: str
expires_in: int
refresh_token: str
refresh_expires_in: int | None = Field(None)
scope: str | None


class RenkuTokens(BaseAPISpec):
"""Represents a set of authentication tokens used in Renku."""

model_config = ConfigDict(
extra="forbid",
)
access_token: str
refresh_token: str

def encode(self) -> str:
"""Encode the Renku tokens as a single URL-safe string."""
as_json = self.model_dump_json()
return base64.urlsafe_b64encode(as_json.encode("utf-8")).decode("utf-8")

@classmethod
def decode(cls, encoded: str) -> Self:
"""Decode a single string into a set of Renku tokens."""
json_raw = base64.urlsafe_b64decode(encoded.encode("utf-8"))
return cls.model_validate_json(json_raw)
131 changes: 128 additions & 3 deletions components/renku_data_services/connected_services/blueprints.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""Connected services blueprint."""

import math
from dataclasses import dataclass
from typing import Any
from datetime import UTC, datetime
from typing import Any, cast
from urllib.parse import unquote, urlparse, urlunparse

import httpx
import jwt
from sanic import HTTPResponse, Request, empty, json, redirect
from sanic.response import JSONResponse
from sanic_ext import validate
Expand All @@ -17,7 +21,7 @@
from renku_data_services.base_api.misc import validate_query
from renku_data_services.base_api.pagination import PaginationRequest, paginate
from renku_data_services.base_models.validation import validate_and_dump, validated_json
from renku_data_services.connected_services import apispec
from renku_data_services.connected_services import apispec, apispec_extras
from renku_data_services.connected_services.apispec_base import AuthorizeParams, CallbackParams
from renku_data_services.connected_services.core import validate_oauth2_client_patch, validate_unsaved_oauth2_client
from renku_data_services.connected_services.db import ConnectedServicesRepository
Expand All @@ -26,6 +30,7 @@
OAuthHttpError,
OAuthHttpFactoryError,
)
from renku_data_services.notebooks.config import NotebooksConfig

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -159,6 +164,7 @@ class OAuth2ConnectionsBP(CustomBlueprint):
connected_services_repo: ConnectedServicesRepository
oauth_client_factory: OAuthHttpClientFactory
authenticator: base_models.Authenticator
nb_config: NotebooksConfig

def get_all(self) -> BlueprintFactoryResponse:
"""List all OAuth2 connections."""
Expand Down Expand Up @@ -202,7 +208,7 @@ async def _get_account(_: Request, user: base_models.APIUser, connection_id: ULI
account = await client.get_connected_account()
match account:
case OAuthHttpError() as err:
raise errors.InvalidTokenError(message=f"OAuth error getting the connected accoun: {err}")
raise errors.InvalidTokenError(message=f"OAuth error getting the connected account: {err}")
case account:
return validated_json(apispec.ConnectedAccount, account)

Expand Down Expand Up @@ -245,3 +251,122 @@ async def _get_installations(
return body, installations_list.total_count

return "/oauth2/connections/<connection_id:ulid>/installations", ["GET"], _get_installations

def post_token_endpoint(self) -> BlueprintFactoryResponse:
"""OAuth 2.0 token endpoint to support applications running in sessions.

Details:
1. Decode the refresh_token value into an instance of RenkuTokens
2. Validate the access_token
-> if the access_token is invalid (expired), use the renku refresh_token
to get a fresh set of tokens
3. Send back the refreshed OAuth 2.0 access token and a the encoded value
of the current RenkuTokens
"""

@validate(form=apispec_extras.PostTokenRequest)
async def _post_token_endpoint(
request: Request, body: apispec_extras.PostTokenRequest, connection_id: ULID
) -> JSONResponse:
renku_tokens = apispec_extras.RenkuTokens.decode(body.refresh_token)
# NOTE: inject the access token in the headers so that we can use `self.authenticator`
request.headers[self.authenticator.token_field] = renku_tokens.access_token

user: base_models.APIUser | None = None
try:
_user = cast(
base_models.APIUser,
await self.authenticator.authenticate(
access_token=renku_tokens.access_token or "", request=request
),
)
if _user.is_authenticated and _user.access_token:
user = _user
except Exception as err:
logger.error(f"Got authenticate error: {err.__class__}.")
raise

# Try to refresh the Renku access token
if user is None and renku_tokens.refresh_token:
renku_base_url = "https://" + self.nb_config.sessions.ingress.host
renku_base_url = renku_base_url.rstrip("/")
renku_realm = self.nb_config.keycloak_realm
renku_auth_token_uri = f"{renku_base_url}/auth/realms/{renku_realm}/protocol/openid-connect/token"

async with httpx.AsyncClient(timeout=10) as http:
auth = (
self.nb_config.sessions.git_proxy.renku_client_id,
self.nb_config.sessions.git_proxy.renku_client_secret,
)
payload = {
"grant_type": "refresh_token",
"refresh_token": renku_tokens.refresh_token,
}
response = await http.post(renku_auth_token_uri, auth=auth, data=payload, follow_redirects=True)
if 200 <= response.status_code < 300:
try:
parsed_response = apispec_extras.PostTokenResponse.model_validate_json(response.content)
except Exception as err:
logger.error(f"Failed to parse refreshed Renku tokens: {err.__class__}.")
raise
try:
renku_tokens.access_token = parsed_response.access_token
renku_tokens.refresh_token = parsed_response.refresh_token
request.headers[self.authenticator.token_field] = renku_tokens.access_token
_user = cast(
base_models.APIUser,
await self.authenticator.authenticate(
access_token=renku_tokens.access_token or "", request=request
),
)
if _user.is_authenticated and _user.access_token:
user = _user
except Exception as err:
logger.error(f"Got authenticate error: {err.__class__}.")
raise
else:
logger.error(
f"Got error from refreshing Renku tokens: HTTP {response.status_code}; {response.json()}."
)
raise errors.UnauthorizedError()

if user is None or not user.is_authenticated:
raise errors.UnauthorizedError()

client = await self.oauth_client_factory.for_user_connection_raise(user, connection_id)
oauth_token = await client.get_token()
access_token = oauth_token.access_token
if access_token is None:
raise errors.ProgrammingError(message="Unexpected error: access token not present.")
result: dict[str, str | int] = {
"access_token": access_token,
"token_type": str(oauth_token.get("token_type")) or "Bearer",
"refresh_token": renku_tokens.encode(),
}
if oauth_token.get("scope"):
result["scope"] = oauth_token["scope"]
# NOTE: Set "expires_in" according to whichever of the OAuth 2.0 access token or the Renku refresh
# token expires first.
try:
refresh_decoded: dict[str, Any] = jwt.decode(
renku_tokens.refresh_token, options={"verify_signature": False}
)
refresh_exp: int | None = refresh_decoded.get("exp")
if refresh_exp is not None and refresh_exp > 0:
exp = datetime.fromtimestamp(refresh_exp, UTC)
expires_in = exp - datetime.now(UTC)
result["expires_in"] = math.ceil(expires_in.total_seconds())
except Exception as err:
logger.error(f"Could not parse Renku refresh token; cannot determine its expiration: {err.__class__}.")
if oauth_token.expires_at:
exp = datetime.fromtimestamp(oauth_token.expires_at, UTC)
expires_in = exp - datetime.now(UTC)
result_expires_in = result.get("expires_in")
if isinstance(result_expires_in, int) and result_expires_in > 0:
result["expires_in"] = min(result_expires_in, math.ceil(expires_in.total_seconds()))
else:
result["expires_in"] = math.ceil(expires_in.total_seconds())

return validated_json(apispec_extras.PostTokenResponse, result)

return "/oauth2/connections/<connection_id:ulid>/token_endpoint", ["POST"], _post_token_endpoint
Loading
Loading