Skip to content

Commit 9a8f2a0

Browse files
authored
feat: add generic OIDC integration type (#1007)
Add a new possible value for the field `kind` on the `OAuth2Client` class in `connected_services`. This allows Renku to connect to third-party platforms which implement OAuth 2.0 via OpenID Connect, e.g. with Keycloak. Other changes: * Update `models.py` so that we do not import from `apispec.py` into `db.py` or `orm.py`.
1 parent b8c5631 commit 9a8f2a0

12 files changed

Lines changed: 275 additions & 31 deletions

File tree

components/renku_data_services/connected_services/api.spec.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ components:
274274
$ref: "#/components/schemas/UsePKCE"
275275
image_registry_url:
276276
$ref: "#/components/schemas/ImageRegistryUrl"
277+
oidc_issuer_url:
278+
$ref: "#/components/schemas/OidcIssuerUrl"
277279
required:
278280
- id
279281
- kind
@@ -307,6 +309,8 @@ components:
307309
$ref: "#/components/schemas/UsePKCE"
308310
image_registry_url:
309311
$ref: "#/components/schemas/ImageRegistryUrl"
312+
oidc_issuer_url:
313+
$ref: "#/components/schemas/OidcIssuerUrl"
310314
required:
311315
- id
312316
- kind
@@ -336,6 +340,8 @@ components:
336340
$ref: "#/components/schemas/UsePKCE"
337341
image_registry_url:
338342
$ref: "#/components/schemas/ImageRegistryUrl"
343+
oidc_issuer_url:
344+
$ref: "#/components/schemas/OidcIssuerUrl"
339345
ConnectionList:
340346
type: array
341347
items:
@@ -410,6 +416,7 @@ components:
410416
- "drive"
411417
- "onedrive"
412418
- "dropbox"
419+
- "generic_oidc"
413420
example: "gitlab"
414421
ApplicationSlug:
415422
description: |
@@ -480,6 +487,11 @@ components:
480487
This should contain no paths, just the domain for the registry and the scheme
481488
(http or https) to access the image registry API.
482489
example: https://registry.gitlab.com
490+
OidcIssuerUrl:
491+
type: string
492+
description: |
493+
The URL for OpenID Connect client discovery. Used for providers of kind 'generic_oidc'.
494+
example: https://renkulab.io/auth/realms/Renku
483495
ErrorResponse:
484496
type: object
485497
properties:

components/renku_data_services/connected_services/apispec.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: api.spec.yaml
3-
# timestamp: 2025-09-01T13:55:20+00:00
3+
# timestamp: 2025-09-05T11:16:18+00:00
44

55
from __future__ import annotations
66

@@ -34,6 +34,7 @@ class ProviderKind(Enum):
3434
drive = "drive"
3535
onedrive = "onedrive"
3636
dropbox = "dropbox"
37+
generic_oidc = "generic_oidc"
3738

3839

3940
class ConnectionStatus(Enum):
@@ -125,6 +126,11 @@ class Provider(BaseAPISpec):
125126
description="This should contain no paths, just the domain for the registry and the scheme\n(http or https) to access the image registry API.\n",
126127
examples=["https://registry.gitlab.com"],
127128
)
129+
oidc_issuer_url: Optional[str] = Field(
130+
None,
131+
description="The URL for OpenID Connect client discovery. Used for providers of kind 'generic_oidc'.\n",
132+
examples=["https://renkulab.io/auth/realms/Renku"],
133+
)
128134

129135

130136
class ProviderPost(BaseAPISpec):
@@ -169,6 +175,11 @@ class ProviderPost(BaseAPISpec):
169175
description="This should contain no paths, just the domain for the registry and the scheme\n(http or https) to access the image registry API.\n",
170176
examples=["https://registry.gitlab.com"],
171177
)
178+
oidc_issuer_url: Optional[str] = Field(
179+
None,
180+
description="The URL for OpenID Connect client discovery. Used for providers of kind 'generic_oidc'.\n",
181+
examples=["https://renkulab.io/auth/realms/Renku"],
182+
)
172183

173184

174185
class ProviderPatch(BaseAPISpec):
@@ -208,6 +219,11 @@ class ProviderPatch(BaseAPISpec):
208219
description="This should contain no paths, just the domain for the registry and the scheme\n(http or https) to access the image registry API.\n",
209220
examples=["https://registry.gitlab.com"],
210221
)
222+
oidc_issuer_url: Optional[str] = Field(
223+
None,
224+
description="The URL for OpenID Connect client discovery. Used for providers of kind 'generic_oidc'.\n",
225+
examples=["https://renkulab.io/auth/realms/Renku"],
226+
)
211227

212228

213229
class Connection(BaseAPISpec):

components/renku_data_services/connected_services/core.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@
88

99
def validate_oauth2_client_patch(patch: apispec.ProviderPatch) -> models.OAuth2ClientPatch:
1010
"""Validate the update to a OAuth2 Client."""
11-
if patch.image_registry_url is not None and len(patch.image_registry_url) > 0:
11+
if patch.image_registry_url:
1212
validate_image_registry_url(patch.image_registry_url)
13+
kind = models.ProviderKind(patch.kind.value) if patch.kind else None
14+
if kind == models.ProviderKind.generic_oidc:
15+
if not patch.oidc_issuer_url:
16+
raise errors.ValidationError(
17+
message=f"The field 'oidc_issuer_url' is required when kind is set to {models.ProviderKind.generic_oidc.value}.", # noqa E501
18+
quiet=True,
19+
)
20+
validate_oidc_issuer_url(patch.oidc_issuer_url)
1321
return models.OAuth2ClientPatch(
14-
kind=patch.kind,
22+
kind=kind,
1523
app_slug=patch.app_slug,
1624
client_id=patch.client_id,
1725
client_secret=patch.client_secret,
@@ -20,16 +28,30 @@ def validate_oauth2_client_patch(patch: apispec.ProviderPatch) -> models.OAuth2C
2028
url=patch.url,
2129
use_pkce=patch.use_pkce,
2230
image_registry_url=patch.image_registry_url,
31+
oidc_issuer_url=patch.oidc_issuer_url,
2332
)
2433

2534

2635
def validate_unsaved_oauth2_client(clnt: apispec.ProviderPost) -> models.UnsavedOAuth2Client:
2736
"""Validate the the creation of a new OAuth2 Client."""
2837
if clnt.image_registry_url is not None:
2938
validate_image_registry_url(clnt.image_registry_url)
39+
kind = models.ProviderKind(clnt.kind.value)
40+
if clnt.oidc_issuer_url and kind != models.ProviderKind.generic_oidc:
41+
raise errors.ValidationError(
42+
message=f"The field 'oidc_issuer_url' can only be set when kind is set to {models.ProviderKind.generic_oidc.value}.", # noqa E501
43+
quiet=True,
44+
)
45+
if kind == models.ProviderKind.generic_oidc:
46+
if not clnt.oidc_issuer_url:
47+
raise errors.ValidationError(
48+
message=f"The field 'oidc_issuer_url' is required when kind is set to {models.ProviderKind.generic_oidc.value}.", # noqa E501
49+
quiet=True,
50+
)
51+
validate_oidc_issuer_url(clnt.oidc_issuer_url)
3052
return models.UnsavedOAuth2Client(
3153
id=clnt.id,
32-
kind=clnt.kind,
54+
kind=kind,
3355
app_slug=clnt.app_slug or "",
3456
client_id=clnt.client_id,
3557
client_secret=clnt.client_secret,
@@ -38,6 +60,7 @@ def validate_unsaved_oauth2_client(clnt: apispec.ProviderPost) -> models.Unsaved
3860
url=clnt.url,
3961
use_pkce=clnt.use_pkce or False,
4062
image_registry_url=clnt.image_registry_url,
63+
oidc_issuer_url=clnt.oidc_issuer_url,
4164
)
4265

4366

@@ -55,3 +78,19 @@ def validate_image_registry_url(url: str) -> None:
5578
message=f"The scheme for the image registry url {url} is not valid, expected one of {accepted_schemes}",
5679
quiet=True,
5780
)
81+
82+
83+
def validate_oidc_issuer_url(url: str) -> None:
84+
"""Validate an OpenID Connect Issuer URL."""
85+
parsed = urlparse(url)
86+
if not parsed.netloc:
87+
raise errors.ValidationError(
88+
message=f"The host for the 'oidc_issuer_url' {url} is not valid, expected a non-empty value.",
89+
quiet=True,
90+
)
91+
accepted_schemes = ["https"]
92+
if parsed.scheme not in accepted_schemes:
93+
raise errors.ValidationError(
94+
message=f"The scheme for the 'oidc_issuer_url' {url} is not valid, expected one of {accepted_schemes}",
95+
quiet=True,
96+
)

components/renku_data_services/connected_services/db.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from renku_data_services.base_api.pagination import PaginationRequest
2020
from renku_data_services.connected_services import models
2121
from renku_data_services.connected_services import orm as schemas
22-
from renku_data_services.connected_services.apispec import ConnectionStatus, ProviderKind
2322
from renku_data_services.connected_services.provider_adapters import (
2423
GitHubAdapter,
2524
ProviderAdapter,
@@ -93,6 +92,7 @@ async def insert_oauth2_client(
9392
use_pkce=new_client.use_pkce or False,
9493
created_by_id=user.id,
9594
image_registry_url=new_client.image_registry_url,
95+
oidc_issuer_url=new_client.oidc_issuer_url or None,
9696
)
9797

9898
async with self.session_maker() as session, session.begin():
@@ -150,6 +150,13 @@ async def update_oauth2_client(
150150
elif patch.image_registry_url == "":
151151
# Patching with "", removes the value
152152
client.image_registry_url = None
153+
if patch.oidc_issuer_url:
154+
client.oidc_issuer_url = patch.oidc_issuer_url
155+
elif patch.oidc_issuer_url == "":
156+
client.oidc_issuer_url = None
157+
# Unset oidc_issuer_url when the kind has been changed to a value other than 'generic_oidc'
158+
if client.kind != models.ProviderKind.generic_oidc:
159+
client.oidc_issuer_url = None
153160

154161
await session.flush()
155162
await session.refresh(client)
@@ -222,14 +229,14 @@ async def authorize_client(
222229
client_id=client.id,
223230
token=None,
224231
state=state,
225-
status=schemas.ConnectionStatus.pending,
232+
status=models.ConnectionStatus.pending,
226233
code_verifier=code_verifier,
227234
next_url=next_url,
228235
)
229236
session.add(connection)
230237
else:
231238
connection.state = state
232-
connection.status = schemas.ConnectionStatus.pending
239+
connection.status = models.ConnectionStatus.pending
233240
connection.code_verifier = code_verifier
234241
connection.next_url = next_url
235242

@@ -284,7 +291,7 @@ async def authorize_callback(self, state: str, raw_url: str, callback_url: str)
284291

285292
connection.token = self._encrypt_token_set(token=token, user_id=connection.user_id)
286293
connection.state = None
287-
connection.status = schemas.ConnectionStatus.connected
294+
connection.status = models.ConnectionStatus.connected
288295
connection.next_url = None
289296

290297
return next_url
@@ -381,7 +388,7 @@ async def get_docker_client(
381388
conn = await session.scalar(stmt)
382389
if not conn:
383390
return None, None
384-
if conn.client.kind != ProviderKind.gitlab:
391+
if conn.client.kind != models.ProviderKind.gitlab:
385392
# NOTE: Only Gitlab is currently supported for this
386393
return None, None
387394
url = conn.client.image_registry_url
@@ -406,7 +413,7 @@ async def get_oauth2_app_installations(
406413
adapter,
407414
):
408415
# NOTE: App installations are only available from GitHub
409-
if connection.client.kind == ProviderKind.github and isinstance(adapter, GitHubAdapter):
416+
if connection.client.kind == models.ProviderKind.github and isinstance(adapter, GitHubAdapter):
410417
request_url = urljoin(adapter.api_url, "user/installations")
411418
params = dict(page=pagination.page, per_page=pagination.per_page)
412419
try:
@@ -450,7 +457,7 @@ async def get_async_oauth2_client(
450457
message=f"OAuth2 connection with id '{connection_id}' does not exist or you do not have access to it." # noqa: E501
451458
)
452459

453-
if connection.status != ConnectionStatus.connected or connection.token is None:
460+
if connection.status != models.ConnectionStatus.connected or connection.token is None:
454461
raise errors.UnauthorizedError(message=f"OAuth2 connection with id '{connection_id}' is not valid.")
455462

456463
client = connection.client

components/renku_data_services/connected_services/external_models.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from pydantic import BaseModel
66

77
from renku_data_services.connected_services import models
8-
from renku_data_services.connected_services.apispec import RepositorySelection
98

109

1110
class GitLabConnectedAccount(BaseModel):
@@ -35,7 +34,7 @@ class GitHubAppInstallation(BaseModel):
3534

3635
id: int
3736
account: GitHubConnectedAccount
38-
repository_selection: RepositorySelection
37+
repository_selection: models.RepositorySelection
3938
suspended_at: datetime | None = None
4039

4140
def to_app_installation(self) -> models.AppInstallation:
@@ -101,3 +100,23 @@ def to_connected_account(self) -> models.ConnectedAccount:
101100
return models.ConnectedAccount(
102101
username=" ".join(filter(None, [self.given_name, self.family_name])), web_url=f"mailto:{self.email}"
103102
)
103+
104+
105+
class GenericOIDCConnectedAccount(BaseModel):
106+
"""OAuth2 connected account model for generic OpenID Connect."""
107+
108+
# Reference: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
109+
sub: str
110+
name: str | None
111+
preferred_username: str | None
112+
113+
def to_connected_account(self) -> models.ConnectedAccount:
114+
"""Returns the corresponding ConnectedAccount object."""
115+
116+
return models.ConnectedAccount(
117+
username=self._get_username(),
118+
web_url="",
119+
)
120+
121+
def _get_username(self) -> str:
122+
return self.preferred_username or self.name or self.sub

components/renku_data_services/connected_services/models.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,35 @@
22

33
from dataclasses import dataclass
44
from datetime import UTC, datetime
5+
from enum import StrEnum
56
from typing import Any
67

78
from ulid import ULID
89

9-
from renku_data_services.connected_services.apispec import ConnectionStatus, ProviderKind, RepositorySelection
10+
11+
class ProviderKind(StrEnum):
12+
"""The kind of platform we connnect to."""
13+
14+
gitlab = "gitlab"
15+
github = "github"
16+
drive = "drive"
17+
onedrive = "onedrive"
18+
dropbox = "dropbox"
19+
generic_oidc = "generic_oidc"
20+
21+
22+
class ConnectionStatus(StrEnum):
23+
"""The status of a connection."""
24+
25+
connected = "connected"
26+
pending = "pending"
27+
28+
29+
class RepositorySelection(StrEnum):
30+
"""The repository selection for GitHub applications."""
31+
32+
all = "all"
33+
selected = "selected"
1034

1135

1236
@dataclass(frozen=True, eq=True, kw_only=True)
@@ -23,6 +47,7 @@ class UnsavedOAuth2Client:
2347
url: str
2448
use_pkce: bool
2549
image_registry_url: str | None = None
50+
oidc_issuer_url: str | None = None
2651

2752

2853
@dataclass(frozen=True, eq=True, kw_only=True)
@@ -47,6 +72,7 @@ class OAuth2ClientPatch:
4772
url: str | None
4873
use_pkce: bool | None
4974
image_registry_url: str | None
75+
oidc_issuer_url: str | None
5076

5177

5278
@dataclass(frozen=True, eq=True, kw_only=True)

components/renku_data_services/connected_services/orm.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from ulid import ULID
1111

1212
from renku_data_services.connected_services import models
13-
from renku_data_services.connected_services.apispec import ConnectionStatus, ProviderKind
1413
from renku_data_services.utils.sqlalchemy import ULIDType
1514

1615
JSONVariant = JSON().with_variant(JSONB(), "postgresql")
@@ -32,7 +31,7 @@ class OAuth2ClientORM(BaseORM):
3231
client_id: Mapped[str] = mapped_column("client_id", String(500), repr=False)
3332
display_name: Mapped[str] = mapped_column("display_name", String(99))
3433
created_by_id: Mapped[str] = mapped_column("created_by_id", String())
35-
kind: Mapped[ProviderKind]
34+
kind: Mapped[models.ProviderKind]
3635
scope: Mapped[str] = mapped_column("scope", String())
3736
url: Mapped[str] = mapped_column("url", String())
3837
use_pkce: Mapped[bool] = mapped_column("use_pkce", Boolean(), server_default=false())
@@ -50,6 +49,7 @@ class OAuth2ClientORM(BaseORM):
5049
nullable=False,
5150
)
5251
image_registry_url: Mapped[str | None] = mapped_column(default=None, nullable=True, server_default=None)
52+
oidc_issuer_url: Mapped[str | None] = mapped_column(default=None, nullable=True, server_default=None)
5353

5454
def dump(self, user_is_admin: bool = False) -> models.OAuth2Client:
5555
"""Create an OAuth2 Client model from the OAuth2ClientORM.
@@ -70,6 +70,7 @@ def dump(self, user_is_admin: bool = False) -> models.OAuth2Client:
7070
creation_date=self.creation_date,
7171
updated_at=self.updated_at,
7272
image_registry_url=self.image_registry_url,
73+
oidc_issuer_url=self.oidc_issuer_url,
7374
)
7475

7576

@@ -83,7 +84,7 @@ class OAuth2ConnectionORM(BaseORM):
8384
client: Mapped[OAuth2ClientORM] = relationship(init=False, repr=False)
8485
token: Mapped[dict[str, Any] | None] = mapped_column("token", JSONVariant)
8586
state: Mapped[str | None] = mapped_column("state", String(), index=True, unique=True)
86-
status: Mapped[ConnectionStatus]
87+
status: Mapped[models.ConnectionStatus]
8788
code_verifier: Mapped[str | None] = mapped_column("code_verifier", String())
8889
next_url: Mapped[str | None] = mapped_column("next_url", String())
8990
creation_date: Mapped[datetime] = mapped_column(

0 commit comments

Comments
 (0)