Skip to content

Commit 1dc599c

Browse files
authored
Merge pull request #23 from ESA-APEx/token_exchange
Token exchange
2 parents 4fc2f27 + 895bb88 commit 1dc599c

File tree

10 files changed

+375
-196
lines changed

10 files changed

+375
-196
lines changed

app/config/openeo/settings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from enum import Enum
2+
from typing import Optional
3+
from pydantic import BaseModel
4+
5+
6+
class OpenEOBackendConfig(BaseModel):
7+
client_credentials: Optional[str] = None
8+
token_provider: Optional[str] = None
9+
token_prefix: Optional[str] = None
10+
11+
12+
class OpenEOAuthMethod(str, Enum):
13+
CLIENT_CREDENTIALS = "CLIENT_CREDENTIALS"
14+
USER_CREDENTIALS = "USER_CREDENTIALS"

app/config/settings.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import json
2+
from typing import Dict
3+
14
from pydantic import Field
25
from pydantic_settings import BaseSettings, SettingsConfigDict
36

7+
from app.config.openeo.settings import OpenEOAuthMethod, OpenEOBackendConfig
8+
49

510
class Settings(BaseSettings):
611
app_name: str = Field(
7-
default="APEx Disatpcher API", json_schema_extra={"env": "APP_NAME"}
12+
default="APEx Dispach API", json_schema_extra={"env": "APP_NAME"}
813
)
914
app_description: str = Field(
10-
default="API description for the APEx Dispatcher",
15+
default="",
1116
json_schema_extra={"env": "APP_DESCRIPTION"},
1217
)
1318
env: str = Field(default="development", json_schema_extra={"env": "APP_ENV"})
@@ -16,6 +21,12 @@ class Settings(BaseSettings):
1621
default="", json_schema_extra={"env": "CORS_ALLOWED_ORIGINS"}
1722
)
1823

24+
model_config = SettingsConfigDict(
25+
env_file=".env",
26+
env_file_encoding="utf-8",
27+
extra="allow",
28+
)
29+
1930
# Keycloak / OIDC
2031
keycloak_host: str = Field(
2132
default=str("localhost"),
@@ -29,17 +40,47 @@ class Settings(BaseSettings):
2940
default="", json_schema_extra={"env": "KEYCLOAK_CLIENT_SECRET"}
3041
)
3142

32-
model_config = SettingsConfigDict(
33-
env_file=".env",
34-
env_file_encoding="utf-8",
35-
extra="allow",
43+
# openEO Settings
44+
openeo_auth_method: OpenEOAuthMethod = Field(
45+
default=OpenEOAuthMethod.USER_CREDENTIALS,
46+
json_schema_extra={"env": "OPENEO_AUTH_METHOD"},
3647
)
3748

38-
# openEO
39-
openeo_enable_user_credentials: bool = Field(
40-
default=False,
41-
json_schema_extra={"env": "OPENEO_ENABLE_USER_CREDENTIALS"},
49+
openeo_backends: str | None = Field(
50+
default="", json_schema_extra={"env": "OPENEO_BACKENDS"}
4251
)
4352

53+
openeo_backend_config: Dict[str, OpenEOBackendConfig] = Field(default_factory=dict)
54+
55+
def load_openeo_backends_from_env(self):
56+
"""
57+
Populate self.backends from BACKENDS_JSON if provided, otherwise keep defaults.
58+
BACKENDS_JSON should be a JSON object keyed by hostname with BackendConfig-like values.
59+
"""
60+
required_fields = []
61+
if self.openeo_backends:
62+
63+
if self.openeo_auth_method == OpenEOAuthMethod.CLIENT_CREDENTIALS:
64+
required_fields = ["client_credentials"]
65+
elif self.openeo_auth_method == OpenEOAuthMethod.USER_CREDENTIALS:
66+
required_fields = ["token_provider"]
67+
68+
try:
69+
raw = json.loads(self.openeo_backends)
70+
for host, cfg in raw.items():
71+
backend = OpenEOBackendConfig(**cfg)
72+
73+
for field in required_fields:
74+
if not getattr(backend, field, None):
75+
raise ValueError(
76+
f"Backend '{host}' must define '{field}' when "
77+
f"OPENEO_AUTH_METHOD={self.openeo_auth_method}"
78+
)
79+
self.openeo_backend_config[host] = OpenEOBackendConfig(**cfg)
80+
except Exception:
81+
# Fall back or raise as appropriate
82+
raise
83+
4484

4585
settings = Settings()
86+
settings.load_openeo_backends_from_env()

app/platforms/implementations/openeo.py

Lines changed: 44 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,21 @@
11
import datetime
2-
import os
3-
import re
4-
from typing import Any
5-
import urllib
6-
import jwt
72

8-
from loguru import logger
3+
import jwt
94
import openeo
105
import requests
116
from dotenv import load_dotenv
7+
from loguru import logger
8+
from stac_pydantic import Collection
129

1310
from app.auth import exchange_token_for_provider
14-
from app.config.settings import settings
11+
from app.config.settings import OpenEOAuthMethod, settings
1512
from app.platforms.base import BaseProcessingPlatform
1613
from app.platforms.dispatcher import register_platform
17-
from app.schemas.enum import OutputFormatEnum, ProcessTypeEnum, ProcessingStatusEnum
14+
from app.schemas.enum import OutputFormatEnum, ProcessingStatusEnum, ProcessTypeEnum
1815
from app.schemas.unit_job import ServiceDetails
19-
from stac_pydantic import Collection
2016

2117
load_dotenv()
2218

23-
# Constants
24-
BACKEND_AUTH_ENV_MAP = {
25-
"openeo.dataspace.copernicus.eu": "OPENEO_AUTH_CLIENT_CREDENTIALS_CDSEFED",
26-
"openeofed.dataspace.copernicus.eu": "OPENEO_AUTH_CLIENT_CREDENTIALS_CDSEFED",
27-
"openeo.vito.be": "OPENEO_AUTH_CLIENT_CREDENTIALS_OPENEO_VITO",
28-
}
29-
30-
BACKEND_PROVIDER_ID_MAP = {
31-
"openeo.dataspace.copernicus.eu": "esa-eoiam",
32-
"openeofed.dataspace.copernicus.eu": "esa-eoiam",
33-
"openeo.vito.be": "egi",
34-
}
35-
3619

3720
@register_platform(ProcessTypeEnum.OPENEO)
3821
class OpenEOPlatform(BaseProcessingPlatform):
@@ -72,23 +55,44 @@ def _connection_expired(self, connection: openeo.Connection) -> bool:
7255
logger.warning("No JWT bearer token found in connection.")
7356
return True
7457

58+
async def _get_bearer_token(self, user_token: str, url: str) -> str:
59+
"""
60+
Retrieve the bearer token for the OpenEO backend. This is done by exchanging the user's
61+
token for a platform-specific token using the configured token provider.
62+
63+
:param url: The URL of the OpenEO backend.
64+
:return: The bearer token as a string.
65+
"""
66+
67+
provider = settings.openeo_backend_config[url].token_provider
68+
token_prefix = settings.openeo_backend_config[url].token_prefix
69+
70+
if not provider or not token_prefix:
71+
raise ValueError(
72+
f"Backend '{url}' must define 'token_provider' and 'token_prefix'"
73+
)
74+
75+
platform_token = await exchange_token_for_provider(
76+
initial_token=user_token, provider=provider
77+
)
78+
return f"{token_prefix}/{platform_token['access_token']}"
79+
7580
async def _authenticate_user(
7681
self, user_token: str, url: str, connection: openeo.Connection
7782
) -> openeo.Connection:
7883
"""
7984
Authenticate the connection using the user's token.
8085
This method can be used to set the user's token for the connection.
8186
"""
82-
if settings.openeo_enable_user_credentials:
87+
88+
if url not in settings.openeo_backend_config:
89+
raise ValueError(f"No OpenEO backend configuration found for URL: {url}")
90+
91+
if settings.openeo_auth_method == OpenEOAuthMethod.USER_CREDENTIALS:
8392
logger.debug("Using user credentials for OpenEO connection authentication")
84-
provider = self._get_backend_config(BACKEND_PROVIDER_ID_MAP, url)
85-
platform_token = await exchange_token_for_provider(
86-
initial_token=user_token, provider=provider
87-
)
88-
connection.authenticate_bearer_token(
89-
bearer_token=platform_token["access_token"]
90-
)
91-
else:
93+
bearer_token = await self._get_bearer_token(user_token, url)
94+
connection.authenticate_bearer_token(bearer_token=bearer_token)
95+
elif settings.openeo_auth_method == OpenEOAuthMethod.CLIENT_CREDENTIALS:
9296
logger.debug(
9397
"Using client credentials for OpenEO connection authentication"
9498
)
@@ -99,6 +103,10 @@ async def _authenticate_user(
99103
client_id=client_id,
100104
client_secret=client_secret,
101105
)
106+
else:
107+
raise ValueError(
108+
f"Unsupported OpenEO authentication method: {settings.openeo_auth_method}"
109+
)
102110

103111
return connection
104112

@@ -119,22 +127,6 @@ async def _setup_connection(self, user_token: str, url: str) -> openeo.Connectio
119127
self._connection_cache[url] = connection
120128
return connection
121129

122-
def _get_backend_config(self, map: Any, url: str) -> str:
123-
"""
124-
Get the authentication provider for the OpenEO backend.
125-
126-
:param url: The URL of the OpenEO backend.
127-
:return: The name of the authentication provider.
128-
"""
129-
if not re.match(r"https?://", url):
130-
url = f"https://{url}"
131-
132-
hostname = urllib.parse.urlparse(url).hostname
133-
if not hostname or hostname not in map:
134-
raise ValueError(f"Unsupported backend: {url} (hostname={hostname})")
135-
136-
return map[hostname]
137-
138130
def _get_client_credentials(self, url: str) -> tuple[str, str, str]:
139131
"""
140132
Get client credentials for the OpenEO backend.
@@ -143,16 +135,17 @@ def _get_client_credentials(self, url: str) -> tuple[str, str, str]:
143135
:param url: The URL of the OpenEO backend.
144136
:return: A tuple containing provider ID, client ID, and client secret.
145137
"""
146-
env_var = self._get_backend_config(BACKEND_AUTH_ENV_MAP, url)
147-
credentials_str = os.getenv(env_var)
138+
credentials_str = settings.openeo_backend_config[url].client_credentials
148139

149140
if not credentials_str:
150-
raise ValueError(f"Environment variable {env_var} not set.")
141+
raise ValueError(
142+
f"Client credentials not configured for OpenEO backend at {url}"
143+
)
151144

152145
parts = credentials_str.split("/", 2)
153146
if len(parts) != 3:
154147
raise ValueError(
155-
f"Invalid client credentials format in {env_var},"
148+
f"Invalid client credentials format for {url},"
156149
"expected 'provider_id/client_id/client_secret'."
157150
)
158151
provider_id, client_id, client_secret = parts

app/services/processing.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,48 @@ async def create_processing_job(
3939
user = get_current_user_id(token)
4040
logger.info(f"Creating processing job for {user} with summary: {request}")
4141

42-
platform = get_processing_platform(request.label)
42+
try:
43+
platform = get_processing_platform(request.label)
44+
45+
job_id = await platform.execute_job(
46+
user_token=token,
47+
title=request.title,
48+
details=request.service,
49+
parameters=request.parameters,
50+
format=request.format,
51+
)
4352

44-
job_id = await platform.execute_job(
45-
user_token=token,
46-
title=request.title,
47-
details=request.service,
48-
parameters=request.parameters,
49-
format=request.format,
50-
)
53+
record = ProcessingJobRecord(
54+
title=request.title,
55+
label=request.label,
56+
status=(
57+
ProcessingStatusEnum.CREATED if job_id else ProcessingStatusEnum.FAILED
58+
),
59+
user_id=user,
60+
platform_job_id=job_id,
61+
parameters=json.dumps(request.parameters),
62+
service=request.service.model_dump_json(),
63+
upscaling_task_id=upscaling_task_id,
64+
)
65+
66+
except Exception as e:
67+
logger.error(f"Error creating processing job: {e}")
68+
69+
if upscaling_task_id:
70+
# Do create the record in case of upscaling task to mark it as failed
71+
record = ProcessingJobRecord(
72+
title=request.title,
73+
label=request.label,
74+
status=ProcessingStatusEnum.FAILED,
75+
user_id=user,
76+
platform_job_id=None,
77+
parameters=json.dumps(request.parameters),
78+
service=request.service.model_dump_json(),
79+
upscaling_task_id=upscaling_task_id,
80+
)
81+
else:
82+
raise e
5183

52-
record = ProcessingJobRecord(
53-
title=request.title,
54-
label=request.label,
55-
status=ProcessingStatusEnum.CREATED if job_id else ProcessingStatusEnum.FAILED,
56-
user_id=user,
57-
platform_job_id=job_id,
58-
parameters=json.dumps(request.parameters),
59-
service=request.service.model_dump_json(),
60-
upscaling_task_id=upscaling_task_id,
61-
)
6284
record = save_job_to_db(database, record)
6385
return ProcessingJobSummary(
6486
id=record.id,

docs/environment.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Configuring the Dispatcher
2+
The Dispatcher can be configured using environment variables. These variables can be set directly in your shell or defined in a `.env` file for convenience.
3+
Below are the key settings that can be adjusted to tailor the Dispatcher's behavior to your needs.
4+
5+
| Environment Variable | Description | Values | Default Value |
6+
| ------------------------ | ----------------------------------------------------------- | ----------------------------------------- | ------------------ |
7+
| **General Settings** | | | |
8+
| `APP_NAME` | The name of the application. | Text | APEx Dispatch API |
9+
| `APP_DESCRIPTION` | A brief description of the application. | Text | "" |
10+
| `APP_ENV` | The environment in which the application is running | `development` / `production` | development |
11+
| `CORS_ALLOWED_ORIGINS` | Comma-separated list of allowed origins for CORS. | Text | "" |
12+
| **Database Settings**|||
13+
| `DATABASE_URL` | The database connection URL. | Text | "" |
14+
| **Keycloak Settings** | | | |
15+
| `KEYCLOAK_HOST` | The hostname of the Keycloak server. | Text | localhost |
16+
| `KEYCLOAK_REALM` | The Keycloak realm to use for authentication. | Text | "" |
17+
| `KEYCLOAK_CLIENT_ID` | The client ID registered in Keycloak. | Text | "" |
18+
| `KEYCLOAK_CLIENT_SECRET` | The client secret for the Keycloak client. | Text | "" |
19+
| **openEO Settings** | | | |
20+
| `OPENEO_AUTH_METHOD` | The authentication method to use for openEO backends. | `USER_CREDENTIALS` / `CLIENT_CREDENTIALS` | `USER_CREDENTIALS` |
21+
| `OPENEO_BACKEND_CONFIG` | JSON string defining the configuration for openEO backends. | JSON | `{}` |
22+
23+
24+
## openEO Backend Configuration
25+
The `OPENEO_BACKEND_CONFIG` environment variable allows you to specify the configuration for multiple openEO backends in JSON format.
26+
Here is an example of how to structure this configuration:
27+
28+
```json
29+
{
30+
"https://openeo.backend1.com": {
31+
"client_credentials": "oidc_provider/client_id/secret_secret",
32+
"token_provider": "backend",
33+
"token_prefix": "oidc/backend"
34+
},
35+
...
36+
}
37+
```
38+
Each backend configuration can include the following fields:
39+
40+
- `client_credentials`: The client credentials for authenticating with the openEO backend. This is required if the `OPENEO_AUTH_METHOD` is set to `CLIENT_CREDENTIALS`. It is a single string in the format `oidc_provider/client_id/client_secret` that should be split into its components when used.
41+
- `token_provider`: The provider refers to the OIDC IDP alias that needs to be used to exchange the incoming token to an external token. This is required if the `OPENEO_AUTH_METHOD` is set to `USER_CREDENTIALS`. For example, if you have a Keycloak setup with an IDP alias `openeo-idp`, you would set this field to `openeo-idp`. This means that when a user authenticates with their token, the Dispatcher will use the `openeo-idp` to exchange the user's token for a token that is valid for the openEO backend.
42+
- `token_prefix`: An optional prefix to be added to the token when authenticating (e.g., "CDSE"). The prefix is required by some backends to identify the token type. This will be prepended to the exchanged token when authenticating with the openEO backend.
43+
44+
## Example Configuration
45+
Here is an example of setting the environment variables in a `.env` file:
46+
47+
```env
48+
# General Settings
49+
APP_NAME="APEx Dispatch API"
50+
APP_DESCRIPTION="APEx Dispatch Service API to run jobs and upscaling tasks"
51+
APP_ENV=development
52+
53+
CORS_ALLOWED_ORIGINS=http://localhost:5173
54+
55+
# Database Settings
56+
DATABASE_URL=sqlite:///:memory:
57+
58+
# Keycloak Settings
59+
KEYCLOAK_HOST=localhost
60+
KEYCLOAK_REALM=apex
61+
KEYCLOAK_CLIENT_ID=apex-client-id
62+
KEYCLOAK_CLIENT_SECRET=apex-client-secret
63+
64+
65+
# openEO Settings
66+
OPENEO_AUTH_METHOD=USER_CREDENTIALS
67+
OPENEO_BACKENDS='{"https://openeo.backend1.com" {"client_credentials": "oidc_provider/client_id/secret_secret", "token_provider": "backend", "token_prefix": "oidc/backend"}}'
68+
```

0 commit comments

Comments
 (0)