Browse Source

Add SPA sample using BFF and Spring Cloud Gateway

pull/1834/head
Joe Grandja 1 year ago
parent
commit
a6d9c19daa
  1. 20
      samples/README.adoc
  2. 1
      samples/backend-for-spa-client/gradle.properties
  3. 35
      samples/backend-for-spa-client/samples-backend-for-spa-client.gradle
  4. 32
      samples/backend-for-spa-client/src/main/java/sample/BackendForSpaClientApplication.java
  5. 52
      samples/backend-for-spa-client/src/main/java/sample/config/CorsConfig.java
  6. 64
      samples/backend-for-spa-client/src/main/java/sample/config/GatewayFilterFunctions.java
  7. 118
      samples/backend-for-spa-client/src/main/java/sample/config/SecurityConfig.java
  8. 43
      samples/backend-for-spa-client/src/main/java/sample/web/DefaultController.java
  9. 2
      samples/backend-for-spa-client/src/main/resources/META-INF/spring.factories
  10. 53
      samples/backend-for-spa-client/src/main/resources/application.yml
  11. 17
      samples/spa-client/.editorconfig
  12. 42
      samples/spa-client/.gitignore
  13. 27
      samples/spa-client/README.md
  14. 103
      samples/spa-client/angular.json
  15. 13125
      samples/spa-client/package-lock.json
  16. 38
      samples/spa-client/package.json
  17. 1
      samples/spa-client/public/spring-security.svg
  18. 58
      samples/spa-client/src/app/app.component.html
  19. 0
      samples/spa-client/src/app/app.component.scss
  20. 75
      samples/spa-client/src/app/app.component.ts
  21. 14
      samples/spa-client/src/app/app.config.ts
  22. 3
      samples/spa-client/src/app/app.routes.ts
  23. 3
      samples/spa-client/src/app/environment.ts
  24. 24
      samples/spa-client/src/app/http.interceptors.ts
  25. 15
      samples/spa-client/src/index.html
  26. 6
      samples/spa-client/src/main.ts
  27. 1
      samples/spa-client/src/styles.scss
  28. 15
      samples/spa-client/tsconfig.app.json
  29. 33
      samples/spa-client/tsconfig.json
  30. 15
      samples/spa-client/tsconfig.spec.json

20
samples/README.adoc

@ -5,6 +5,26 @@ @@ -5,6 +5,26 @@
The default sample provides the minimal configuration to get started with Spring Authorization Server.
[[spa-sample]]
== SPA (Single Page Application) Sample
The SPA sample provides a reference implementation of the https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-19#name-backend-for-frontend-bff[Backend For Frontend (BFF)] application architecture pattern.
The *spa-client* is the _frontend_ SPA implemented with Angular and the *backend-for-spa-client* is the _backend_ application.
The *backend-for-spa-client* uses https://spring.io/projects/spring-cloud-gateway[Spring Cloud Gateway] to route `/userinfo` (UserInfo Endpoint) requests to *demo-authorizationserver* and `/messages` requests to *messages-resource*.
The *backend-for-spa-client* performs the authorization flows and stores the access tokens.
The *spa-client* is never exposed the access tokens and directly communicates with the *backend-for-spa-client* via an authenticated session cookie.
[[run-spa-sample]]
=== Run the Sample
* Run Authorization Server -> `./gradlew -b samples/demo-authorizationserver/samples-demo-authorizationserver.gradle bootRun`
* Run Resource Server -> `./gradlew -b samples/messages-resource/samples-messages-resource.gradle bootRun`
* Run Backend -> `./gradlew -b samples/backend-for-spa-client/samples-backend-for-spa-client.gradle bootRun`
* Run Frontend -> `ng serve` (from `samples/spa-client` directory)
** *NOTE:* Angular must be installed locally before running `ng serve`. See https://angular.dev/installation[installation instructions].
* Go to `http://127.0.0.1:4200`
** Login with credentials -> user1 \ password
[[demo-sample]]
== Demo Sample

1
samples/backend-for-spa-client/gradle.properties

@ -0,0 +1 @@ @@ -0,0 +1 @@
spring-security.version=6.3.0

35
samples/backend-for-spa-client/samples-backend-for-spa-client.gradle

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
plugins {
id "org.springframework.boot" version "3.2.2"
id "io.spring.dependency-management" version "1.1.0"
id "java"
}
group = project.rootProject.group
version = project.rootProject.version
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
maven { url "https://repo.spring.io/snapshot" }
}
ext {
set("springCloudVersion", "2023.0.2")
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
implementation "org.springframework.cloud:spring-cloud-starter-gateway-mvc"
}

32
samples/backend-for-spa-client/src/main/java/sample/BackendForSpaClientApplication.java

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Joe Grandja
* @since 1.4
*/
@SpringBootApplication
public class BackendForSpaClientApplication {
public static void main(String[] args) {
SpringApplication.run(BackendForSpaClientApplication.class, args);
}
}

52
samples/backend-for-spa-client/src/main/java/sample/config/CorsConfig.java

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import java.util.Arrays;
import java.util.Collections;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* @author Joe Grandja
* @since 1.4
*/
@Configuration(proxyBeanMethods = false)
public class CorsConfig {
@Value("${app.base-uri}")
private String appBaseUri;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader("X-XSRF-TOKEN");
config.addAllowedHeader(HttpHeaders.CONTENT_TYPE);
config.setAllowedMethods(Arrays.asList("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedOrigins(Collections.singletonList(this.appBaseUri));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

64
samples/backend-for-spa-client/src/main/java/sample/config/GatewayFilterFunctions.java

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import org.springframework.cloud.gateway.server.mvc.common.Shortcut;
import org.springframework.cloud.gateway.server.mvc.filter.SimpleFilterSupplier;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.web.servlet.function.HandlerFilterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.getApplicationContext;
/**
* Custom {@code HandlerFilterFunction}'s registered in META-INF/spring.factories and used in application.yml.
*
* @author Joe Grandja
* @since 1.4
*/
public interface GatewayFilterFunctions {
@Shortcut
static HandlerFilterFunction<ServerResponse, ServerResponse> relayTokenIfExists(String clientRegistrationId) {
return (request, next) -> {
Authentication principal = (Authentication) request.servletRequest().getUserPrincipal();
OAuth2AuthorizedClientRepository authorizedClientRepository = getApplicationContext(request)
.getBean(OAuth2AuthorizedClientRepository.class);
OAuth2AuthorizedClient authorizedClient = authorizedClientRepository.loadAuthorizedClient(
clientRegistrationId, principal, request.servletRequest());
if (authorizedClient != null) {
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
ServerRequest bearerRequest = ServerRequest.from(request)
.headers(httpHeaders -> httpHeaders.setBearerAuth(accessToken.getTokenValue())).build();
return next.handle(bearerRequest);
}
return next.handle(request);
};
}
class FilterSupplier extends SimpleFilterSupplier {
FilterSupplier() {
super(GatewayFilterFunctions.class);
}
}
}

118
samples/backend-for-spa-client/src/main/java/sample/config/SecurityConfig.java

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import java.util.LinkedHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.logout.CompositeLogoutHandler;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfLogoutHandler;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
/**
* @author Joe Grandja
* @since 1.4
*/
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecurityConfig {
@Value("${app.base-uri}")
private String appBaseUri;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
/*
IMPORTANT:
Set the csrfRequestAttributeName to null, to opt-out of deferred tokens, resulting in the CsrfToken to be loaded on every request.
If it does not exist, the CookieCsrfTokenRepository will automatically generate a new one and add the Cookie to the response.
See the reference: https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#deferred-csrf-token
*/
csrfTokenRequestAttributeHandler.setCsrfRequestAttributeName(null);
// @formatter:off
http
.authorizeHttpRequests(authorize ->
authorize
.anyRequest().authenticated()
)
.csrf(csrf ->
csrf
.csrfTokenRepository(cookieCsrfTokenRepository)
.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
)
.cors(Customizer.withDefaults())
.exceptionHandling(exceptionHandling ->
exceptionHandling
.authenticationEntryPoint(authenticationEntryPoint())
)
.oauth2Login(oauth2Login ->
oauth2Login
.successHandler(new SimpleUrlAuthenticationSuccessHandler(this.appBaseUri)))
.logout(logout ->
logout
.addLogoutHandler(logoutHandler(cookieCsrfTokenRepository))
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
)
.oauth2Client(Customizer.withDefaults());
// @formatter:on
return http.build();
}
private AuthenticationEntryPoint authenticationEntryPoint() {
AuthenticationEntryPoint authenticationEntryPoint =
new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/messaging-client-oidc");
MediaTypeRequestMatcher textHtmlMatcher =
new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
textHtmlMatcher.setUseEquals(true);
LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
entryPoints.put(textHtmlMatcher, authenticationEntryPoint);
DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints);
delegatingAuthenticationEntryPoint.setDefaultEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
return delegatingAuthenticationEntryPoint;
}
private LogoutHandler logoutHandler(CsrfTokenRepository csrfTokenRepository) {
return new CompositeLogoutHandler(
new SecurityContextLogoutHandler(),
new CsrfLogoutHandler(csrfTokenRepository));
}
}

43
samples/backend-for-spa-client/src/main/java/sample/web/DefaultController.java

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* @author Joe Grandja
* @since 1.4
*/
@Controller
public class DefaultController {
@Value("${app.base-uri}")
private String appBaseUri;
@GetMapping("/")
public String root() {
return "redirect:" + this.appBaseUri;
}
// '/authorized' is the registered 'redirect_uri' for authorization_code
@GetMapping("/authorized")
public String authorized() {
return "redirect:" + this.appBaseUri;
}
}

2
samples/backend-for-spa-client/src/main/resources/META-INF/spring.factories

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
org.springframework.cloud.gateway.server.mvc.filter.FilterSupplier=\
sample.config.GatewayFilterFunctions.FilterSupplier

53
samples/backend-for-spa-client/src/main/resources/application.yml

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
server:
port: 8080
logging:
level:
root: INFO
org.springframework.web: INFO
org.springframework.security: INFO
org.springframework.security.oauth2: INFO
spring:
security:
oauth2:
client:
registration:
messaging-client-oidc:
provider: spring
client-id: messaging-client
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/{registrationId}"
scope: openid,profile
client-name: messaging-client-oidc
messaging-client-authorization-code:
provider: spring
client-id: messaging-client
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: "http://127.0.0.1:8080/authorized"
scope: message.read,message.write
client-name: messaging-client-authorization-code
provider:
spring:
issuer-uri: http://localhost:9000
cloud:
gateway:
mvc:
routes:
- id: userinfo
uri: http://localhost:9000
predicates:
- Path=/userinfo
filters:
- TokenRelay=
- id: messages
uri: http://localhost:8090
predicates:
- Path=/messages
filters:
- RelayTokenIfExists=messaging-client-authorization-code
app:
base-uri: http://127.0.0.1:4200

17
samples/spa-client/.editorconfig

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
samples/spa-client/.gitignore vendored

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

27
samples/spa-client/README.md

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
# SpaClient
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.11.
## Development server
Run `ng serve` for a dev server. Navigate to `http://127.0.0.1:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

103
samples/spa-client/angular.json

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"spa-client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/spa-client",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "spa-client:build:production"
},
"development": {
"buildTarget": "spa-client:build:development",
"host": "127.0.0.1"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}

13125
samples/spa-client/package-lock.json generated

File diff suppressed because it is too large Load Diff

38
samples/spa-client/package.json

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
{
"name": "spa-client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.11",
"@angular/cli": "^18.2.11",
"@angular/compiler-cli": "^18.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.5.2"
}
}

1
samples/spa-client/public/spring-security.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.08 150.97"><defs><style>.cls-1{fill:#6bb344;}</style></defs><title>logo-security</title><path class="cls-1" d="M108.08,13,54,0,0,13V54.6H28.67a23.94,23.94,0,0,0,0,6H0V80.14C0,125,54,151,54,151s54-26,54-70.83V60.62H79.4a22.75,22.75,0,0,0,0-6h28.68ZM54,77.15A19.54,19.54,0,1,1,73.58,57.61,19.54,19.54,0,0,1,54,77.15Z"/><path class="cls-1" d="M54,48.34a5.06,5.06,0,0,0-2.32,9.56v1.31l1.49,1.49v1l1,1v1l-.88.88.94,1.55v1l-1,1.19,1.4,1.4,1.55-1.55V58A5.06,5.06,0,0,0,54,48.34Zm0,5.26a1.88,1.88,0,1,1,1.88-1.88A1.88,1.88,0,0,1,54,53.6Z"/></svg>

After

Width:  |  Height:  |  Size: 628 B

58
samples/spa-client/src/app/app.component.html

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
<div>
<nav class="navbar navbar-expand-lg bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<img src="/spring-security.svg" width="40" height="32">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Home</a>
</li>
<li *ngIf="isAuthenticated" class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Authorize</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" (click)="authorizeMessages()">Messages</a></li>
</ul>
</li>
</ul>
<div class="d-flex">
@if (isAuthenticated) {
<div>
<span class="fs-6 px-3">{{ userName }}</span>
<button class="btn btn-outline-dark" (click)="logout()">Logout</button>
</div>
} @else {
<div>
<button class="btn btn-outline-dark" (click)="login()">Login</button>
</div>
}
</div>
</div>
</div>
</nav>
</div>
<div class="container">
<div *ngIf="messages.length > 0" class="row py-5 justify-content-start">
<div class="col">
<table class="table table-striped caption-top">
<caption>Messages</caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let message of messages; let index = index">
<th scope="row">{{ index + 1 }}</th>
<td>{{ message }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

0
samples/spa-client/src/app/app.component.scss

75
samples/spa-client/src/app/app.component.ts

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
import {Component, OnInit} from '@angular/core';
import {NgIf, NgForOf} from '@angular/common';
import {HttpClient} from '@angular/common/http';
import {catchError, of} from 'rxjs';
import {environment} from "./environment";
@Component({
selector: 'app-root',
standalone: true,
imports: [NgIf, NgForOf],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit {
isAuthenticated: boolean = false;
userName: string = '';
messages: string[] = [];
constructor(private http: HttpClient) {
}
ngOnInit(): void {
this.getUserInfo();
this.getMessages();
}
login(): void {
// The Backend is configured to trigger login when unauthenticated
window.location.href = environment.backendBaseUrl;
}
logout(): void {
this.http.post('/logout', null)
.pipe(catchError((error) => {
console.error(error);
return of(null);
}))
.subscribe(() => {
this.isAuthenticated = false;
this.userName = '';
this.messages = [];
});
}
getUserInfo(): void {
this.http.get<any>('/userinfo')
.pipe(catchError((error) => {
console.error(error);
return of(null);
}))
.subscribe((userInfo) => {
if (userInfo) {
this.isAuthenticated = true;
this.userName = userInfo.sub;
}
});
}
authorizeMessages(): void {
// Trigger the Backend to perform the authorization_code grant flow.
// After authorization is complete, the Backend will redirect back to this app.
window.location.href = environment.backendBaseUrl + "/oauth2/authorization/messaging-client-authorization-code";
}
getMessages(): void {
this.http.get<string[]>('/messages')
.pipe(catchError((error) => {
console.error(error);
return of([]);
}))
.subscribe((messages) => {
this.messages = messages;
});
}
}

14
samples/spa-client/src/app/app.config.ts

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import { routes } from './app.routes';
import { withCredentialsInterceptor } from './http.interceptors';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([withCredentialsInterceptor]))
]
};

3
samples/spa-client/src/app/app.routes.ts

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];

3
samples/spa-client/src/app/environment.ts

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
export const environment = {
backendBaseUrl: 'http://127.0.0.1:8080',
};

24
samples/spa-client/src/app/http.interceptors.ts

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
import {HttpRequest, HttpHandlerFn, HttpEvent} from '@angular/common/http';
import {Observable} from 'rxjs';
import {environment} from "./environment";
/*
IMPORTANT:
By default, the HttpClient passes the CSRF token via the X-XSRF-TOKEN header using its built-in interceptor.
However, this DOES NOT WORK when absolute URLs are used in HttpClient calls.
Hence, the reason for this interceptor, as it prepends the Backend base URL to the relative URL.
Ensure you only use relative URLs in HttpClient calls for mutating requests (e.g. POST),
otherwise operations such as /logout will not work.
See the reference for further information:
https://angular.dev/best-practices/security#httpclient-xsrf-csrf-security
*/
export function withCredentialsInterceptor(request: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
request = request.clone({
url: environment.backendBaseUrl + request.url,
withCredentials: true // This is required to ensure the Session Cookie is passed in every request to the Backend
});
return next(request);
}

15
samples/spa-client/src/index.html

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SPA with Backend and Spring Cloud Gateway</title>
<base href="/">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
</head>
<body>
<app-root></app-root>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js" integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
</body>
</html>

6
samples/spa-client/src/main.ts

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

1
samples/spa-client/src/styles.scss

@ -0,0 +1 @@ @@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

15
samples/spa-client/tsconfig.app.json

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

33
samples/spa-client/tsconfig.json

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

15
samples/spa-client/tsconfig.spec.json

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
Loading…
Cancel
Save