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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ local.properties

/src/main/docker/etc/rest_source_clients_configs.yml
.DS_Store
bin/
__pycache__/
*.pyc
22 changes: 21 additions & 1 deletion authorizer-app-backend/authorizer.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -26,6 +26,26 @@ restSourceClients:
clientId: <CLIENT_ID>
clientSecret: <CLIENT_SECRET>
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: <CLIENT_ID>
clientSecret: <CLIENT_SECRET>
# 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<HuaweiTokenResponse>()
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<HuaweiTokenResponse>()
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,
)
}
4 changes: 2 additions & 2 deletions authorizer-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,7 +12,7 @@

FROM nginxinc/nginx-unprivileged:1.27-alpine3.20-perl

ENV BASE_HREF="/rest-sources/authorizer/" \

Check warning on line 15 in authorizer-app/Dockerfile

View workflow job for this annotation

GitHub Actions / docker (radar-rest-source-authorizer, authorizer-app/Dockerfile, Peyman Mohtashami <peyman@thehyv...

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_PATH") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 15 in authorizer-app/Dockerfile

View workflow job for this annotation

GitHub Actions / docker (radar-rest-source-authorizer, authorizer-app/Dockerfile, Peyman Mohtashami <peyman@thehyv...

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_URI") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 15 in authorizer-app/Dockerfile

View workflow job for this annotation

GitHub Actions / docker (radar-rest-source-authorizer, authorizer-app/Dockerfile, Peyman Mohtashami <peyman@thehyv...

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_CALLBACK_URL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 15 in authorizer-app/Dockerfile

View workflow job for this annotation

GitHub Actions / docker (radar-rest-source-authorizer, authorizer-app/Dockerfile, Peyman Mohtashami <peyman@thehyv...

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_CLIENT_SECRET") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 15 in authorizer-app/Dockerfile

View workflow job for this annotation

GitHub Actions / docker (radar-rest-source-authorizer, authorizer-app/Dockerfile, Peyman Mohtashami <peyman@thehyv...

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_CLIENT_ID") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 15 in authorizer-app/Dockerfile

View workflow job for this annotation

GitHub Actions / docker (radar-rest-source-authorizer, authorizer-app/Dockerfile, Peyman Mohtashami <peyman@thehyv...

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_GRANT_TYPE") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
BACKEND_BASE_URL="http://localhost/rest-sources/backend" \
AUTH_GRANT_TYPE="authorization_code" \
AUTH_CLIENT_ID="radar_rest_sources_authorizer" \
Expand All @@ -23,7 +23,7 @@
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/

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
10 changes: 9 additions & 1 deletion docker/etc/rest-source-authorizer/authorizer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,4 +53,4 @@ restSourceClients:
deregistrationEndpoint: https://apis.garmin.com/wellness-api/rest/user/registration
clientId: Garmin-clientid
clientSecret: Garmin-clientsecret
oauthVersion: OAUTH2
oauthVersion: OAUTH2
Loading
Loading