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.