Skip to content

Commit

Permalink
Add SPA sample using BFF and Spring Cloud Gateway
Browse files Browse the repository at this point in the history
  • Loading branch information
jgrandja committed Nov 19, 2024
1 parent 3a63bf6 commit a6d9c19
Show file tree
Hide file tree
Showing 30 changed files with 14,035 additions and 0 deletions.
20 changes: 20 additions & 0 deletions samples/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions samples/backend-for-spa-client/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
spring-security.version=6.3.0
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}

}
Original file line number Diff line number Diff line change
@@ -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));
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springframework.cloud.gateway.server.mvc.filter.FilterSupplier=\
sample.config.GatewayFilterFunctions.FilterSupplier
Loading

0 comments on commit a6d9c19

Please sign in to comment.