Skip to content

Commit 82450c1

Browse files
committed
Add SPA sample using BFF and Spring Cloud Gateway
1 parent 3a63bf6 commit 82450c1

30 files changed

+14035
-0
lines changed

samples/README.adoc

+20
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@
55

66
The default sample provides the minimal configuration to get started with Spring Authorization Server.
77

8+
[[spa-sample]]
9+
== SPA (Single Page Application) Sample
10+
11+
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.
12+
The *spa-client* is the _frontend_ SPA implemented with Angular and the *backend-for-spa-client* is the _backend_ application.
13+
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*.
14+
The *backend-for-spa-client* performs the authorization flows and stores the access tokens.
15+
The *spa-client* is never exposed the access tokens and directly communicates with the *backend-for-spa-client* via an authenticated session cookie.
16+
17+
[[run-spa-sample]]
18+
=== Run the Sample
19+
20+
* Run Authorization Server -> `./gradlew -b samples/demo-authorizationserver/samples-demo-authorizationserver.gradle bootRun`
21+
* Run Resource Server -> `./gradlew -b samples/messages-resource/samples-messages-resource.gradle bootRun`
22+
* Run Backend -> `./gradlew -b samples/backend-for-spa-client/samples-backend-for-spa-client.gradle bootRun`
23+
* Run Frontend -> `ng serve` (from `samples/spa-client` directory)
24+
** *NOTE:* Angular must be installed locally before running `ng serve`. See https://angular.dev/installation[installation instructions].
25+
* Go to `http://127.0.0.1:4200`
26+
** Login with credentials -> user1 \ password
27+
828
[[demo-sample]]
929
== Demo Sample
1030

Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
spring-security.version=6.3.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
plugins {
2+
id "org.springframework.boot" version "3.2.2"
3+
id "io.spring.dependency-management" version "1.1.0"
4+
id "java"
5+
}
6+
7+
group = project.rootProject.group
8+
version = project.rootProject.version
9+
10+
java {
11+
sourceCompatibility = JavaVersion.VERSION_17
12+
}
13+
14+
repositories {
15+
mavenCentral()
16+
maven { url "https://repo.spring.io/milestone" }
17+
maven { url "https://repo.spring.io/snapshot" }
18+
}
19+
20+
ext {
21+
set("springCloudVersion", "2023.0.2")
22+
}
23+
24+
dependencyManagement {
25+
imports {
26+
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
27+
}
28+
}
29+
30+
dependencies {
31+
implementation "org.springframework.boot:spring-boot-starter-web"
32+
implementation "org.springframework.boot:spring-boot-starter-security"
33+
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
34+
implementation "org.springframework.cloud:spring-cloud-starter-gateway-mvc"
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample;
17+
18+
import org.springframework.boot.SpringApplication;
19+
import org.springframework.boot.autoconfigure.SpringBootApplication;
20+
21+
/**
22+
* @author Joe Grandja
23+
* @since 1.4
24+
*/
25+
@SpringBootApplication
26+
public class BackendForSpaClientApplication {
27+
28+
public static void main(String[] args) {
29+
SpringApplication.run(BackendForSpaClientApplication.class, args);
30+
}
31+
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.config;
17+
18+
import java.util.Arrays;
19+
import java.util.Collections;
20+
21+
import org.springframework.beans.factory.annotation.Value;
22+
import org.springframework.context.annotation.Bean;
23+
import org.springframework.context.annotation.Configuration;
24+
import org.springframework.http.HttpHeaders;
25+
import org.springframework.web.cors.CorsConfiguration;
26+
import org.springframework.web.cors.CorsConfigurationSource;
27+
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
28+
29+
/**
30+
* @author Joe Grandja
31+
* @since 1.4
32+
*/
33+
@Configuration(proxyBeanMethods = false)
34+
public class CorsConfig {
35+
36+
@Value("${app.base-uri}")
37+
private String appBaseUri;
38+
39+
@Bean
40+
public CorsConfigurationSource corsConfigurationSource() {
41+
CorsConfiguration config = new CorsConfiguration();
42+
config.addAllowedHeader("X-XSRF-TOKEN");
43+
config.addAllowedHeader(HttpHeaders.CONTENT_TYPE);
44+
config.setAllowedMethods(Arrays.asList("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"));
45+
config.setAllowedOrigins(Collections.singletonList(this.appBaseUri));
46+
config.setAllowCredentials(true);
47+
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
48+
source.registerCorsConfiguration("/**", config);
49+
return source;
50+
}
51+
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.config;
17+
18+
import org.springframework.cloud.gateway.server.mvc.common.Shortcut;
19+
import org.springframework.cloud.gateway.server.mvc.filter.SimpleFilterSupplier;
20+
import org.springframework.security.core.Authentication;
21+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
22+
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
23+
import org.springframework.security.oauth2.core.OAuth2AccessToken;
24+
import org.springframework.web.servlet.function.HandlerFilterFunction;
25+
import org.springframework.web.servlet.function.ServerRequest;
26+
import org.springframework.web.servlet.function.ServerResponse;
27+
28+
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.getApplicationContext;
29+
30+
/**
31+
* Custom {@code HandlerFilterFunction}'s registered in META-INF/spring.factories and used in application.yml.
32+
*
33+
* @author Joe Grandja
34+
* @since 1.4
35+
*/
36+
public interface GatewayFilterFunctions {
37+
38+
@Shortcut
39+
static HandlerFilterFunction<ServerResponse, ServerResponse> relayTokenIfExists(String clientRegistrationId) {
40+
return (request, next) -> {
41+
Authentication principal = (Authentication) request.servletRequest().getUserPrincipal();
42+
OAuth2AuthorizedClientRepository authorizedClientRepository = getApplicationContext(request)
43+
.getBean(OAuth2AuthorizedClientRepository.class);
44+
OAuth2AuthorizedClient authorizedClient = authorizedClientRepository.loadAuthorizedClient(
45+
clientRegistrationId, principal, request.servletRequest());
46+
if (authorizedClient != null) {
47+
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
48+
ServerRequest bearerRequest = ServerRequest.from(request)
49+
.headers(httpHeaders -> httpHeaders.setBearerAuth(accessToken.getTokenValue())).build();
50+
return next.handle(bearerRequest);
51+
}
52+
return next.handle(request);
53+
};
54+
}
55+
56+
class FilterSupplier extends SimpleFilterSupplier {
57+
58+
FilterSupplier() {
59+
super(GatewayFilterFunctions.class);
60+
}
61+
62+
}
63+
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.config;
17+
18+
import java.util.LinkedHashMap;
19+
20+
import org.springframework.beans.factory.annotation.Value;
21+
import org.springframework.context.annotation.Bean;
22+
import org.springframework.context.annotation.Configuration;
23+
import org.springframework.http.HttpStatus;
24+
import org.springframework.http.MediaType;
25+
import org.springframework.security.config.Customizer;
26+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
27+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
28+
import org.springframework.security.web.AuthenticationEntryPoint;
29+
import org.springframework.security.web.SecurityFilterChain;
30+
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
31+
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
32+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
33+
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
34+
import org.springframework.security.web.authentication.logout.CompositeLogoutHandler;
35+
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
36+
import org.springframework.security.web.authentication.logout.LogoutHandler;
37+
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
38+
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
39+
import org.springframework.security.web.csrf.CsrfLogoutHandler;
40+
import org.springframework.security.web.csrf.CsrfTokenRepository;
41+
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
42+
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
43+
import org.springframework.security.web.util.matcher.RequestMatcher;
44+
45+
/**
46+
* @author Joe Grandja
47+
* @since 1.4
48+
*/
49+
@Configuration(proxyBeanMethods = false)
50+
@EnableWebSecurity
51+
public class SecurityConfig {
52+
53+
@Value("${app.base-uri}")
54+
private String appBaseUri;
55+
56+
@Bean
57+
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
58+
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
59+
CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
60+
/*
61+
IMPORTANT:
62+
Set the csrfRequestAttributeName to null, to opt-out of deferred tokens, resulting in the CsrfToken to be loaded on every request.
63+
If it does not exist, the CookieCsrfTokenRepository will automatically generate a new one and add the Cookie to the response.
64+
See the reference: https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#deferred-csrf-token
65+
*/
66+
csrfTokenRequestAttributeHandler.setCsrfRequestAttributeName(null);
67+
68+
// @formatter:off
69+
http
70+
.authorizeHttpRequests(authorize ->
71+
authorize
72+
.anyRequest().authenticated()
73+
)
74+
.csrf(csrf ->
75+
csrf
76+
.csrfTokenRepository(cookieCsrfTokenRepository)
77+
.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
78+
)
79+
.cors(Customizer.withDefaults())
80+
.exceptionHandling(exceptionHandling ->
81+
exceptionHandling
82+
.authenticationEntryPoint(authenticationEntryPoint())
83+
)
84+
.oauth2Login(oauth2Login ->
85+
oauth2Login
86+
.successHandler(new SimpleUrlAuthenticationSuccessHandler(this.appBaseUri)))
87+
.logout(logout ->
88+
logout
89+
.addLogoutHandler(logoutHandler(cookieCsrfTokenRepository))
90+
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
91+
)
92+
.oauth2Client(Customizer.withDefaults());
93+
// @formatter:on
94+
return http.build();
95+
}
96+
97+
private AuthenticationEntryPoint authenticationEntryPoint() {
98+
AuthenticationEntryPoint authenticationEntryPoint =
99+
new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/messaging-client-oidc");
100+
MediaTypeRequestMatcher textHtmlMatcher =
101+
new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
102+
textHtmlMatcher.setUseEquals(true);
103+
104+
LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
105+
entryPoints.put(textHtmlMatcher, authenticationEntryPoint);
106+
107+
DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints);
108+
delegatingAuthenticationEntryPoint.setDefaultEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
109+
return delegatingAuthenticationEntryPoint;
110+
}
111+
112+
private LogoutHandler logoutHandler(CsrfTokenRepository csrfTokenRepository) {
113+
return new CompositeLogoutHandler(
114+
new SecurityContextLogoutHandler(),
115+
new CsrfLogoutHandler(csrfTokenRepository));
116+
}
117+
118+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.web;
17+
18+
import org.springframework.beans.factory.annotation.Value;
19+
import org.springframework.stereotype.Controller;
20+
import org.springframework.web.bind.annotation.GetMapping;
21+
22+
/**
23+
* @author Joe Grandja
24+
* @since 1.4
25+
*/
26+
@Controller
27+
public class DefaultController {
28+
29+
@Value("${app.base-uri}")
30+
private String appBaseUri;
31+
32+
@GetMapping("/")
33+
public String root() {
34+
return "redirect:" + this.appBaseUri;
35+
}
36+
37+
// '/authorized' is the registered 'redirect_uri' for authorization_code
38+
@GetMapping("/authorized")
39+
public String authorized() {
40+
return "redirect:" + this.appBaseUri;
41+
}
42+
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
org.springframework.cloud.gateway.server.mvc.filter.FilterSupplier=\
2+
sample.config.GatewayFilterFunctions.FilterSupplier

0 commit comments

Comments
 (0)