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??
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
.