diff --git a/.gitignore b/.gitignore index a5c7eea4..59666faf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ local.properties /src/main/docker/etc/rest_source_clients_configs.yml .DS_Store +bin/ +__pycache__/ +*.pyc diff --git a/authorizer-app-backend/authorizer.yml b/authorizer-app-backend/authorizer.yml index ca6d346c..08c4917a 100644 --- a/authorizer-app-backend/authorizer.yml +++ b/authorizer-app-backend/authorizer.yml @@ -1,7 +1,7 @@ service: # Interval time in minutes for syncing projects and subjects. baseUri: http://0.0.0.0:8085/rest-sources/backend/ - advertisedBaseUri: http://example.org/rest-sources/backend/ + advertisedBaseUri: http://localhost:8085/rest-sources/backend/ enableCors: true auth: @@ -26,6 +26,26 @@ restSourceClients: clientId: clientSecret: scope: activity heartrate sleep profile + + - sourceType: Huawei + authorizationEndpoint: https://oauth-login.cloud.huawei.com/oauth2/v2/authorize + tokenEndpoint: https://oauth-login.cloud.huawei.com/oauth2/v3/token + clientId: + clientSecret: + # openid will be prepended automatically by HuaweiAuthorizationService + scope: >- + https://www.huawei.com/healthkit/step.read + https://www.huawei.com/healthkit/calories.read + https://www.huawei.com/healthkit/heartrate.read + https://www.huawei.com/healthkit/sleep.read + https://www.huawei.com/healthkit/activity.read + https://www.huawei.com/healthkit/dailyActivitySummary.read + https://www.huawei.com/healthkit/oxygenSaturation.read + https://www.huawei.com/healthkit/stress.read + https://www.huawei.com/healthkit/bloodGlucose.read + https://www.huawei.com/healthkit/bloodPressure.read + https://www.huawei.com/healthkit/bodyTemperature.read + https://www.huawei.com/healthkit/bodyfat.read # Garmin OAuth2 PKCE configuration. # oauthVersion: OAUTH2 selects the GarminOAuth2AuthorizationService; # OAUTH1 selects the legacy GarminOAuth1AuthorizationService. diff --git a/authorizer-app-backend/src/main/java/org/radarbase/authorizer/enhancer/AuthorizerResourceEnhancer.kt b/authorizer-app-backend/src/main/java/org/radarbase/authorizer/enhancer/AuthorizerResourceEnhancer.kt index 86e7ff9a..357381ea 100644 --- a/authorizer-app-backend/src/main/java/org/radarbase/authorizer/enhancer/AuthorizerResourceEnhancer.kt +++ b/authorizer-app-backend/src/main/java/org/radarbase/authorizer/enhancer/AuthorizerResourceEnhancer.kt @@ -30,10 +30,12 @@ import org.radarbase.authorizer.service.DelegatedRestSourceAuthorizationService import org.radarbase.authorizer.service.DelegatedRestSourceAuthorizationService.Companion.FITBIT_AUTH import org.radarbase.authorizer.service.DelegatedRestSourceAuthorizationService.Companion.GARMIN_AUTH import org.radarbase.authorizer.service.DelegatedRestSourceAuthorizationService.Companion.GOOGLE_AUTH +import org.radarbase.authorizer.service.DelegatedRestSourceAuthorizationService.Companion.HUAWEI_AUTH import org.radarbase.authorizer.service.DelegatedRestSourceAuthorizationService.Companion.OURA_AUTH import org.radarbase.authorizer.service.GarminOAuth2AuthorizationService import org.radarbase.authorizer.service.GarminOauth1AuthorizationService import org.radarbase.authorizer.service.GoogleHealthAuthorizationService +import org.radarbase.authorizer.service.HuaweiAuthorizationService import org.radarbase.authorizer.service.OAuth2RestSourceAuthorizationService import org.radarbase.authorizer.service.OuraAuthorizationService import org.radarbase.authorizer.service.RegistrationService @@ -131,6 +133,9 @@ class AuthorizerResourceEnhancer( .named(OURA_AUTH) .`in`(Singleton::class.java) + bind(HuaweiAuthorizationService::class.java) + .to(RestSourceAuthorizationService::class.java) + .named(HUAWEI_AUTH) bind(GoogleHealthAuthorizationService::class.java) .to(RestSourceAuthorizationService::class.java) .named(GOOGLE_AUTH) diff --git a/authorizer-app-backend/src/main/java/org/radarbase/authorizer/service/DelegatedRestSourceAuthorizationService.kt b/authorizer-app-backend/src/main/java/org/radarbase/authorizer/service/DelegatedRestSourceAuthorizationService.kt index f63d385b..6a3aad54 100644 --- a/authorizer-app-backend/src/main/java/org/radarbase/authorizer/service/DelegatedRestSourceAuthorizationService.kt +++ b/authorizer-app-backend/src/main/java/org/radarbase/authorizer/service/DelegatedRestSourceAuthorizationService.kt @@ -63,6 +63,7 @@ class DelegatedRestSourceAuthorizationService( const val GARMIN_AUTH = "Garmin" const val FITBIT_AUTH = "FitBit" const val OURA_AUTH = "Oura" + const val HUAWEI_AUTH = "Huawei" const val GOOGLE_AUTH = "GoogleHealth" } } diff --git a/authorizer-app-backend/src/main/java/org/radarbase/authorizer/service/HuaweiAuthorizationService.kt b/authorizer-app-backend/src/main/java/org/radarbase/authorizer/service/HuaweiAuthorizationService.kt new file mode 100644 index 00000000..c32a0526 --- /dev/null +++ b/authorizer-app-backend/src/main/java/org/radarbase/authorizer/service/HuaweiAuthorizationService.kt @@ -0,0 +1,173 @@ +package org.radarbase.authorizer.service + +import io.ktor.client.call.body +import io.ktor.client.request.forms.submitForm +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.http.URLBuilder +import io.ktor.http.isSuccess +import io.ktor.http.takeFrom +import jakarta.ws.rs.core.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.radarbase.authorizer.api.RequestTokenPayload +import org.radarbase.authorizer.api.RestOauth2AccessToken +import org.radarbase.authorizer.config.AuthorizerConfig +import org.radarbase.authorizer.doa.entity.RestSourceUser +import org.radarbase.jersey.exception.HttpBadGatewayException +import java.util.Base64 + +/** + * Huawei Health Kit OAuth2 authorization service. + * + * Key differences from standard OAuth2: + * - Token endpoint uses client_id/client_secret as form params, not Basic Auth. + * - redirect_uri is required in the token exchange request. + * - Authorization URL requires access_type=offline and openid must be the first scope. + * - External user ID is extracted from the id_token JWT (sub claim). + * - Callback parameter from Huawei is authorization_code, not code — the frontend + * must map this to the code field of RequestTokenPayload before calling /authorize. + */ +class HuaweiAuthorizationService( + @Context private val clients: RestSourceClientService, + @Context private val config: AuthorizerConfig, +) : OAuth2RestSourceAuthorizationService(clients, config) { + + override suspend fun getAuthorizationEndpointWithParams( + sourceType: String, + userId: Long, + state: String, + ): String { + val authConfig = clients.forSourceType(sourceType) + return URLBuilder().run { + takeFrom(authConfig.authorizationEndpoint) + parameters.append("response_type", "code") + parameters.append("client_id", authConfig.clientId ?: "") + parameters.append("state", state) + // openid must be first; prepend it to whatever scopes are configured + parameters.append("scope", prependOpenId(authConfig.scope)) + parameters.append("access_type", "offline") + parameters.append("redirect_uri", config.service.callbackUrl.toString()) + buildString() + } + } + + override suspend fun requestAccessToken( + payload: RequestTokenPayload, + sourceType: String, + token: String?, + ): RestOauth2AccessToken = withContext(Dispatchers.IO) { + val authConfig = clients.forSourceType(sourceType) + val response = httpClient.submitForm( + url = authConfig.tokenEndpoint, + formParameters = Parameters.build { + append("grant_type", "authorization_code") + payload.code?.let { append("code", it) } + append("client_id", checkNotNull(authConfig.clientId)) + append("client_secret", checkNotNull(authConfig.clientSecret)) + append("redirect_uri", config.service.callbackUrl.toString()) + }, + ) + if (!response.status.isSuccess()) { + throw HttpBadGatewayException( + "Failed to request Huawei access token (HTTP ${response.status}): ${response.bodyAsText()}", + ) + } + val tokenResponse = response.body() + val externalUserId = tokenResponse.idToken?.let { parseSubFromJwt(it) } + RestOauth2AccessToken( + accessToken = tokenResponse.accessToken, + refreshToken = tokenResponse.refreshToken, + expiresIn = tokenResponse.expiresIn, + tokenType = tokenResponse.tokenType, + externalUserId = externalUserId, + ) + } + + override suspend fun refreshToken(user: RestSourceUser): RestOauth2AccessToken? = withContext(Dispatchers.IO) { + val refreshToken = user.refreshToken ?: return@withContext null + val authConfig = clients.forSourceType(user.sourceType) + val response = httpClient.submitForm( + url = authConfig.tokenEndpoint, + formParameters = Parameters.build { + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) + append("client_id", checkNotNull(authConfig.clientId)) + append("client_secret", checkNotNull(authConfig.clientSecret)) + }, + ) + when (response.status) { + HttpStatusCode.OK -> { + val tokenResponse = response.body() + RestOauth2AccessToken( + accessToken = tokenResponse.accessToken, + // Huawei may not return a new refresh token; keep the existing one + refreshToken = tokenResponse.refreshToken ?: refreshToken, + expiresIn = tokenResponse.expiresIn, + tokenType = tokenResponse.tokenType, + externalUserId = user.externalUserId, + ) + } + HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden -> { + logger.error( + "Failed to refresh Huawei token (HTTP {}): {}", + response.status, + response.bodyAsText(), + ) + null + } + else -> throw HttpBadGatewayException( + "Cannot connect to Huawei token endpoint (HTTP ${response.status}): ${response.bodyAsText()}", + ) + } + } + + override suspend fun revokeToken(user: RestSourceUser): Boolean { + // Huawei does not expose a public token revocation endpoint + logger.info("Token revocation not supported for Huawei; marking as revoked locally") + return true + } + + /** Decodes the JWT payload (no signature verification needed) and extracts the sub claim. */ + private fun parseSubFromJwt(idToken: String): String? { + return try { + val payloadBase64 = idToken.split(".").getOrNull(1) ?: return null + // Add padding so Base64 decoder doesn't complain + val padded = payloadBase64 + "=".repeat((4 - payloadBase64.length % 4) % 4) + val decoded = Base64.getUrlDecoder().decode(padded) + val element = Json.parseToJsonElement(String(decoded, Charsets.UTF_8)) + element.jsonObject["sub"]?.jsonPrimitive?.content + } catch (e: Exception) { + logger.warn("Failed to parse sub from Huawei id_token: {}", e.toString()) + null + } + } + + /** Ensures openid is the first scope, then appends the rest. */ + private fun prependOpenId(scope: String?): String { + val trimmed = scope?.trim().orEmpty() + val parts = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() && it != "openid" } + return buildString { + append("openid") + if (parts.isNotEmpty()) { + append(" ") + append(parts.joinToString(" ")) + } + } + } + + @Serializable + private data class HuaweiTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("expires_in") val expiresIn: Int = 3600, + @SerialName("token_type") val tokenType: String? = null, + @SerialName("id_token") val idToken: String? = null, + ) +} diff --git a/authorizer-app/Dockerfile b/authorizer-app/Dockerfile index 07a9d2df..23c7f257 100644 --- a/authorizer-app/Dockerfile +++ b/authorizer-app/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM node:18-alpine as builder +FROM --platform=$BUILDPLATFORM node:18-alpine AS builder RUN mkdir /app WORKDIR /app @@ -23,7 +23,7 @@ ENV BASE_HREF="/rest-sources/authorizer/" \ RADAR_BASE_URL="http://localhost" # add init script -COPY authorizer-app/docker/optimization.conf /etc/nginx/conf.d/ +COPY --chown=101 authorizer-app/docker/optimization.conf /etc/nginx/conf.d/ COPY --chown=101 authorizer-app/docker/default.conf /etc/nginx/conf.d/ COPY authorizer-app/docker/30-env-subst.sh /docker-entrypoint.d/ diff --git a/authorizer-app/src/app/auth/containers/login-page/login-page.component.ts b/authorizer-app/src/app/auth/containers/login-page/login-page.component.ts index 5d3767fe..72811ad3 100644 --- a/authorizer-app/src/app/auth/containers/login-page/login-page.component.ts +++ b/authorizer-app/src/app/auth/containers/login-page/login-page.component.ts @@ -94,7 +94,7 @@ export class LoginPageComponent implements OnInit, OnDestroy { state: Date.now().toString(), audience: this.DEFAULT_AUTH_AUDIENCE, scope: this.DEFAULT_AUTH_SCOPES.join(' '), - redirect_uri: window.location.href.split('?')[0], + redirect_uri: environment.authCallbackUrl, }); window.location.href = `${baseUrl}?${params.toString()}`; diff --git a/authorizer-app/src/app/auth/services/management-portal-auth.service.ts b/authorizer-app/src/app/auth/services/management-portal-auth.service.ts index b765d04e..a059dd5f 100644 --- a/authorizer-app/src/app/auth/services/management-portal-auth.service.ts +++ b/authorizer-app/src/app/auth/services/management-portal-auth.service.ts @@ -89,7 +89,7 @@ export class ManagementPortalAuthService extends AuthService { getAccessTokenRequestParams(authCode: string) { return new HttpParams() .set('grant_type', environment.authorizationGrantType) - .set('redirect_uri', window.location.href.split('?')[0]) + .set('redirect_uri', environment.authCallbackUrl) .set('code', authCode) .set('client_id', environment.appClientId) } diff --git a/authorizer-app/src/app/shared/containers/authorization-complete-page/authorization-complete-page.component.ts b/authorizer-app/src/app/shared/containers/authorization-complete-page/authorization-complete-page.component.ts index 5bcbdd6b..b93ac9dd 100644 --- a/authorizer-app/src/app/shared/containers/authorization-complete-page/authorization-complete-page.component.ts +++ b/authorizer-app/src/app/shared/containers/authorization-complete-page/authorization-complete-page.component.ts @@ -52,7 +52,7 @@ export class AuthorizationCompletePageComponent implements OnInit { private buildAuthorizeRequest(queryParams: any, storedParams: any): any { return { - code: this.getOrDefault(queryParams.code, storedParams.code), + code: this.getOrDefault(queryParams.code ?? queryParams.authorization_code, storedParams.code), oauth_token: this.getOrDefault(queryParams.oauth_token, storedParams.oauth_token), oauth_verifier: this.getOrDefault(queryParams.oauth_verifier, storedParams.oauth_verifier), oauth_token_secret: this.getOrDefault(queryParams.oauth_token_secret, storedParams.oauth_token_secret) diff --git a/docker-compose.yml b/docker-compose.yml index 3b49ccda..103c38a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,20 +30,20 @@ services: - "traefik.http.services.managementportal.loadbalancer.server.port=8080" mp-postgresql: - image: bitnami/postgresql:15 + image: postgres:15 environment: - - POSTGRESQL_USERNAME=radarcns - - POSTGRESQL_PASSWORD=radarcns - - POSTGRESQL_DATABASE=managementportal + - POSTGRES_USER=radarcns + - POSTGRES_PASSWORD=radarcns + - POSTGRES_DB=managementportal labels: - "traefik.enable=false" rest-auth-postgresql: - image: bitnami/postgresql:15 + image: postgres:15 environment: - - POSTGRESQL_USERNAME=radarcns - - POSTGRESQL_PASSWORD=radarcns - - POSTGRESQL_DATABASE=restsourceauthorizer + - POSTGRES_USER=radarcns + - POSTGRES_PASSWORD=radarcns + - POSTGRES_DB=restsourceauthorizer labels: - "traefik.enable=false" diff --git a/docker/etc/rest-source-authorizer/authorizer.yml b/docker/etc/rest-source-authorizer/authorizer.yml index 035103df..6d400293 100644 --- a/docker/etc/rest-source-authorizer/authorizer.yml +++ b/docker/etc/rest-source-authorizer/authorizer.yml @@ -23,6 +23,14 @@ redis: uri: redis://redis:6379 restSourceClients: + - sourceType: Huawei + authorizationEndpoint: https://oauth-login.cloud.huawei.com/oauth2/v2/authorize + tokenEndpoint: https://oauth-login.cloud.huawei.com/oauth2/v3/token + clientId: Huawei-clientid + clientSecret: Huawei-clientsecret + # openid is prepended automatically by HuaweiAuthorizationService + scope: >- + https://www.huawei.com/healthkit/step.read https://www.huawei.com/healthkit/calories.read https://www.huawei.com/healthkit/heartrate.read https://www.huawei.com/healthkit/sleep.read https://www.huawei.com/healthkit/activity.read https://www.huawei.com/healthkit/dailyActivitySummary.read https://www.huawei.com/healthkit/oxygenSaturation.read https://www.huawei.com/healthkit/stress.read https://www.huawei.com/healthkit/bloodGlucose.read https://www.huawei.com/healthkit/bloodPressure.read https://www.huawei.com/healthkit/bodyTemperature.read https://www.huawei.com/healthkit/bodyfat.read - sourceType: FitBit authorizationEndpoint: https://www.fitbit.com/oauth2/authorize tokenEndpoint: https://api.fitbit.com/oauth2/token @@ -45,4 +53,4 @@ restSourceClients: deregistrationEndpoint: https://apis.garmin.com/wellness-api/rest/user/registration clientId: Garmin-clientid clientSecret: Garmin-clientsecret - oauthVersion: OAUTH2 \ No newline at end of file + oauthVersion: OAUTH2 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..ba029052 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,186 @@ +# RADAR-Rest-Source-Auth — Architecture + +## Overview + +Kotlin/JAX-RS via Jersey backend that manages OAuth consent and token lifecycle for third-party wearable/health APIs. Stores tokens in PostgreSQL; uses Redis for distributed locking during concurrent refresh. + +--- + +## Directory Structure + +``` +RADAR-Rest-Source-Auth/ +├── authorizer-app-backend/ # Main application module +│ ├── src/main/java/org/radarbase/authorizer/ +│ │ ├── Main.kt # Entry point — loads config, starts Grizzly/Jersey +│ │ ├── api/ # Request/response DTOs +│ │ ├── config/ # Configuration data classes +│ │ ├── doa/ # Hibernate repositories + JPA entities +│ │ ├── enhancer/ # HK2 DI binders and Jersey setup +│ │ ├── lifecycle/ # Startup/shutdown hooks (registration cleanup) +│ │ ├── resources/ # JAX-RS REST endpoints (controllers) +│ │ ├── service/ # Business logic and auth implementations +│ │ └── util/ # HMAC-256, OAuth1 signing utilities +│ ├── src/main/resources/db/ # Liquibase migrations +│ └── authorizer.yml # Runtime configuration template +├── buildSrc/ # Custom Gradle plugins +├── docker/ # Docker configs and compose files +└── docs/ # Architecture and reference docs +``` + +--- + +## Key Packages + +| Package | Purpose | +|---------|---------| +| `api` | DTOs: `RestOauth2AccessToken`, `RequestTokenPayload`, `RestSourceUserDTO`, etc. | +| `config` | `AuthorizerConfig` (root), `AuthorizerServiceConfig`, `RestSourceClient`, `RedisConfig` | +| `doa` | Repositories (`RestSourceUserRepository`, `RegistrationRepository`) and entities | +| `doa.entity` | `RestSourceUser`, `RegistrationState` — JPA entities backed by PostgreSQL | +| `enhancer` | `AuthorizerResourceEnhancer` — HK2 bindings for all services | +| `resources` | `RegistrationResource`, `RestSourceUserResource`, `SourceClientResource`, `ProjectResource` | +| `service` | Authorization services (OAuth2/OAuth1/Huawei/Garmin/Oura), user and client services | + +--- + +## REST API + +### `/registrations` — OAuth flow initiation + +| Method | Path | Action | +|--------|------|--------| +| POST | `/registrations` | Create ephemeral state token for a user | +| GET | `/registrations/{token}` | Fetch registration details | +| POST | `/registrations/{token}` | Get OAuth authorize URL (validates HMAC secret) | +| POST | `/registrations/{token}/authorize` | Exchange auth code for tokens | +| DELETE | `/registrations/{token}` | Cancel registration | + +### `/users` — User account management + +| Method | Path | Action | +|--------|------|--------| +| GET/POST | `/users` | List / create users | +| GET/POST/DELETE | `/users/{id}` | Get / update / delete user | +| POST | `/users/{id}/reset` | Reset authorization | +| GET/POST | `/users/{id}/token` | Check / refresh token | + +### `/source-clients` — OAuth client configuration + +| Method | Path | Action | +|--------|------|--------| +| GET | `/source-clients` | List all configured sources | +| GET | `/source-clients/{type}` | Get config for a source type | +| POST | `/source-clients/{type}/deregister` | Webhook for provider-initiated deregistration | + +--- + +## Authorization Service Architecture + +All authorization implementations share the `RestSourceAuthorizationService` interface. `DelegatedRestSourceAuthorizationService` routes calls to the named implementation by sourceType. + +``` +RestSourceAuthorizationService (interface) +├── OAuth2RestSourceAuthorizationService → FitBit (default OAuth2 + Basic Auth) +│ ├── OuraAuthorizationService → Oura (+ custom user ID fetch, custom revoke) +│ └── HuaweiAuthorizationService → Huawei (+ form-param auth, JWT id_token parsing) +└── OAuth1RestSourceAuthorizationService + └── GarminSourceAuthorizationService → Garmin (+ user ID API call, deregistration scheduler) +``` + +### Adding a New Source + +1. Create `XyzAuthorizationService` extending the appropriate base. +2. Add `const val XYZ_AUTH = "Xyz"` to `DelegatedRestSourceAuthorizationService.Companion`. +3. Bind in `AuthorizerResourceEnhancer.enhance()` with `.named(XYZ_AUTH)`. +4. Add the source client block to `authorizer.yml`. + +--- + +## Database + +### Tables + +**`rest_source_user`** — One row per authorized user per source type. + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `project_id`, `user_id` | varchar | RADAR identifiers | +| `source_id` | UUID | Kafka record key (unique) | +| `source_type` | varchar | "FitBit", "Garmin", "Oura", "Huawei", … | +| `external_user_id` | varchar | Provider's user ID | +| `authorized` | boolean | Current auth status | +| `access_token` | varchar(2000) | | +| `refresh_token` | varchar(2000) | | +| `expires_at` | timestamp | Computed from `expires_in` | +| `start_date`, `end_date` | timestamp | Data collection window | +| `version`, `times_reset` | int | Reset tracking | + +**`registration`** — Short-lived state tokens for the OAuth flow. + +| Column | Type | Notes | +|--------|------|-------| +| `token` | varchar PK | State param in OAuth URL | +| `user_id` | FK → rest_source_user | | +| `salt`, `secret_hash` | bytea | HMAC-256 for persistent tokens | +| `created_at`, `expires_at` | timestamp | TTL | +| `persistent` | boolean | Long-lived vs ephemeral | + +Migrations managed by Liquibase under `src/main/resources/db/changelog/`. + +--- + +## Configuration + +`authorizer.yml` (parsed into `AuthorizerConfig`): + +```yaml +service: + baseUri: http://0.0.0.0:8085/rest-sources/backend/ + advertisedBaseUri: http://example.org/rest-sources/backend/ + # callbackUrl derived from advertisedBaseUri or frontendBaseUri + +auth: + managementPortalUrl: https://... + clientId: radar_rest_sources_auth + clientSecret: + +database: + driver: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/managementportal + user: radar + password: radar_test + +redis: + uri: redis://localhost:6379 + lockPrefix: radar-rest-sources-backend/lock + +restSourceClients: + - sourceType: FitBit + ... +``` + +Secrets can be overridden via env vars: `{SOURCETYPE}_CLIENT_ID`, `{SOURCETYPE}_CLIENT_SECRET`. + +--- + +## Dependency Injection + +Framework: HK2 (Jersey's DI). All bindings in `AuthorizerResourceEnhancer.enhance()`. + +- Services bound as singletons. +- `DelegatedRestSourceAuthorizationService` receives an `IterableProvider` and dispatches by the HK2 named binding that matches the sourceType string. + +--- + +## Key Dependencies + +| Library | Purpose | +|---------|---------| +| radar-jersey | JAX-RS + Jersey + Hibernate integration | +| ktor-client | Async HTTP for token exchange calls | +| kotlinx.serialization | JSON (de)serialization for API responses | +| postgresql / Hibernate | ORM + DB | +| Jedis | Redis client for distributed token-refresh locking | +| Liquibase | DB migrations |