How to secure an Angular 8 frontend with Keycloak and a Java Spring Cloud microservice backend with gateway, eureka

Issue

By advance, I appologize for this looong question! In fact, the quesiton is not so long, but I posted a lot of my code pieces, since I don’t really know what is relevant or not to solve my problem…

I’ve been trying to make a simple poc with:
– An Angular 8 frontend
– A Keycloak server for authentication
– A Spring cloud backend architecture:
– a Spring Cloud Gateway secured with Spring Cloud Security
– a Spring Cloud Netflix Eureka server
– a Spring Cloud Configuration server
– some Springboot microservices secured with Spring Security OAuth2
NOT WORKING: I can’t manage to get my Angular app to reach and fetch any data from my protected backend uris. I get a 401 Unauthorized response. And if I breakpoint to the MS Spring secu filter, I just don’t have any token in the HttpServletRequest request

WORKING:
– Authentication with front through Angular
– Angular can fetch data from backend unprotected uris
– Postman tests on protected backend uris with OAuth2 Grant Type set to Resource Owner Password Credential

I followed many tutorials, but I had better results with this one: https://blog.jdriven.com/2019/11/spring-cloud-gateway-with-openid-connect-and-token-relay/

Here are the piece of code I think are relevant:

ANGULAR

I used this OAuth library :
https://www.npmjs.com/package/angular-oauth2-oidc

    • AppModule*
@NgModule({
  declarations: [
    AppComponent,
    BooksComponent,
    HeaderComponent,
    SideNavComponent
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    HttpClientModule,
    AppRoutingModule,
    ReactiveFormsModule,
    OAuthModule.forRoot({
      resourceServer: {
        allowedUrls: ['http://localhost:4200'],
        sendAccessToken: true
      }
    }),
    AuthConfigModule,
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule
  ],
  providers: [
    TheLibraryGuard,
    { provide: HTTP_INTERCEPTORS,
      useClass: DefaultOAuthInterceptor,
      multi: true
    }
  ],
  entryComponents: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule {
}
    • CustomAuthGuard*
@Injectable()
export class CustomAuthGuard implements CanActivate {

  constructor(private oauthService: OAuthService, protected router: Router) {
  }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): any {
    const hasIdToken = this.oauthService.hasValidIdToken();
    const hasAccessToken = this.oauthService.hasValidAccessToken();

    if (this.oauthService.hasValidAccessToken()) {
      return (hasIdToken && hasAccessToken);
    }

    this.router.navigate([this.router.url]);
    return this.oauthService.loadDiscoveryDocumentAndLogin();
  }
}
    • DefaultOAuthInterceptor*
@Injectable()
export class DefaultOAuthInterceptor implements HttpInterceptor {

  constructor(
    private authStorage: OAuthStorage,
    private oauthService: OAuthService,
    private errorHandler: OAuthResourceServerErrorHandler,
    @Optional() private moduleConfig: OAuthModuleConfig
  ) {
  }

  private checkUrl(url: string): boolean {
    const found = this.moduleConfig.resourceServer.allowedUrls.find(u => url.startsWith(u));
    return !!found;
  }

  public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    console.log('INTERCEPTOR');

    const url = req.url.toLowerCase();

    if (!this.moduleConfig) { return next.handle(req); }
    if (!this.moduleConfig.resourceServer) { return next.handle(req); }
    if (!this.moduleConfig.resourceServer.allowedUrls) { return next.handle(req); }
    if (!this.checkUrl(url)) { return next.handle(req); }

    const sendAccessToken = this.moduleConfig.resourceServer.sendAccessToken;

    if (sendAccessToken) {

      // const token = this.authStorage.getItem('access_token');
      const token = this.oauthService.getIdToken();
      const header = 'Bearer ' + token;

      console.log('TOKEN in INTERCEPTOR : ' + token);

      const headers = req.headers
        .set('Authorization', header);

      req = req.clone({ headers });
    }

    return next.handle(req)/*.catch(err => this.errorHandler.handleError(err))*/;

  }
}
    • AuthConfig*
export const authConfig: AuthConfig = {

  issuer: environment.keycloak.issuer,
  redirectUri: environment.keycloak.redirectUri,
  clientId: environment.keycloak.clientId,
  dummyClientSecret: environment.keycloak.dummyClientSecret,
  responseType: environment.keycloak.responseType,
  scope: environment.keycloak.scope,
  requireHttps: environment.keycloak.requireHttps,
  // at_hash is not present in JWT token
  showDebugInformation: environment.keycloak.showDebugInformation,
  disableAtHashCheck: environment.keycloak.disableAtHashCheck
};


export class OAuthModuleConfig {
  resourceServer: OAuthResourceServerConfig = {sendAccessToken: false};
}

export class OAuthResourceServerConfig {
  /**
   * Urls for which calls should be intercepted.
   * If there is an ResourceServerErrorHandler registered, it is used for them.
   * If sendAccessToken is set to true, the access_token is send to them too.
   */
  allowedUrls?: Array<string>;
  sendAccessToken = true;
  customUrlValidation?: (url: string) => boolean;
}
    • AuthConfigService*
@Injectable()
export class AuthConfigService {

  private decodedAccessToken: any;
  private decodedIDToken: any;

  constructor(
    private readonly oauthService: OAuthService,
    private readonly authConfig: AuthConfig
  ) {
  }

  async initAuth(): Promise<any> {
    return new Promise((resolveFn, rejectFn) => {
      // setup oauthService
      this.oauthService.configure(this.authConfig);
      this.oauthService.setStorage(localStorage);
      this.oauthService.tokenValidationHandler = new NullValidationHandler();

      // subscribe to token events
      this.oauthService.events
        .pipe(filter((e: any) => {
          return e.type === 'token_received';
        }))
        .subscribe(() => this.handleNewToken());

      // continue initializing app or redirect to login-page

      this.oauthService.loadDiscoveryDocumentAndLogin().then(isLoggedIn => {
        if (isLoggedIn) {
          this.oauthService.setupAutomaticSilentRefresh();
          resolveFn();
        } else {
          this.oauthService.initLoginFlow();
          rejectFn();
        }
      });

    });
  }

  private handleNewToken() {
    this.decodedAccessToken = this.oauthService.getAccessToken();
    this.decodedIDToken = this.oauthService.getIdToken();
  }
}
    • AuthConfigModule*
@NgModule({
  imports: [ HttpClientModule, OAuthModule.forRoot() ],
  providers: [
    AuthConfigService,
    { provide: AuthConfig, useValue: authConfig },
    OAuthModuleConfig,
    {
      provide: APP_INITIALIZER,
      useFactory: init_app,
      deps: [ AuthConfigService ],
      multi: true
    }
  ]
})
export class AuthConfigModule { }
  • environment.ts
export const environment = {
  production: false,
  envName: 'local',
  baseUrl: 'http://localhost:8081/',
  keycloak: {
    issuer: 'http://localhost:8080/auth/realms/TheLibrary',
    redirectUri: 'http://localhost:4200/',
    clientId: 'XXXXXXXXXXX',
    dummyClientSecret: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    responseType: 'code',
    scope: 'openid profile email',
    requireHttps: false,
    // at_hash is not present in JWT token
    showDebugInformation: true,
    disableAtHashCheck: true
  }
};

GATEWAY

    • application.yml*
spring:
    application:
        name: gateway-service
    cloud:
        config:
            uri: http://localhost:8888
        discovery:
            enabled: true
        gateway:
#            default-filters:
#                - TokenRelay
            routes:
                -   id: THELIBRARY-MS-BOOK
                    uri: lb://thelibrary-ms-book
                    predicates:
                        - Path=/api/**
                    filters:
                        - TokenRelay=
            globalcors:
                corsConfigurations:
                    '[/**]':
                        allowedOrigins: "*"
                        allowedMethods:
                            - GET
                            - POST
                            - DELETE
                            - PUT
                        add-to-simple-url-handler-mapping: true
    security:
        oauth2:
            client:
                provider:
                    keycloak:
                        issuer-uri: http://localhost:8080/auth/realms/TheLibrary
                        user-name-attribute: preferred_username
                registration:
                    keycloak:
                        client-id: xxxxxxxxxxxxxxxxxx
                        client-secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx

eureka:
    client:
        serviceUrl:
            defaultZone: http://localhost:8761/eureka/

management:
    endpoints:
        web:
            exposure:
                include: "*"

server:
    port: 8081

logging:
    level:
        org:
            springframework:
                cloud.gateway: DEBUG
                http.server.reactive: DEBUG
                web.reactive: DEBUG
    • SpringBootApplication*
@SpringBootApplication
@CrossOrigin("*")
public class GatewayApplication {

//  @Autowired
//  private TokenRelayGatewayFilterFactory filterFactory;
//
//  @Bean
//  public RouteLocator myRoutes(RouteLocatorBuilder builder) {
//      return builder.routes()
//                     .route(route -> route
//                                         .path("/api/**")
////                                           .filters(f -> f.hystrix(config -> config.setName("d").setFallbackUri( "forward:/defaultBook"  )))
//                                         .filters(f -> f.filter( filterFactory.apply() ))
//                                         .uri("lb://thelibrary-ms-book")
//                                         .id( "ms-books" ))
//              .build();
//  }

    @Bean
    DiscoveryClientRouteDefinitionLocator discoveryClientRouteDefinitionLocator(
            ReactiveDiscoveryClient reactiveDiscoveryClient,
            DiscoveryLocatorProperties discoveryLocatorProperties ){
        return new DiscoveryClientRouteDefinitionLocator(reactiveDiscoveryClient, discoveryLocatorProperties);
    }

    public static void main( String[] args ) {
        SpringApplication.run( GatewayApplication.class, args );
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain( ServerHttpSecurity http,
                                                             ReactiveClientRegistrationRepository clientRegistrationRepository) {

        // Require authentication for all requests
        http.cors().and().authorizeExchange().anyExchange().permitAll();

        // Allow showing /home within a frame
//      http.headers().frameOptions().mode( XFrameOptionsServerHttpHeadersWriter.Mode.SAMEORIGIN);

        // Disable CSRF in the gateway to prevent conflicts with proxied service CSRF
        http.csrf().disable();
        return http.build();
    }
}

Microservice

    • application.yml*
spring:
    application:
        name: thelibrary-ms-book
    cloud:
        config:
            uri: http://localhost:8888
            profile: local, prod
        discovery:
            enabled: true
    data:
        rest:
            return-body-on-create: true
            return-body-on-update: true
    rabbitmq:
        host: localhost
        username: user
        password: user

    security:
        oauth2:
            resourceserver:
                jwt:
                    issuer-uri: http://localhost:8080/auth/realms/TheLibrary
                    jwk-set-uri: http://localhost:8080/auth/realms/TheLibrary/.well-known/openid-configuration

eureka:
    client:
        serviceUrl:
            defaultZone: http://localhost:8761/eureka/

management:
    endpoints:
        web:
            exposure:
                include: "*"

server:
    port: 8090
    servlet:
        context-path: /api/

logging:
    level:
        org:
            hibernate:
                SQL: DEBUG
                type:
                    descriptor:
                        sql:
                            BasicBinder: TRACE
    • SecurityConfig*
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // Validate tokens through configured OpenID Provider
        http.cors().and().oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());
        http.cors().and().authorizeRequests().mvcMatchers("/books").hasRole("admin");
        // Allow showing pages within a frame
        http.headers().frameOptions().sameOrigin();
    }

    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        // Convert realm_access.roles claims to granted authorities, for use in access decisions
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
        return jwtAuthenticationConverter;
    }

    @Bean
    public JwtDecoder jwtDecoderByIssuerUri( OAuth2ResourceServerProperties properties) {
        String issuerUri = properties.getJwt().getIssuerUri();
        NimbusJwtDecoder jwtDecoder = ( NimbusJwtDecoder ) JwtDecoders.fromIssuerLocation(issuerUri);
        // Use preferred_username from claims as authentication name, instead of UUID subject
        jwtDecoder.setClaimSetConverter(new UsernameSubClaimAdapter());
        return jwtDecoder;
    }
}
    • KeycloakRealmRoleConverter*
class KeycloakRealmRoleConverter implements Converter< Jwt, Collection< GrantedAuthority > > {

    @Override
    @SuppressWarnings("unchecked")
    public Collection<GrantedAuthority> convert(final Jwt jwt) {
        final Map<String, Object> realmAccess = (Map<String, Object>) jwt.getClaims().get("realm_access");
        return (( List<String> ) realmAccess.get("roles")).stream()
                .map(roleName -> "ROLE_" + roleName)
                .map( SimpleGrantedAuthority::new)
                .collect( Collectors.toList());
    }
}
    • UsernameSubClaimAdapter*
class UsernameSubClaimAdapter implements Converter< Map<String, Object>, Map<String, Object>> {

    private final MappedJwtClaimSetConverter delegate = MappedJwtClaimSetConverter.withDefaults( Collections.emptyMap());

    @Override
    public Map<String, Object> convert(Map<String, Object> claims) {
        Map<String, Object> convertedClaims = this.delegate.convert(claims);
        String username = (String) convertedClaims.get("preferred_username");
        convertedClaims.put("sub", username);
        return convertedClaims;
    }
}
    • Relevant dependencies*
        <springboot-version>2.2.5.RELEASE</springboot-version>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Hoxton.SR3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

I have a very standard Cleint Keycloak configuration, relevant is :
– Access Type : confidential
– Standard Flow Enabled : ON
– Implicit Flow Enabled : OFF
– Direct Access Grants Enabled : ON
– Service Accounts Enabled : ON
– Authorization Enabled : ON

I really tried many things but I have no any idea anymore…

Could someone take a look and tell me what I’m doing wrong?
I would be very gratefull! 🙂

Thanks a lot for your time! 🙂

Solution

Here is what solved my problem!

1 – In Angular: correct the DefaultOAuthInterceptor

Remove this part:

    if (!this.moduleConfig) { return next.handle(req); }
    if (!this.moduleConfig.resourceServer) { return next.handle(req); }
    if (!this.moduleConfig.resourceServer.allowedUrls) { return next.handle(req); }
    if (!this.checkUrl(url)) { return next.handle(req); }

For whatever reason, one of these condition always end to be true then the rest of the method is never executed. (warning: I don’t really know the consequences of skipping this code)

So the final interceptot is:

@Injectable()
export class DefaultOAuthInterceptor implements HttpInterceptor {
  constructor(
    private authStorage: OAuthStorage,
    private oAuthService: OAuthService,
    private errorHandler: OAuthResourceServerErrorHandler,
    @Optional() private moduleConfig: OAuthModuleConfig
  ) {
  }

  private checkUrl(url: string): boolean {
    if (this.moduleConfig.resourceServer.customUrlValidation) {
      return this.moduleConfig.resourceServer.customUrlValidation(url);
    }

    if (this.moduleConfig.resourceServer.allowedUrls) {
      return !!this.moduleConfig.resourceServer.allowedUrls.find(u =>
        url.startsWith(u)
      );
    }

    return true;
  }

  public intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    const url = req.url.toLowerCase();

    // if (
    //   !this.moduleConfig ||
    //   !this.moduleConfig.resourceServer ||
    //   !this.checkUrl(url)
    // ) {
    //   return next.handle(req);
    // }

    const sendAccessToken = this.moduleConfig.resourceServer.sendAccessToken;

    if (!sendAccessToken) {
      return next
        .handle(req)
        .pipe(catchError(err => this.errorHandler.handleError(err)));
    }

    return merge(
      of(this.oAuthService.getAccessToken()).pipe(
        filter(token => (token ? true : false))
      ),
      this.oAuthService.events.pipe(
        filter(e => e.type === 'token_received'),
        timeout(this.oAuthService.waitForTokenInMsec || 0),
        catchError(_ => of(null)), // timeout is not an error
        map(_ => this.oAuthService.getAccessToken())
      )
    ).pipe(
      take(1),
      mergeMap(token => {
        if (token) {
          const header = 'Bearer ' + token;
          const headers = req.headers.set('Authorization', header);
          req = req.clone({headers});
        }

        return next
          .handle(req)
          .pipe(catchError(err => this.errorHandler.handleError(err)));
      })
    );
  }
}

2 – In the GATEWAY, add a CorsWebFilter
With the Angular interceptor working correctly, I still had a CORS issue, regardless the yaml config from the spring cloud gateway documentation.

I had to add a simple CorsWebFilter, as this link says https://github.com/spring-cloud/spring-cloud-gateway/issues/840:

@Configuration
public class PreFlightCorsConfiguration {

    @Bean
    public CorsWebFilter corsFilter() {
        return new CorsWebFilter(corsConfigurationSource());
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration().applyPermitDefaultValues();
        config.addAllowedMethod( HttpMethod.GET);
        config.addAllowedMethod( HttpMethod.PUT);
        config.addAllowedMethod( HttpMethod.POST);
        config.addAllowedMethod(HttpMethod.DELETE);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

That’s it! It now works like a charm 🙂
Hope this helps 🙂

Answered By – John Student

Answer Checked By – Dawn Plyler (AngularFixing Volunteer)

Leave a Reply

Your email address will not be published.