1

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! :)

John Student
  • 298
  • 1
  • 6
  • 18

1 Answers1

2

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 :)

John Student
  • 298
  • 1
  • 6
  • 18
  • Some time passed, I see that the problem seems fixed. Anyway with this kind of infrastructure I would have searched where exactly the authentication was not recognized. I'm not an OAuth2 expert, but I'm using keycloak since a year now, sometimes it can be picky on certain input parameters. In cases like this, I suggest to do some Http debugging with a client (eg. Postman), trying to check each part on it's own. In your case from client to gateway, and from gateway to microservices. Check response headers, they contain the reason of 403 error usually. – funder7 Jan 11 '21 at 16:15