From 8d83218dc832a3a6bb37fbe4170648eb62e21211 Mon Sep 17 00:00:00 2001 From: Florian BEZANNIER <48728684+flobz@users.noreply.github.com> Date: Mon, 11 May 2026 13:50:47 +0200 Subject: [PATCH] Improve oauth2 (#3014) * feat: add custom header to oauth2 req * fix: current.getClass() raise NPE * fix: use access token instead of id token * fix: missing dependency * feat: add oauth2 login from swagger-ui * docs: update oauth2 configuration --- docs/authorization.md | 91 +++++++++++++++++-- .../resource/MgmtOpenApiConfiguration.java | 60 ++++++++---- hawkbit-mgmt/hawkbit-mgmt-starter/pom.xml | 9 +- .../mgmt/MgmtSecurityConfiguration.java | 13 ++- .../org/eclipse/hawkbit/ui/HawkbitUiApp.java | 54 +++++++---- .../ui/security/Oauth2ClientConfig.java | 23 ++++- .../ui/security/OidcClientProperties.java | 6 +- 7 files changed, 199 insertions(+), 57 deletions(-) diff --git a/docs/authorization.md b/docs/authorization.md index be0cf441e..15b899eb7 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -62,18 +62,93 @@ information on password encoders in Spring Security. ### OpenID Connect -hawkbit supports authentication providers which use the OpenID Connect standard, an authentication layer built on top of -the OAuth 2.0 protocol. -An example configuration is given below. +HawkBit supports authentication providers which use the OpenID Connect standard, an authentication layer built on top of +the OAuth 2.0 protocol. OIDC integration can be enabled on UI and in server: + +- **Hawkbit UI** — redirects users to an OIDC provider for authentication +- **Hawkbit Management Server** — validates JWT bearer tokens on Management API requests + +#### Hawkbit UI + +Enable OIDC login for the UI and register the provider using standard Spring Boot OAuth2 client properties: ```properties -spring.security.oauth2.client.registration.oidc.client-id=clientID -spring.security.oauth2.client.provider.oidc.issuer-uri=https://oidc-provider/issuer-uri -spring.security.oauth2.client.provider.oidc.jwk-set-uri=https://oidc-provider/jwk-set-uri +# Enable hawkBit OIDC UI login +hawkbit.server.security.oauth2.client.enabled=true + +# Register the provider (replace "myidp" with any name) +spring.security.oauth2.client.provider.myidp.issuer-uri=https://idp.example.com/ +spring.security.oauth2.client.registration.myidp.client-id=my-client-id +spring.security.oauth2.client.registration.myidp.client-secret=my-client-secret +spring.security.oauth2.client.registration.myidp.scope=openid,email,profile +spring.security.oauth2.client.registration.myidp.provider=myidp ``` -Note: at the moment only DEFAULT tenant is supported. By default the resource_access//roles claim is mapped -to hawkBit permissions. +Some providers (e.g. Auth0) require additional parameters in the authorization request. Use `additional-query-string-params` to pass +them: + +```properties +# Add extra parameters to the OAuth2 authorization request +hawkbit.server.security.oauth2.client.additional-query-string-params.audience=https://my-api-audience +``` + +Spring Security automatically sets the `redirect_uri` for the authorization code callback. It constructs the value as: + +``` +{baseUrl}/login/oauth2/code/myidp +``` + +This URL must be registered as an allowed redirect URI in your identity provider (IdP). + +#### Hawkbit Management Server + +Enable JWT bearer token validation for the Management REST API: + +```properties +# Enable hawkBit default JWT resource server +hawkbit.server.security.oauth2.resourceserver.enabled=true + +# JWK issuer URI for token signature verification +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://idp.example.com/ +``` + +hawkBit maps JWT claims to the username, tenant, and permissions of the authenticated user. The claim paths are +configurable and support dot-notation for nested claims (e.g. `resource_access.my-client.roles`): + +```properties +# Claim path for the hawkBit username (default: preferred_username) +hawkbit.server.security.oauth2.resourceserver.jwt.claim.username=preferred_username + +# Claim path for hawkBit roles/permissions (default: roles) +hawkbit.server.security.oauth2.resourceserver.jwt.claim.roles=roles + +# Claim path for the hawkBit tenant (default: DEFAULT) +hawkbit.server.security.oauth2.resourceserver.jwt.claim.tenant=tenant +``` + +To allow HTTP Basic authentication alongside OAuth2: + +```properties +hawkbit.server.security.allow-http-basic-on-o-auth-enabled=true +``` + +#### Swagger UI + +To enable OAuth2 authorization from Swagger UI, configure the authorization and token endpoints: + +```properties +springdoc.oauth-flow.authorizationUrl=https://idp.example.com/oauth2/authorize +springdoc.oauth-flow.tokenUrl=https://idp.example.com/oauth2/token +``` + +Swagger UI automatically sets the `redirect_uri` when initiating the authorization flow. It constructs the value as: + +``` +{window.location.origin}{base-path}/swagger-ui/oauth2-redirect.html +``` + +This URL must be registered as an allowed redirect URI in your identity provider (IdP) configuration. + ### Permissions diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtOpenApiConfiguration.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtOpenApiConfiguration.java index 50f0bd576..29b3d0166 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtOpenApiConfiguration.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtOpenApiConfiguration.java @@ -10,11 +10,14 @@ package org.eclipse.hawkbit.mgmt.rest.resource; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.OAuthFlow; +import io.swagger.v3.oas.models.security.OAuthFlows; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; @@ -22,11 +25,17 @@ import io.swagger.v3.oas.models.servers.ServerVariable; import io.swagger.v3.oas.models.servers.ServerVariables; import io.swagger.v3.oas.models.tags.Tag; import org.eclipse.hawkbit.rest.OpenApi; +import org.eclipse.hawkbit.security.HawkbitSecurityProperties; import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; @Configuration @ConditionalOnProperty(value = OpenApi.HAWKBIT_SERVER_OPENAPI_ENABLED, havingValue = "true", matchIfMissing = true) @@ -37,10 +46,37 @@ public class MgmtOpenApiConfiguration { @Bean @ConditionalOnProperty( - value = "hawkbit.server.openapi.mgmt.enabled", - havingValue = "true", - matchIfMissing = true) - public GroupedOpenApi mgmtApi(@Value("${hawkbit.server.openapi.mgmt.tenant-endpoint.enabled:false}") final boolean tenantEndpointEnabled) { + value = "hawkbit.server.openapi.mgmt.enabled", havingValue = "true", matchIfMissing = true) + public GroupedOpenApi mgmtApi( + @Value("${hawkbit.server.openapi.mgmt.tenant-endpoint.enabled:false}") final boolean tenantEndpointEnabled, + @Value("${springdoc.oauth-flow.authorizationUrl:}") final String authorizationUrl, + @Value("${springdoc.oauth-flow.tokenUrl:}") final String tokenUrl, + @Autowired(required = false) @Qualifier("hawkbitOAuth2ResourceServerCustomizer") final Customizer> oauth2ResourceServerCustomizer, + final HawkbitSecurityProperties hawkbitSecurityProperties + ) { + boolean oauth2Enabled = oauth2ResourceServerCustomizer != null; + Map securitySchemeMap = new HashMap<>(); + final SecurityRequirement securityRequirement = new SecurityRequirement(); + if (!oauth2Enabled || hawkbitSecurityProperties.isAllowHttpBasicOnOAuthEnabled()) { + securityRequirement.addList(BASIC_AUTH_SEC_SCHEME_NAME); + securitySchemeMap.put(BASIC_AUTH_SEC_SCHEME_NAME, + new SecurityScheme() + .description(BASIC_AUTH_SEC_SCHEME_NAME + " Authentication") + .type(SecurityScheme.Type.HTTP) + .scheme("basic")); + } + if (oauth2Enabled) { + securityRequirement.addList(BEARER_AUTH_SEC_SCHEME_NAME); + securitySchemeMap.put(BEARER_AUTH_SEC_SCHEME_NAME, + new SecurityScheme() + .description(BEARER_AUTH_SEC_SCHEME_NAME + " Authentication") + .type(SecurityScheme.Type.OAUTH2) + .flows(new OAuthFlows() + .authorizationCode(new OAuthFlow().authorizationUrl(authorizationUrl).tokenUrl(tokenUrl)) + .clientCredentials(new OAuthFlow().tokenUrl(tokenUrl))) + .bearerFormat("JWT") + .scheme("bearer")); + } // @formatter:off return GroupedOpenApi .builder() @@ -62,23 +98,11 @@ public class MgmtOpenApiConfiguration { .variables(new ServerVariables().addServerVariable("tenant", tenantSeverVariable())), new Server().url("/")) : List.of(new Server().url("/"))) - .addSecurityItem(new SecurityRequirement() - .addList(BASIC_AUTH_SEC_SCHEME_NAME) - .addList(BEARER_AUTH_SEC_SCHEME_NAME)) + .addSecurityItem(securityRequirement) .components( openApi .getComponents() - .addSecuritySchemes(BASIC_AUTH_SEC_SCHEME_NAME, - new SecurityScheme() - .description(BASIC_AUTH_SEC_SCHEME_NAME + " Authentication") - .type(SecurityScheme.Type.HTTP) - .scheme("basic")) - .addSecuritySchemes(BEARER_AUTH_SEC_SCHEME_NAME, - new SecurityScheme() - .description(BEARER_AUTH_SEC_SCHEME_NAME + " Authentication") - .type(SecurityScheme.Type.HTTP) - .bearerFormat("JWT") - .scheme("bearer"))) + .securitySchemes(securitySchemeMap)) .tags(sort(openApi.getTags()))) .build(); // @formatter:on diff --git a/hawkbit-mgmt/hawkbit-mgmt-starter/pom.xml b/hawkbit-mgmt/hawkbit-mgmt-starter/pom.xml index 37f6f60c2..725fd20b6 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-starter/pom.xml +++ b/hawkbit-mgmt/hawkbit-mgmt-starter/pom.xml @@ -68,12 +68,9 @@ spring-security-aspects - org.springframework.security - spring-security-oauth2-resource-server - - - org.springframework.security - spring-security-oauth2-jose + org.springframework.boot + spring-boot-starter-oauth2-resource-server + compile diff --git a/hawkbit-mgmt/hawkbit-mgmt-starter/src/main/java/org/eclipse/hawkbit/autoconfigure/mgmt/MgmtSecurityConfiguration.java b/hawkbit-mgmt/hawkbit-mgmt-starter/src/main/java/org/eclipse/hawkbit/autoconfigure/mgmt/MgmtSecurityConfiguration.java index d3251a47e..a2d3e35a1 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-starter/src/main/java/org/eclipse/hawkbit/autoconfigure/mgmt/MgmtSecurityConfiguration.java +++ b/hawkbit-mgmt/hawkbit-mgmt-starter/src/main/java/org/eclipse/hawkbit/autoconfigure/mgmt/MgmtSecurityConfiguration.java @@ -16,6 +16,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import lombok.EqualsAndHashCode; @@ -165,8 +166,10 @@ public class MgmtSecurityConfiguration { final String tenantClaim = claim.getTenant(); final String rolesClaim = claim.getRoles(); oauth2ResourceServerConfigurer.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwt -> { - final String username = followPathInJwtClaims(jwt, usernameClaim, String.class); - final String tenant = tenantClaim == null ? "DEFAULT" : followPathInJwtClaims(jwt, tenantClaim, String.class); + final String username = Objects.requireNonNull(followPathInJwtClaims(jwt, usernameClaim, String.class)); + final String tenant = tenantClaim == null + ? "DEFAULT" + : Objects.requireNonNull(followPathInJwtClaims(jwt, tenantClaim, String.class)); final Collection authorities = Optional .ofNullable(followPathInJwtClaims(jwt, rolesClaim, Collection.class)) .map(resourceRoles -> ((Collection) resourceRoles).stream() @@ -189,8 +192,10 @@ public class MgmtSecurityConfiguration { for (final String chunk : chunks) { if (current instanceof Map map) { current = map.get(chunk); - } else if (current == null) { - return null; + if (current == null) { + log.warn("Path {} not found in claim", path); + return null; + } } else { log.warn("Unexpected claim type for path {} (chunk {})! Expected a Map but got {}", path, chunk, current.getClass()); return null; diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/HawkbitUiApp.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/HawkbitUiApp.java index 9cd37767e..8c5742203 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/HawkbitUiApp.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/HawkbitUiApp.java @@ -29,6 +29,7 @@ import org.eclipse.hawkbit.sdk.HawkbitClient; import org.eclipse.hawkbit.sdk.HawkbitServer; import org.eclipse.hawkbit.sdk.Tenant; import org.eclipse.hawkbit.ui.view.util.Utils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; @@ -41,7 +42,10 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @Slf4j @Theme("hawkbit") @@ -56,22 +60,32 @@ public class HawkbitUiApp implements AppShellConfigurator { private static final long serialVersionUID = 1L; private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final RequestInterceptor AUTHORIZATION = requestTemplate -> { - final Authentication authentication = Objects.requireNonNull( - SecurityContextHolder.getContext().getAuthentication(), "No authentication available in security context!"); - final Object principal = Objects.requireNonNull(authentication.getPrincipal(), "User is null!"); - if (principal instanceof OidcUser oidcUser) { - requestTemplate.header( - AUTHORIZATION_HEADER, - "Bearer " + oidcUser.getIdToken().getTokenValue()); - } else { - final String user = String.valueOf(principal); - final Object pass = Objects.requireNonNull(authentication.getCredentials(), "Password is not available!"); - requestTemplate.header( - AUTHORIZATION_HEADER, - "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(ISO_8859_1))); - } - }; + + private static RequestInterceptor authorizationInterceptor(final OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager) { + return requestTemplate -> { + final Authentication authentication = Objects.requireNonNull( + SecurityContextHolder.getContext().getAuthentication(), "No authentication available in security context!"); + if (authentication instanceof OAuth2AuthenticationToken oauth2Token) { + // line from /org/springframework/security/oauth2/client/web/client/OAuth2ClientHttpRequestInterceptor.java#authorizeClient + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(oauth2Token + .getAuthorizedClientRegistrationId()).principal(authentication).build(); + OAuth2AuthorizedClient authorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest); + if (authorizedClient != null) { + requestTemplate.header(AUTHORIZATION_HEADER, "Bearer " + authorizedClient.getAccessToken().getTokenValue()); + } else { + log.warn("No authorized client found for principal {} — request will be sent without Authorization header", oauth2Token + .getName()); + } + } else { + final Object principal = Objects.requireNonNull(authentication.getPrincipal(), "User is null!"); + final String user = String.valueOf(principal); + final Object pass = Objects.requireNonNull(authentication.getCredentials(), "Password is not available!"); + requestTemplate.header( + AUTHORIZATION_HEADER, + "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(ISO_8859_1))); + } + }; + } private static final ErrorDecoder DEFAULT_ERROR_DECODER = new ErrorDecoder.Default(); private static final ErrorDecoder ERROR_DECODER = (methodKey, response) -> { @@ -85,12 +99,14 @@ public class HawkbitUiApp implements AppShellConfigurator { } @Bean - HawkbitClient hawkbitClient(final HawkbitServer hawkBitServer) { + HawkbitClient hawkbitClient(final HawkbitServer hawkBitServer, + @Autowired(required = false) final OAuth2AuthorizedClientManager authorizedClientManager) { + final RequestInterceptor authorization = authorizationInterceptor(authorizedClientManager); return new HawkbitClient( hawkBitServer, null, null, null, ERROR_DECODER, (tenant, controller) -> controller == null - ? AUTHORIZATION + ? authorization : HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller)); } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/Oauth2ClientConfig.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/Oauth2ClientConfig.java index efd31c36c..2b0ea8526 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/Oauth2ClientConfig.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/Oauth2ClientConfig.java @@ -9,6 +9,8 @@ */ package org.eclipse.hawkbit.ui.security; +import java.util.Map; + import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -16,13 +18,32 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; @Configuration +@ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled") public class Oauth2ClientConfig { + @Bean(name = "hawkbitOAuth2ClientCustomizer") - @ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled") @ConditionalOnMissingBean(name = "hawkbitOAuth2ClientCustomizer") Customizer> defaultOAuth2ClientCustomizer() { return Customizer.withDefaults(); } + + @Bean + @ConditionalOnMissingBean + public OAuth2AuthorizationRequestResolver authorizationRequestResolver( + ClientRegistrationRepository repo, + OidcClientProperties properties) { + + final Map additionalQueryStringParams = properties.getOauth2().getClient().getAdditionalQueryStringParams(); + final DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, + OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI); + resolver.setAuthorizationRequestCustomizer( + customizer -> customizer.additionalParameters(params -> params.putAll(additionalQueryStringParams))); + return resolver; + } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/OidcClientProperties.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/OidcClientProperties.java index 9c9981245..f640c6f69 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/OidcClientProperties.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/security/OidcClientProperties.java @@ -9,6 +9,9 @@ */ package org.eclipse.hawkbit.ui.security; +import java.util.HashMap; +import java.util.Map; + import lombok.Data; import lombok.ToString; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -29,6 +32,7 @@ public class OidcClientProperties { public static class Client { private boolean enabled = false; + private Map additionalQueryStringParams = new HashMap<>(); } } -} \ No newline at end of file +}