Skip to content
Merged
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
2 changes: 2 additions & 0 deletions bases/renku_data_services/data_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,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 +203,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
99 changes: 98 additions & 1 deletion components/renku_data_services/connected_services/api.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,26 @@ paths:
$ref: "#/components/responses/Error"
tags:
- oauth2
/oauth2/connections/{connection_id}/token:
get:
summary: Get the access token for a specific OAuth2 connection
parameters:
- in: path
name: connection_id
required: true
schema:
type: string
responses:
"200":
description: The access token and its metadata.
content:
application/json:
schema:
$ref: "#/components/schemas/OAuth2Token"
default:
$ref: "#/components/responses/Error"
tags:
- oauth2
/oauth2/connections/{connection_id}/installations:
get:
summary: Get the installations for this OAuth2 connection for the currently authenticated user if their account is connected
Expand Down Expand Up @@ -259,6 +279,32 @@ paths:
$ref: "#/components/responses/Error"
tags:
- oauth2
/oauth2/connections/{connection_id}/token_endpoint:
post:
summary: OAuth 2.0 token endpoint to support applications running in sessions
parameters:
- in: path
name: connection_id
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PostTokenRequest"
responses:
"200":
description: The access token and its metadata.
content:
application/json:
schema:
$ref: "#/components/schemas/PostTokenResponse"
default:
$ref: "#/components/responses/Error"
tags:
- oauth2
components:
schemas:
ProviderList:
Expand Down Expand Up @@ -385,7 +431,21 @@ components:
$ref: "#/components/schemas/WebUrl"
required:
- username
- web_url
OAuth2Token:
type: object
additionalProperties: true
properties:
access_token:
type: string
description: An access token for OAuth 2.0
scope:
type: string
token_type:
type: string
id_token:
type: string
expires_at_iso:
type: string
AppInstallationList:
type: array
items:
Expand Down Expand Up @@ -413,6 +473,38 @@ components:
- account_login
- account_web_url
- repository_selection
PostTokenRequest:
type: object
additionalProperties: true
properties:
grant_type:
$ref: "#/components/schemas/PostTokenGrantType"
refresh_token:
type: string
required:
- grant_type
- refresh_token
PostTokenResponse:
type: object
additionalProperties: true
properties:
access_token:
type: string
token_type:
type: string
expires_in:
type: integer
refresh_token:
type: string
refresh_expires_in:
type: integer
scope:
type: string
required:
- access_token
- token_type
- expires_in
- refresh_token
Ulid:
description: ULID identifier
type: string
Expand Down Expand Up @@ -506,6 +598,11 @@ components:
description: |
The URL for OpenID Connect client discovery. Used for providers of kind 'generic_oidc'.
example: https://renkulab.io/auth/realms/Renku
PostTokenGrantType:
type: string
description: A grant type for OAuth 2.0 (see RFC 6749)
enum:
- refresh_token
ErrorResponse:
type: object
properties:
Expand Down
43 changes: 40 additions & 3 deletions components/renku_data_services/connected_services/apispec.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: api.spec.yaml
# timestamp: 2026-01-26T13:18:55+00:00
# timestamp: 2026-02-05T09:28:04+00:00

from __future__ import annotations

Expand All @@ -12,6 +12,19 @@
from renku_data_services.connected_services.apispec_base import BaseAPISpec


class OAuth2Token(BaseAPISpec):
model_config = ConfigDict(
extra="allow",
)
access_token: Optional[str] = Field(
None, description="An access token for OAuth 2.0"
)
scope: Optional[str] = None
token_type: Optional[str] = None
id_token: Optional[str] = None
expires_at_iso: Optional[str] = None


class RepositorySelection(Enum):
all = "all"
selected = "selected"
Expand All @@ -28,6 +41,18 @@ class AppInstallation(BaseAPISpec):
suspended_at: Optional[datetime] = None


class PostTokenResponse(BaseAPISpec):
model_config = ConfigDict(
extra="allow",
)
access_token: str
token_type: str
expires_in: int
refresh_token: str
refresh_expires_in: Optional[int] = None
scope: Optional[str] = None


class ProviderKind(Enum):
dropbox = "dropbox"
generic_oidc = "generic_oidc"
Expand All @@ -51,6 +76,10 @@ class PaginationRequest(BaseAPISpec):
)


class PostTokenGrantType(Enum):
refresh_token = "refresh_token"


class Error(BaseAPISpec):
code: int = Field(..., examples=[1404], gt=0)
detail: Optional[str] = Field(
Expand Down Expand Up @@ -249,8 +278,8 @@ class ConnectedAccount(BaseAPISpec):
extra="forbid",
)
username: str = Field(..., examples=["some-username"])
web_url: str = Field(
...,
web_url: Optional[str] = Field(
None,
description="A URL which can be opened in a browser, i.e. a web page.",
examples=["https://example.org"],
)
Expand All @@ -260,6 +289,14 @@ class AppInstallationList(RootModel[List[AppInstallation]]):
root: List[AppInstallation]


class PostTokenRequest(BaseAPISpec):
model_config = ConfigDict(
extra="allow",
)
grant_type: PostTokenGrantType
refresh_token: str


class ProviderList(RootModel[List[Provider]]):
root: List[Provider]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Extra definitions for the API spec."""

Comment thread
leafty marked this conversation as resolved.
from __future__ import annotations

import base64
from typing import Self

from pydantic import ConfigDict

from renku_data_services.connected_services.apispec_base import BaseAPISpec


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)
39 changes: 37 additions & 2 deletions components/renku_data_services/connected_services/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@
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.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.core import (
handle_oauth2_token_refresh,
validate_oauth2_client_patch,
validate_unsaved_oauth2_client,
)
from renku_data_services.connected_services.db import ConnectedServicesRepository
from renku_data_services.connected_services.oauth_http import (
OAuthHttpClientFactory,
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,32 @@ 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.PostTokenRequest)
async def _post_token_endpoint(
request: Request, body: apispec.PostTokenRequest, connection_id: ULID
) -> JSONResponse:
result = await handle_oauth2_token_refresh(
request=request,
body=body,
connection_id=connection_id,
oauth_client_factory=self.oauth_client_factory,
authenticator=self.authenticator,
nb_config=self.nb_config,
)

return validated_json(apispec.PostTokenResponse, result)

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