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
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/<client id>/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

View File

@@ -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<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
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

View File

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

View File

@@ -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<GrantedAuthority> authorities = Optional
.ofNullable(followPathInJwtClaims(jwt, rolesClaim, Collection.class))
.map(resourceRoles -> ((Collection<String>) 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) {
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;

View File

@@ -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,15 +60,24 @@ public class HawkbitUiApp implements AppShellConfigurator {
private static final long serialVersionUID = 1L;
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(
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());
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(
@@ -72,6 +85,7 @@ public class HawkbitUiApp implements AppShellConfigurator {
"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));
}

View File

@@ -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
public class Oauth2ClientConfig {
@Bean(name = "hawkbitOAuth2ClientCustomizer")
@ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled")
public class Oauth2ClientConfig {
@Bean(name = "hawkbitOAuth2ClientCustomizer")
@ConditionalOnMissingBean(name = "hawkbitOAuth2ClientCustomizer")
Customizer<OAuth2LoginConfigurer<HttpSecurity>> defaultOAuth2ClientCustomizer() {
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;
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<String, String> additionalQueryStringParams = new HashMap<>();
}
}
}