Use Keycloak Spring Adapter with Spring Boot 3

I updated to Spring Boot 3 in a project that uses the Keycloak Spring Adapter. Unfortunately, it doesn’t start because the KeycloakWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter which was first deprecated in Spring Security and then removed. Is there currently another way to implement security with Keycloak? Or to put it in other words: How can I use Spring Boot 3 in combination with the Keycloak adapter?

I searched the Internet, but couldn’t find any other version of the adapter.

1

You can’t use Keycloak adapters with spring-boot 3 for the reason you found, plus a few others related to transitive dependencies. As most Keycloak adapters were deprecated in early 2022, it is very likely that no update will be published to fix that.

Instead, use spring-security 6 libs for OAuth2. Don’t panic, it’s an easy task with spring-boot.

In the following, I’ll consider you have a good understanding of OAuth2 concepts and know exactly why you need to configure an OAuth2 client with oauth2Login (using authorization code flow, request authorization based on a session) or an OAuth2 resource server (no session, request authorization based on a Bearer token). In case of doubt, please refer to the OAuth2 essentials section of my tutorials.

I’ll only detail here the configuration of servlet application as a resource server, and then as a client, for a single Keycloak realm, with and then without spring-addons-starter-oidc, a Spring Boot starter of mine. Browse directly to the section you are interested in (but be prepared to write much more code if you don’t want to use “my” starter).

Also refer to my tutorials for different use-cases like:

  • accepting tokens issued by multiple realms or instances (known in advance or dynamically created in a trusted domain)
  • reactive applications (webflux), like spring-cloud-gateway for instance
  • apps publicly serving both a REST API and a server-side rendered UI to consume it
  • advanced access-control rules
  • BFF pattern

1. OAuth2 Resource Server

App exposes a REST API secured with access tokens. It is consumed by an OAuth2 REST client. A few sample of such clients:

  • another Spring application configured as an OAuth2 client and using RestClient, WebClient, @FeignClient, RestTemplate or alike to query the resource server
  • a Backend For Frontend (BFF) like a spring-cloud-gateway instance configured with oauth2Login() and the TokenRelay filter
  • development tools like Postman capable of fetching OAuth2 tokens and issuing REST requests
  • Javascript based application configured as a “public” OAuth2 client with a library like angular-auth-oidc-client (but warning, this is now discouraged in favor of the OAuth2 BFF pattern)

1.1. With spring-addons-starter-oidc

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
    <version>7.8.8</version>
</dependency>
origins: http://localhost:4200
issuer: http://localhost:8442/realms/master

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${issuer}
          username-claim: preferred_username
          authorities:
          - path: $.realm_access.roles
            prefix: ROLE_
          - path: $.resource_access.*.roles
        resourceserver:
          cors:
          - path: /my-resources/**
            allowed-origin-patterns: ${origins}
          permit-all: 
          - "/actuator/health/readiness"
          - "/actuator/health/liveness"
          - "/v3/api-docs/**"

Prefix for realm roles in the conf above are there only for illustration purposes, you might remove it. The CORS configuration would need some refinements too.

@Configuration
@EnableMethodSecurity
public static class WebSecurityConfig { }

Nothing more is needed to configure a resource-server with fine tuned CORS policy and authorities mapping. Bootiful, isn’t it?.

As you can guess from the ops property being an array, this solution is actually compatible with “static” multi-tenancy: you can declare as many trusted issuers as you need and it can be heterogeneous (use different claims for username and authorities).

Also, this solution is compatible with reactive application: spring-addons-starter-oidc will detect it from what is on the classpath and adapt its security auto-configuration.

1.2. With just spring-boot-starter-oauth2-resource-server

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <!-- used when converting Keycloak roles to Spring authorities -->
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
</dependency>
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8442/realms/master
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public static class WebSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(
            HttpSecurity http,
            Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter,
            @Value("${spring.security.oauth2.resourceserver.jwt.ssuer-uri}") String issuerUri) throws Exception {

        http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));

        // Enable and configure CORS
        http.cors(cors -> cors.configurationSource(corsConfigurationSource("http://localhost:4200")));

        // State-less session (state in access-token only)
        http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // Disable CSRF because of state-less session-management
        http.csrf(csrf -> csrf.disable());

        // Return 401 (unauthorized) instead of 302 (redirect to login) when
        // authorization is missing or invalid
        http.exceptionHandling(eh -> eh.authenticationEntryPoint((request, response, authException) -> {
            response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "OAuth realm="%s"".formatted(issuerUri));
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        }));

        // @formatter:off
        http.authorizeHttpRequests(accessManagement -> accessManagement
            .requestMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated()
        );
        // @formatter:on

        return http.build();
    }

    private UrlBasedCorsConfigurationSource corsConfigurationSource(String... origins) {
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList(origins));
        configuration.setAllowedMethods(List.of("*"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setExposedHeaders(List.of("*"));

        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/my-resources/**", configuration);
        return source;
    }

    @RequiredArgsConstructor
    static class JwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<? extends GrantedAuthority>> {

        @Override
        @SuppressWarnings({ "rawtypes", "unchecked" })
        public Collection<? extends GrantedAuthority> convert(Jwt jwt) {
            return Stream.of("$.realm_access.roles", "$.resource_access.*.roles").flatMap(claimPaths -> {
                Object claim;
                try {
                    claim = JsonPath.read(jwt.getClaims(), claimPaths);
                } catch (PathNotFoundException e) {
                    claim = null;
                }
                if (claim == null) {
                    return Stream.empty();
                }
                if (claim instanceof String claimStr) {
                    return Stream.of(claimStr.split(","));
                }
                if (claim instanceof String[] claimArr) {
                    return Stream.of(claimArr);
                }
                if (Collection.class.isAssignableFrom(claim.getClass())) {
                    final var iter = ((Collection) claim).iterator();
                    if (!iter.hasNext()) {
                        return Stream.empty();
                    }
                    final var firstItem = iter.next();
                    if (firstItem instanceof String) {
                        return (Stream<String>) ((Collection) claim).stream();
                    }
                    if (Collection.class.isAssignableFrom(firstItem.getClass())) {
                        return (Stream<String>) ((Collection) claim).stream().flatMap(colItem -> ((Collection) colItem).stream()).map(String.class::cast);
                    }
                }
                return Stream.empty();
            })
            /* Insert some transformation here if you want to add a prefix like "ROLE_" or force upper-case authorities */
            .map(SimpleGrantedAuthority::new)
            .map(GrantedAuthority.class::cast).toList();
        }
    }

    @Component
    @RequiredArgsConstructor
    static class SpringAddonsJwtAuthenticationConverter implements Converter<Jwt, JwtAuthenticationToken> {

        @Override
        public JwtAuthenticationToken convert(Jwt jwt) {
            final var authorities = new JwtGrantedAuthoritiesConverter().convert(jwt);
            final String username = JsonPath.read(jwt.getClaims(), "preferred_username");
            return new JwtAuthenticationToken(jwt, authorities, username);
        }
    }
}

In addition to being much more verbose than preceding one, this solution is also less flexible:

  • not adapted to multi-tenancy (multiple Keycloak realms or instances)
  • hardcoded allowed origins
  • hardcoded claim names to fetch autorities from
  • hardcoded “permitAll” path matchers

2. OAuth2 Client

App exposes any kind of resources secured with sessions (not access tokens). It is consumed directly by a browser (or any other user agent capable of maintaining a session) without the need of a scripting language or OAuth2 client lib (authorization-code flow, logout and token storage are handled by Spring on the server). Common uses-cases are:

  • applications with server-side rendered UI (with Thymeleaf, JSF, or whatever)
  • spring-cloud-gateway used as Backend For Frontend: configured with oauth2Login and the TokenRelay filter (hides OAuth2 tokens from the browser and replaces session cookie with an access token before forwarding a request to downstream resource server(s)).

2.1. With spring-addons-starter-oidc

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-client</artifactId>
</dependency>
<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
    <version>7.8.5</version>
</dependency>
issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me
client-uri: http://localhost:8080

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: ${issuer}
        registration:
          keycloak-login:
            provider: keycloak
            authorization-grant-type: authorization_code
            client-id: ${client-id}
            client-secret: ${client-secret}
            scope: openid,profile,email,offline_access

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${issuer}
          username-claim: preferred_username
          authorities:
          - path: $.realm_access.roles
          - path: $.resource_access.*.roles
        client:
          client-uri: ${client-uri}
          security-matchers: /**
          permit-all:
          - /
          - /login/**
          - /oauth2/**
          csrf: cookie-accessible-from-js
          post-login-redirect-path: /home
          post-logout-redirect-path: /
@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {
}

As for resource server, this solution works in reactive applications too.

There is also an optional support for multi-tenancy on clients: allow a user to be logged in simultaneously on several OpenID Providers, on which he might have different usernames (subject by default, which is a UUID in Keycloak, and changes with each realm).

2.2. With just spring-boot-starter-oauth2-client

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <!-- used when converting Keycloak roles to Spring authorities -->
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
</dependency>
issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: ${issuer}
        registration:
          keycloak-login:
            provider: keycloak
            authorization-grant-type: authorization_code
            client-id: ${client-id}
            client-secret: ${client-secret}
            scope: openid,profile,email,offline_access
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {

    @Bean
    SecurityFilterChain
            clientSecurityFilterChain(HttpSecurity http, InMemoryClientRegistrationRepository clientRegistrationRepository)
                    throws Exception {
        http.oauth2Login(withDefaults());
        http.logout(logout -> {
            logout.logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository));
        });
        // @formatter:off
        http.authorizeHttpRequests(ex -> ex
                .requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
                .requestMatchers("/nice.html").hasAuthority("NICE")
                .anyRequest().authenticated());
        // @formatter:on
        return http.build();
    }

    @Component
    @RequiredArgsConstructor
    static class GrantedAuthoritiesMapperImpl implements GrantedAuthoritiesMapper {

        @Override
        public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            authorities.forEach(authority -> {
                if (OidcUserAuthority.class.isInstance(authority)) {
                    final var oidcUserAuthority = (OidcUserAuthority) authority;
                    final var issuer = oidcUserAuthority.getIdToken().getClaimAsURL(JwtClaimNames.ISS);
                    mappedAuthorities.addAll(extractAuthorities(oidcUserAuthority.getIdToken().getClaims()));

                } else if (OAuth2UserAuthority.class.isInstance(authority)) {
                    try {
                        final var oauth2UserAuthority = (OAuth2UserAuthority) authority;
                        final var userAttributes = oauth2UserAuthority.getAttributes();
                        final var issuer = new URL(userAttributes.get(JwtClaimNames.ISS).toString());
                        mappedAuthorities.addAll(extractAuthorities(userAttributes));

                    } catch (MalformedURLException e) {
                        throw new RuntimeException(e);
                    }
                }
            });

            return mappedAuthorities;
        };

        @SuppressWarnings({ "rawtypes", "unchecked" })
        private static Collection<GrantedAuthority> extractAuthorities(Map<String, Object> claims) {
            /* See resource server solution above for authorities mapping */
        }
    }
}

3. What is spring-addons-starter-oidc and why using it

This starter is a standard Spring Boot starter with additional application properties used to auto-configure default beans and provide it to Spring Security. It is important to note that the auto-configured @Beans are almost all @ConditionalOnMissingBean which enables you to override it in your conf.

It is open-source and you can change everything it pre-configures for you (refer to the Javadoc, the starter READMEs, or the many samples). You should read the starters source before deciding not to trust it, it is not that big. Start with imports resource, it defines what is loaded by Spring Boot for auto-configuration.

In my opinion (and as demonstrated above), Spring Boot auto-configuration for OAuth2 can be pushed one step further to:

  • make OAuth2 configuration more portable: with a configurable authorities converter, switching from an OIDC provider to another is just a matter of editing properties (Keycloak, Auth0, Cognito, Azure AD, etc.)
  • ease app deployment on different environments: CORS configuration is controlled from properties file
  • reduce drastically the amount of Java code (things get even more complicated if you are in multi-tenancy scenario)
  • support more than just one issuer by default
  • reduce chances of misconfiguration. For instance, it is frequent to see sample configurations with disabled CSRF protection on clients with oauth2Login (which is a major security breach as, in this case, requests authorization is based on sessions, the CSRF attack vector), or wasting resources with sessions on endpoints secured with access tokens

37

Use the standard Spring Security OAuth2 client instead of a specific Keycloak adapter and SecurityFilterChain instead of WebSecurityAdapter.

Something like this:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true, prePostEnabled = true)
class OAuth2SecurityConfig {
    @Bean
    fun customOauth2FilterChain(http: HttpSecurity): SecurityFilterChain {
        log.info("Configure HttpSecurity with OAuth2")
        http {
            oauth2ResourceServer {
                jwt { jwtAuthenticationConverter = CustomBearerJwtAuthenticationConverter() }
            }
            oauth2Login {}

            csrf { disable() }

            authorizeRequests {
                // Kubernetes
                authorize("/readiness", permitAll)
                authorize("/liveness", permitAll)
                authorize("/actuator/health/**", permitAll)
                // ...
                // everything else needs at least a valid login, roles are checked at method level
                authorize(anyRequest, authenticated)
            }
        }
        return http.build()
    }
    // ...
}

And then in application.yml:

spring:
    security:
    oauth2:
        client:
        provider:
            abc:
            issuer-uri: https://keycloak.../auth/realms/foo
        registration:
            abc:
            client-secret: ...
            provider: abc
            client-id: foo
            scope: [ openid, profile, email ]
        resourceserver:
        jwt:
            issuer-uri: https://keycloak.../auth/realms/foo

4

Keycloak adapters are deprecated and there will not be any future updates or fixes as announce by Keycloak Team .

It is recommended to use Spring Security provided OAuth2 and OpenID Connect support.

Using Keycloak adapters is not possible because the KeycloakWebSecurityConfigurerAdapter inherited from the WebSecurityConfigurerAdapter class, which was deprecated in Spring Security and subsequently removed in the newer release.

I have published a detailed article on integrating Keycloak with Spring Boot 3.0 on Medium, which provides a step-by-step guide on how to integrate Keycloak with Spring Boot 3.0.

This guide is particularly helpful for those who are new to integrating Keycloak with Spring Boot 3.0 or migrating to Spring Boot 3.0 from an older version.

https://medium.com/geekculture/using-keycloak-with-spring-boot-3-0-376fa9f60e0b

7

If you are using the above spring boot 3.x version, here are these configurations.

Dependencies

implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.keycloak:keycloak-spring-boot-starter:24.0.3")

Config File Classes

public class GrantedAuthoritiesConverter implements Converter<Jwt,Collection<GrantedAuthority>>{
    @Override
    public Collection<GrantedAuthority> convert(Jwt source) {
        Map<String, Object> realmAccess = source.getClaimAsMap("realm_access");
        if (Objects.nonNull(realmAccess)) {
            List<String> roles = (List<String>) realmAccess.get("roles");
            if (Objects.nonNull(roles)) {
                return roles.stream().map(rn -> new SimpleGrantedAuthority("ROLE_" + rn)).collect(Collectors.toList());
            }
        }
        return List.of();
    }
}
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true)
public class WebSecurityConfig {
    @Bean
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults()).csrf(CsrfConfigurer::disable)
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated());
        http.oauth2ResourceServer(
                oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
        return http.build();
    }
    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(new GrantedAuthoritiesConverter());
        return jwtConverter;
    }
}

YAML

server:
    port: 9090
spring:
    security:
    oauth2:
        client:
        registration:
            keycloak:
            client-id: <add-client-id>
            client-secret: <add-client-secret>
            scope: openid #leave as it is 
            authorization-grant-type: authorization_code #leave as it is 
        provider:
            keycloak:
            issuer-uri: <add-realm issuer url>
            user-name-attribute: preferred_username #leave as it is 
        resourceserver:
        jwt:
            issuer-uri: <add-realm issuer url>
    data:
    mongodb:
        uri: mongodb://localhost:27017/<dbname>
jpa:
    hibernate:
        ddl-auto: update
        show-sql: true
    properties:
        hibernate:
        format_sql: true
logging:
    level:
    org:
        springframework: trace

3

Based on different resources and whole weekend, spent on solving this new issue, I managed to find perfectly working solution.

I defined 2 roles: user and administrator on client level (not realm) and assigned to different users.

  • JDK 17
  • Keycloak 22.0.0.
  • Spring Boot 3.1.1

Here is the working solution for:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class OAuth2ResourceServerSecurityConfiguration {
    @Value("${keycloak.resource}")
    private String keycloakClientName;
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
        .authorizeHttpRequests((authorize) -> {
            authorize
            .anyRequest().authenticated();
        })
        .oauth2ResourceServer(httpSecurityOAuth2ResourceServerConfigurer -> httpSecurityOAuth2ResourceServerConfigurer
            .jwt(jwtConfigurer -> {
                jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter());
            })
        );

        return httpSecurity.build();
    }
    private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());

        return jwtAuthenticationConverter;
    }

    private class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
        private Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            final Map<String, Object> resourceAccess = (Map<String, Object>) jwt.getClaims().get("resource_access");

            if (resourceAccess != null) {
                final Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get(OAuth2ResourceServerSecurityConfiguration.this.keycloakClientName);

                if (clientAccess != null) {
                    grantedAuthorities = ((List<String>) clientAccess.get("roles")).stream()
                            .map(roleName -> "ROLE_" + roleName) // Prefix to map to a Spring Security "role"
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());
                }
            }

            return grantedAuthorities;
        }
    }
}

Keycloak configuration in properties:

keycloak:
  authServerUrl: http://<your_keycloak_host>:8989
  realm: <your_realm>
  resource: <your_client>
  useResourceRoleMappings: true
  cors: true
  corsMaxAge: 1000
  corsAllowedMethods: POST, PUT, DELETE, GET
  sslRequired: none
  bearerOnly: true
  publicClient: true
  principalAttribute: preferred_username
  credentials:
    secret: '{cipher}<your_encrypted_secret>'

And test controller:

@RestController
@RequestMapping("/api/v1/test")
public class TestController {
    @GetMapping("/")
    public String allAccess() {
        return "Public content";
    }

    @GetMapping("/endpoint1")
    @PreAuthorize("hasRole('user')")
    public String endpoint1() {
        return "User board";
    }

    @GetMapping("/endpoint2")
    @PreAuthorize("hasRole('administrator')")
    public String endpoint2() {
        return "Administrator board";
    }
}

1

Keycloak 21.0.0 have introduced some new changes to support Spring Security 6.x.x and Spring Boot 3.x.x.. Here is a reference to that

3

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật