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
This commit is contained in:
Florian BEZANNIER
2026-05-11 13:50:47 +02:00
committed by GitHub
parent 394048a583
commit 8d83218dc8
7 changed files with 199 additions and 57 deletions

View File

@@ -62,18 +62,93 @@ information on password encoders in Spring Security.
### OpenID Connect ### OpenID Connect
hawkbit supports authentication providers which use the OpenID Connect standard, an authentication layer built on top of HawkBit supports authentication providers which use the OpenID Connect standard, an authentication layer built on top of
the OAuth 2.0 protocol. the OAuth 2.0 protocol. OIDC integration can be enabled on UI and in server:
An example configuration is given below.
- **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 ```properties
spring.security.oauth2.client.registration.oidc.client-id=clientID # Enable hawkBit OIDC UI login
spring.security.oauth2.client.provider.oidc.issuer-uri=https://oidc-provider/issuer-uri hawkbit.server.security.oauth2.client.enabled=true
spring.security.oauth2.client.provider.oidc.jwk-set-uri=https://oidc-provider/jwk-set-uri
# 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/<client id>/roles claim is mapped Some providers (e.g. Auth0) require additional parameters in the authorization request. Use `additional-query-string-params` to pass
to hawkBit permissions. 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 ### Permissions

View File

@@ -10,11 +10,14 @@
package org.eclipse.hawkbit.mgmt.rest.resource; package org.eclipse.hawkbit.mgmt.rest.resource;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import io.swagger.v3.oas.models.info.Info; 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.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server; 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.servers.ServerVariables;
import io.swagger.v3.oas.models.tags.Tag; import io.swagger.v3.oas.models.tags.Tag;
import org.eclipse.hawkbit.rest.OpenApi; import org.eclipse.hawkbit.rest.OpenApi;
import org.eclipse.hawkbit.security.HawkbitSecurityProperties;
import org.springdoc.core.models.GroupedOpenApi; 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.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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 @Configuration
@ConditionalOnProperty(value = OpenApi.HAWKBIT_SERVER_OPENAPI_ENABLED, havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(value = OpenApi.HAWKBIT_SERVER_OPENAPI_ENABLED, havingValue = "true", matchIfMissing = true)
@@ -37,10 +46,37 @@ public class MgmtOpenApiConfiguration {
@Bean @Bean
@ConditionalOnProperty( @ConditionalOnProperty(
value = "hawkbit.server.openapi.mgmt.enabled", value = "hawkbit.server.openapi.mgmt.enabled", havingValue = "true", matchIfMissing = true)
havingValue = "true", public GroupedOpenApi mgmtApi(
matchIfMissing = true) @Value("${hawkbit.server.openapi.mgmt.tenant-endpoint.enabled:false}") final boolean tenantEndpointEnabled,
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<OAuth2ResourceServerConfigurer<HttpSecurity>> oauth2ResourceServerCustomizer,
final HawkbitSecurityProperties hawkbitSecurityProperties
) {
boolean oauth2Enabled = oauth2ResourceServerCustomizer != null;
Map<String, SecurityScheme> 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 // @formatter:off
return GroupedOpenApi return GroupedOpenApi
.builder() .builder()
@@ -62,23 +98,11 @@ public class MgmtOpenApiConfiguration {
.variables(new ServerVariables().addServerVariable("tenant", tenantSeverVariable())), .variables(new ServerVariables().addServerVariable("tenant", tenantSeverVariable())),
new Server().url("/")) new Server().url("/"))
: List.of(new Server().url("/"))) : List.of(new Server().url("/")))
.addSecurityItem(new SecurityRequirement() .addSecurityItem(securityRequirement)
.addList(BASIC_AUTH_SEC_SCHEME_NAME)
.addList(BEARER_AUTH_SEC_SCHEME_NAME))
.components( .components(
openApi openApi
.getComponents() .getComponents()
.addSecuritySchemes(BASIC_AUTH_SEC_SCHEME_NAME, .securitySchemes(securitySchemeMap))
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")))
.tags(sort(openApi.getTags()))) .tags(sort(openApi.getTags())))
.build(); .build();
// @formatter:on // @formatter:on

View File

@@ -68,12 +68,9 @@
<artifactId>spring-security-aspects</artifactId> <artifactId>spring-security-aspects</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency> <scope>compile</scope>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency> </dependency>
<!-- Spring - END --> <!-- Spring - END -->
</dependencies> </dependencies>

View File

@@ -16,6 +16,7 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -165,8 +166,10 @@ public class MgmtSecurityConfiguration {
final String tenantClaim = claim.getTenant(); final String tenantClaim = claim.getTenant();
final String rolesClaim = claim.getRoles(); final String rolesClaim = claim.getRoles();
oauth2ResourceServerConfigurer.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwt -> { oauth2ResourceServerConfigurer.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwt -> {
final String username = followPathInJwtClaims(jwt, usernameClaim, String.class); final String username = Objects.requireNonNull(followPathInJwtClaims(jwt, usernameClaim, String.class));
final String tenant = tenantClaim == null ? "DEFAULT" : followPathInJwtClaims(jwt, tenantClaim, String.class); final String tenant = tenantClaim == null
? "DEFAULT"
: Objects.requireNonNull(followPathInJwtClaims(jwt, tenantClaim, String.class));
final Collection<GrantedAuthority> authorities = Optional final Collection<GrantedAuthority> authorities = Optional
.ofNullable(followPathInJwtClaims(jwt, rolesClaim, Collection.class)) .ofNullable(followPathInJwtClaims(jwt, rolesClaim, Collection.class))
.map(resourceRoles -> ((Collection<String>) resourceRoles).stream() .map(resourceRoles -> ((Collection<String>) resourceRoles).stream()
@@ -189,8 +192,10 @@ public class MgmtSecurityConfiguration {
for (final String chunk : chunks) { for (final String chunk : chunks) {
if (current instanceof Map<?, ?> map) { if (current instanceof Map<?, ?> map) {
current = map.get(chunk); current = map.get(chunk);
} else if (current == null) { if (current == null) {
log.warn("Path {} not found in claim", path);
return null; return null;
}
} else { } else {
log.warn("Unexpected claim type for path {} (chunk {})! Expected a Map but got {}", path, chunk, current.getClass()); log.warn("Unexpected claim type for path {} (chunk {})! Expected a Map but got {}", path, chunk, current.getClass());
return null; return null;

View File

@@ -29,6 +29,7 @@ import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.HawkbitServer; import org.eclipse.hawkbit.sdk.HawkbitServer;
import org.eclipse.hawkbit.sdk.Tenant; import org.eclipse.hawkbit.sdk.Tenant;
import org.eclipse.hawkbit.ui.view.util.Utils; import org.eclipse.hawkbit.ui.view.util.Utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching; 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.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; 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 @Slf4j
@Theme("hawkbit") @Theme("hawkbit")
@@ -56,15 +60,24 @@ public class HawkbitUiApp implements AppShellConfigurator {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String AUTHORIZATION_HEADER = "Authorization";
private static final RequestInterceptor AUTHORIZATION = requestTemplate -> {
private static RequestInterceptor authorizationInterceptor(final OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager) {
return requestTemplate -> {
final Authentication authentication = Objects.requireNonNull( final Authentication authentication = Objects.requireNonNull(
SecurityContextHolder.getContext().getAuthentication(), "No authentication available in security context!"); SecurityContextHolder.getContext().getAuthentication(), "No authentication available in security context!");
final Object principal = Objects.requireNonNull(authentication.getPrincipal(), "User is null!"); if (authentication instanceof OAuth2AuthenticationToken oauth2Token) {
if (principal instanceof OidcUser oidcUser) { // line from /org/springframework/security/oauth2/client/web/client/OAuth2ClientHttpRequestInterceptor.java#authorizeClient
requestTemplate.header( OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(oauth2Token
AUTHORIZATION_HEADER, .getAuthorizedClientRegistrationId()).principal(authentication).build();
"Bearer " + oidcUser.getIdToken().getTokenValue()); OAuth2AuthorizedClient authorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);
if (authorizedClient != null) {
requestTemplate.header(AUTHORIZATION_HEADER, "Bearer " + authorizedClient.getAccessToken().getTokenValue());
} else { } 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 String user = String.valueOf(principal);
final Object pass = Objects.requireNonNull(authentication.getCredentials(), "Password is not available!"); final Object pass = Objects.requireNonNull(authentication.getCredentials(), "Password is not available!");
requestTemplate.header( requestTemplate.header(
@@ -72,6 +85,7 @@ public class HawkbitUiApp implements AppShellConfigurator {
"Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(ISO_8859_1))); "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(ISO_8859_1)));
} }
}; };
}
private static final ErrorDecoder DEFAULT_ERROR_DECODER = new ErrorDecoder.Default(); private static final ErrorDecoder DEFAULT_ERROR_DECODER = new ErrorDecoder.Default();
private static final ErrorDecoder ERROR_DECODER = (methodKey, response) -> { private static final ErrorDecoder ERROR_DECODER = (methodKey, response) -> {
@@ -85,12 +99,14 @@ public class HawkbitUiApp implements AppShellConfigurator {
} }
@Bean @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( return new HawkbitClient(
hawkBitServer, null, null, null, hawkBitServer, null, null, null,
ERROR_DECODER, ERROR_DECODER,
(tenant, controller) -> controller == null (tenant, controller) -> controller == null
? AUTHORIZATION ? authorization
: HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller)); : HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller));
} }

View File

@@ -9,6 +9,8 @@
*/ */
package org.eclipse.hawkbit.ui.security; package org.eclipse.hawkbit.ui.security;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; 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.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; 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 @Configuration
public class Oauth2ClientConfig {
@Bean(name = "hawkbitOAuth2ClientCustomizer")
@ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled") @ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled")
public class Oauth2ClientConfig {
@Bean(name = "hawkbitOAuth2ClientCustomizer")
@ConditionalOnMissingBean(name = "hawkbitOAuth2ClientCustomizer") @ConditionalOnMissingBean(name = "hawkbitOAuth2ClientCustomizer")
Customizer<OAuth2LoginConfigurer<HttpSecurity>> defaultOAuth2ClientCustomizer() { Customizer<OAuth2LoginConfigurer<HttpSecurity>> defaultOAuth2ClientCustomizer() {
return Customizer.withDefaults(); return Customizer.withDefaults();
} }
@Bean
@ConditionalOnMissingBean
public OAuth2AuthorizationRequestResolver authorizationRequestResolver(
ClientRegistrationRepository repo,
OidcClientProperties properties) {
final Map<String, String> 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;
}
} }

View File

@@ -9,6 +9,9 @@
*/ */
package org.eclipse.hawkbit.ui.security; package org.eclipse.hawkbit.ui.security;
import java.util.HashMap;
import java.util.Map;
import lombok.Data; import lombok.Data;
import lombok.ToString; import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -29,6 +32,7 @@ public class OidcClientProperties {
public static class Client { public static class Client {
private boolean enabled = false; private boolean enabled = false;
private Map<String, String> additionalQueryStringParams = new HashMap<>();
} }
} }
} }