Search code examples
javaangularspring-bootspring-securityokta

Getting 404 error when callback after authentication(Spring Boot + Angular + Okta)


Hi I am now using Angular + Spring Boot to build a website, in my website, I am using Okta Single-Page App to do authentication. For the frontend, I am using okta-angular, and follow the instructions here: https://github.com/okta/okta-oidc-js/tree/master/packages/okta-angular. I am using implicit flow. In order to keep simple, I used okta hosted sign-in widget.

My frontend code like this:

app.module.ts

import {
  OKTA_CONFIG,
  OktaAuthModule
} from '@okta/okta-angular';

const oktaConfig = {
  issuer: 'https://{yourOktaDomain}.com/oauth2/default',
  clientId: '{clientId}',
  redirectUri: 'http://localhost:{port}/implicit/callback',
  pkce: true
}

@NgModule({
  imports: [
    ...
    OktaAuthModule
  ],
  providers: [
    { provide: OKTA_CONFIG, useValue: oktaConfig }
  ],
})
export class MyAppModule { }

then I use OktaAuthGuard in app-routing.module.ts

import {
  OktaAuthGuard,
  ...
} from '@okta/okta-angular';

const appRoutes: Routes = [
  {
    path: 'protected',
    component: MyProtectedComponent,
    canActivate: [ OktaAuthGuard ],
  },
  ...
]

Also in the app-routing.module.ts I am using OktaCallBackComponent.

of course I have login/logout button at headers:

import { Component, OnInit } from '@angular/core';
import {OktaAuthService} from '@okta/okta-angular';

@Component({
  selector: 'app-header',
  templateUrl: './app-header.component.html',
  styleUrls: ['./app-header.component.scss']
})
export class AppHeaderComponent implements OnInit {
  isAuthenticated: boolean;
  constructor(public oktaAuth: OktaAuthService) {
    // Subscribe to authentication state changes
    this.oktaAuth.$authenticationState.subscribe(
      (isAuthenticated: boolean) => this.isAuthenticated = isAuthenticated
    );
  }
  async ngOnInit() {
    this.isAuthenticated = await this.oktaAuth.isAuthenticated();
  }

  login() {
    this.oktaAuth.loginRedirect('/');
  }

  logout() {
    this.oktaAuth.logout('/');
  }

}
<nav class="navbar navbar-expand-lg navbar-light">

  <div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item">
        <a class="nav-link" *ngIf="!isAuthenticated" (click)="login()"> Login </a>
        <a class="nav-link" *ngIf="isAuthenticated" (click)="logout()"> Logout </a>
      </li>
    </ul>
  </div>
</nav>

After user login at frontend, I will pass Authoirization header to backend, and At the backend, I use spring security to protect backend api. like this:

import com.okta.spring.boot.oauth.Okta;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;

@RequiredArgsConstructor
@EnableWebSecurity
public class OktaOAuth2WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // Disable CSRF (cross site request forgery)
        http.csrf().disable();

        // No session will be created or used by spring security
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests()
                .antMatchers("/api/**").authenticated()
                .and()
                .oauth2ResourceServer().opaqueToken();

        Okta.configureResourceServer401ResponseBody(http);
    }
}

Everything works fine if i run angular and spring boot separately in terminals. I can log in, and I can get user info at backend.

But the problem is when we were using gradle build and to deploy, we will put angular compiled code to static folder under spring boot project. At this time if I run the project:

java -jar XX.jar

And I open at localhost:8080.

I login, then at this time, the authentication callback will throw 404 not found error.

In my understanding, the reason is that when I run jar file, and I didn't define controller for the "callback" url. But if I run angular and spring boot separately, angular is hosted by nodejs, and I used okta callbackcomponent, so everything works.

So what should I do to fix the problem? I mean, what should I do to let it work as a jar file? should I define a callback controller? but what should I do in callback controller? will it conflict with frontend code??


Solution

  • You're in luck! I just published a blog post today that shows how to take an Angular + Spring Boot app that runs separately (with Okta's SDKs) and package them in a single JAR. You can still develop each app independently using ng serve and ./gradlew bootRun, but you can also run them in a single instance using ./gradlew bootRun -Pprod. The disadvantage to running in prod mode is you won't get hot-reload in Angular. Here are the steps I used in the aforementioned tutorial.

    Create a new AuthService service that will communicate with your Spring Boot API for authentication logic.

    import { Injectable } from '@angular/core';
    import { Location } from '@angular/common';
    import { BehaviorSubject, Observable } from 'rxjs';
    import { HttpClient, HttpHeaders } from '@angular/common/http';
    import { environment } from '../../environments/environment';
    import { User } from './user';
    import { map } from 'rxjs/operators';
    
    const headers = new HttpHeaders().set('Accept', 'application/json');
    
    @Injectable({
      providedIn: 'root'
    })
    export class AuthService {
      $authenticationState = new BehaviorSubject<boolean>(false);
    
      constructor(private http: HttpClient, private location: Location) {
      }
    
      getUser(): Observable<User> {
        return this.http.get<User>(`${environment.apiUrl}/user`, {headers}).pipe(
          map((response: User) => {
            if (response !== null) {
              this.$authenticationState.next(true);
              return response;
            }
          })
        );
      }
    
      isAuthenticated(): Promise<boolean> {
        return this.getUser().toPromise().then((user: User) => { 
          return user !== undefined;
        }).catch(() => {
          return false;
        })
      }
    
      login(): void { 
        location.href =
          `${location.origin}${this.location.prepareExternalUrl('oauth2/authorization/okta')}`;
      }
    
      logout(): void { 
        const redirectUri = `${location.origin}${this.location.prepareExternalUrl('/')}`;
    
        this.http.post(`${environment.apiUrl}/api/logout`, {}).subscribe((response: any) => {
          location.href = response.logoutUrl + '?id_token_hint=' + response.idToken
            + '&post_logout_redirect_uri=' + redirectUri;
        });
      }
    }
    

    Create a user.ts file in the same directory, to hold your User model.

    export class User {
      sub: number;
      fullName: string;
    }
    

    Update app.component.ts to use your new AuthService in favor of OktaAuthService.

    import { Component, OnInit } from '@angular/core';
    import { AuthService } from './shared/auth.service';
    
    @Component({
      selector: 'app-root',
      templateUrl: './app.component.html',
      styleUrls: ['./app.component.scss']
    })
    export class AppComponent implements OnInit {
      title = 'Notes';
      isAuthenticated: boolean;
      isCollapsed = true;
    
      constructor(public auth: AuthService) {
      }
    
      async ngOnInit() {
        this.isAuthenticated = await this.auth.isAuthenticated();
        this.auth.$authenticationState.subscribe(
          (isAuthenticated: boolean)  => this.isAuthenticated = isAuthenticated
        );
      }
    }
    

    Change the buttons in app.component.html to reference the auth service instead of oktaAuth.

    <button *ngIf="!isAuthenticated" (click)="auth.login()"
            class="btn btn-outline-primary" id="login">Login</button>
    <button *ngIf="isAuthenticated" (click)="auth.logout()"
            class="btn btn-outline-secondary" id="logout">Logout</button>
    

    Update home.component.ts to use AuthService too.

    import { Component, OnInit } from '@angular/core';
    import { AuthService } from '../shared/auth.service';
    
    @Component({
      selector: 'app-home',
      templateUrl: './home.component.html',
      styleUrls: ['./home.component.scss']
    })
    export class HomeComponent implements OnInit {
      isAuthenticated: boolean;
    
      constructor(public auth: AuthService) {
      }
    
      async ngOnInit() {
        this.isAuthenticated = await this.auth.isAuthenticated();
      }
    }
    

    If you used OktaDev Schematics to integrate Okta into your Angular app, delete src/app/auth-routing.module.ts and src/app/shared/okta.

    Modify app.module.ts to remove the AuthRoutingModule import, add HomeComponent as a declaration, and import HttpClientModule.

    Add the route for HomeComponent to app-routing.module.ts.

    import { HomeComponent } from './home/home.component';
    
    const routes: Routes = [
      { path: '', redirectTo: '/home', pathMatch: 'full' },
      {
        path: 'home',
        component: HomeComponent
      }
    ];
    

    Create a proxy.conf.js file to proxy certain requests to your Spring Boot API on http://localhost:8080.

    const PROXY_CONFIG = [
      {
        context: ['/user', '/api', '/oauth2', '/login'],
        target: 'http://localhost:8080',
        secure: false,
        logLevel: "debug"
      }
    ]
    
    module.exports = PROXY_CONFIG;
    

    Add this file as a proxyConfig option in angular.json.

    "serve": {
      "builder": "@angular-devkit/build-angular:dev-server",
      "options": {
        "browserTarget": "notes:build",
        "proxyConfig": "src/proxy.conf.js"
      },
      ...
    },
    

    Remove Okta’s Angular SDK and OktaDev Schematics from your Angular project.

    npm uninstall @okta/okta-angular @oktadev/schematics
    

    At this point, your Angular app won't contain any Okta-specific code for authentication. Instead, it relies on your Spring Boot app to provide that.

    To configure your Spring Boot app to include Angular, you need to configure Gradle (or Maven) to build your Spring Boot app when you pass in -Pprod, you'll need to adjust routes to be SPA-aware, and modify Spring Security to allow access to HTML, CSS, and JavaScript.

    In my example, I used Gradle and Kotlin.

    First, create a RouteController.kt that routes all requests to index.html.

    package com.okta.developer.notes
    
    import org.springframework.stereotype.Controller
    import org.springframework.web.bind.annotation.RequestMapping
    import javax.servlet.http.HttpServletRequest
    
    @Controller
    class RouteController {
    
        @RequestMapping(value = ["/{path:[^\\.]*}"])
        fun redirect(request: HttpServletRequest): String {
            return "forward:/"
        }
    }
    

    Modify SecurityConfiguration.kt to allow anonymous access to static web files, the /user info endpoint, and to add additional security headers.

    package com.okta.developer.notes
    
    import org.springframework.security.config.annotation.web.builders.HttpSecurity
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
    import org.springframework.security.web.csrf.CookieCsrfTokenRepository
    import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter
    import org.springframework.security.web.util.matcher.RequestMatcher
    
    @EnableWebSecurity
    class SecurityConfiguration : WebSecurityConfigurerAdapter() {
    
        override fun configure(http: HttpSecurity) {
            //@formatter:off
            http
                .authorizeRequests()
                    .antMatchers("/**/*.{js,html,css}").permitAll()
                    .antMatchers("/", "/user").permitAll()
                    .anyRequest().authenticated()
                    .and()
                .oauth2Login()
                    .and()
                .oauth2ResourceServer().jwt()
    
            http.requiresChannel()
                    .requestMatchers(RequestMatcher {
                        r -> r.getHeader("X-Forwarded-Proto") != null
                    }).requiresSecure()
    
            http.csrf()
                    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    
            http.headers()
                    .contentSecurityPolicy("script-src 'self'; report-to /csp-report-endpoint/")
                    .and()
                    .referrerPolicy(ReferrerPolicyHeaderWriter.ReferrerPolicy.SAME_ORIGIN)
                    .and()
                    .featurePolicy("accelerometer 'none'; camera 'none'; microphone 'none'")
    
            //@formatter:on
        }
    }
    

    Create a UserController.kt that can be used to determine if the user is logged in.

    package com.okta.developer.notes
    
    import org.springframework.security.core.annotation.AuthenticationPrincipal
    import org.springframework.security.oauth2.core.oidc.user.OidcUser
    import org.springframework.web.bind.annotation.GetMapping
    import org.springframework.web.bind.annotation.RestController
    
    @RestController
    class UserController() {
    
        @GetMapping("/user")
        fun user(@AuthenticationPrincipal user: OidcUser?): OidcUser? {
            return user;
        }
    }
    

    Previously, Angular handled logout. Add a LogoutController that will handle expiring the session as well as sending information back to Angular so it can logout from Okta.

    package com.okta.developer.notes
    
    import org.springframework.http.ResponseEntity
    import org.springframework.security.core.annotation.AuthenticationPrincipal
    import org.springframework.security.oauth2.client.registration.ClientRegistration
    import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
    import org.springframework.security.oauth2.core.oidc.OidcIdToken
    import org.springframework.web.bind.annotation.PostMapping
    import org.springframework.web.bind.annotation.RestController
    import javax.servlet.http.HttpServletRequest
    
    @RestController
    class LogoutController(val clientRegistrationRepository: ClientRegistrationRepository) {
    
        val registration: ClientRegistration = clientRegistrationRepository.findByRegistrationId("okta");
    
        @PostMapping("/api/logout")
        fun logout(request: HttpServletRequest,
                   @AuthenticationPrincipal(expression = "idToken") idToken: OidcIdToken): ResponseEntity<*> {
            val logoutUrl = this.registration.providerDetails.configurationMetadata["end_session_endpoint"]
            val logoutDetails: MutableMap<String, String> = HashMap()
            logoutDetails["logoutUrl"] = logoutUrl.toString()
            logoutDetails["idToken"] = idToken.tokenValue
            request.session.invalidate()
            return ResponseEntity.ok().body<Map<String, String>>(logoutDetails)
        }
    }
    

    Finally, I configured Gradle to build a JAR with Angular included.

    Start by importing NpmTask and adding the Node Gradle plugin in build.gradle.kts:

    import com.moowork.gradle.node.npm.NpmTask
    
    plugins {
        ...
        id("com.github.node-gradle.node") version "2.2.4"
        ...
    }
    

    Then, define the location of your Angular app and configuration for the Node plugin.

    val spa = "${projectDir}/../notes";
    
    node {
        version = "12.16.2"
        nodeModulesDir = file(spa)
    }
    

    Add a buildWeb task:

    val buildWeb = tasks.register<NpmTask>("buildNpm") {
        dependsOn(tasks.npmInstall)
        setNpmCommand("run", "build")
        setArgs(listOf("--", "--prod"))
        inputs.dir("${spa}/src")
        inputs.dir(fileTree("${spa}/node_modules").exclude("${spa}/.cache"))
        outputs.dir("${spa}/dist")
    }
    

    And modify the processResources task to build Angular when -Pprod is passed in.

    tasks.processResources {
        rename("application-${profile}.properties", "application.properties")
        if (profile == "prod") {
            dependsOn(buildWeb)
            from("${spa}/dist/notes") {
                into("static")
            }
        }
    }
    

    Now you should be able to combine both apps using ./gradlew bootJar -Pprod or see them running using ./gradlew bootRun -Pprod.