From df23e9e6b8a40fbd14984fcf20355790f33502cc Mon Sep 17 00:00:00 2001
From: farrell-m <168534673+farrell-m@users.noreply.github.com>
Date: Fri, 7 Jun 2024 11:03:59 +0100
Subject: [PATCH] Story: [CCLS 2191] Authentication Starter (#4)
* add auth starter
* publish library
* return JSON response, add logging
* log specific failure reason
* clean up logging and documentation
* fix log
* add access denied handler
* improved logging
* add mermaid graph
* update mermaid graph for clarity
* address review comments
* add openapi security scheme configuration, cleanup
---
build.gradle | 5 +
.../groovy/gradle-plugin-conventions.gradle | 3 +-
.../spring-boot-starter-conventions.gradle | 2 +
laa-ccms-java-gradle-plugin/build.gradle | 5 -
.../build.gradle | 10 +-
.../README.md | 170 ++++++++++++++++++
.../build.gradle | 39 ++++
.../auth/ApiAccessDeniedHandler.java | 64 +++++++
.../auth/ApiAuthenticationFilter.java | 74 ++++++++
.../auth/ApiAuthenticationService.java | 140 +++++++++++++++
.../auth/ApiAuthenticationToken.java | 35 ++++
.../auth/AuthenticationProperties.java | 44 +++++
.../ccms/springboot/auth/AuthorizedRole.java | 9 +
.../springboot/auth/ClientCredential.java | 13 ++
.../ccms/springboot/auth/ErrorResponse.java | 26 +++
.../auth/MissingCredentialsException.java | 27 +++
.../SecurityFilterChainAutoConfiguration.java | 144 +++++++++++++++
.../auth/config/OpenApiConfiguration.java | 39 ++++
...ot.autoconfigure.AutoConfiguration.imports | 1 +
.../auth/ApiEndpointAuthenticationTest.java | 98 ++++++++++
.../gov/laa/ccms/springboot/auth/TestAPI.java | 52 ++++++
.../auth/config/OpenApiConfigurationTest.java | 59 ++++++
.../src/test/resources/application.yml | 45 +++++
settings.gradle | 3 +-
24 files changed, 1091 insertions(+), 16 deletions(-)
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java
create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml
diff --git a/build.gradle b/build.gradle
index 5c1f7f6..22efbf1 100644
--- a/build.gradle
+++ b/build.gradle
@@ -8,6 +8,11 @@ subprojects {
it.jvmArgs = ["--add-opens=java.base/java.lang=ALL-UNNAMED"]
}
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ }
+
apply plugin: 'maven-publish'
publishing.repositories {
diff --git a/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle b/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle
index abc6128..2c3e1dc 100644
--- a/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle
+++ b/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle
@@ -1,5 +1,6 @@
plugins {
id 'groovy'
+ id 'java-gradle-plugin'
}
java {
@@ -7,8 +8,6 @@ java {
}
dependencies {
- implementation gradleApi()
- implementation localGroovy()
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
diff --git a/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle b/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle
index 46e4150..4feebdd 100644
--- a/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle
+++ b/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle
@@ -2,6 +2,8 @@ plugins {
id 'java-library'
}
+group = 'uk.gov.laa.ccms.springboot'
+
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion))
}
diff --git a/laa-ccms-java-gradle-plugin/build.gradle b/laa-ccms-java-gradle-plugin/build.gradle
index 262f189..ebe28aa 100644
--- a/laa-ccms-java-gradle-plugin/build.gradle
+++ b/laa-ccms-java-gradle-plugin/build.gradle
@@ -1,15 +1,10 @@
plugins {
id 'gradle-plugin-conventions'
- id 'java-gradle-plugin'
id 'maven-publish'
}
group = 'uk.gov.laa.ccms.java'
-repositories {
- gradlePluginPortal()
-}
-
dependencies {
implementation "com.github.ben-manes:gradle-versions-plugin:${gradleVersionsPluginVersion}"
}
diff --git a/laa-ccms-spring-boot-gradle-plugin/build.gradle b/laa-ccms-spring-boot-gradle-plugin/build.gradle
index 771f43f..0d0c44d 100644
--- a/laa-ccms-spring-boot-gradle-plugin/build.gradle
+++ b/laa-ccms-spring-boot-gradle-plugin/build.gradle
@@ -1,23 +1,17 @@
plugins {
- id 'java-gradle-plugin'
- id 'maven-publish'
id 'java'
id 'groovy'
+ id 'java-gradle-plugin'
+ id 'maven-publish'
}
group = 'uk.gov.laa.ccms.springboot'
-repositories {
- gradlePluginPortal()
-}
-
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion))
}
dependencies {
- implementation gradleApi()
- implementation localGroovy()
// Make sure we're using the same version of the Java plugin that we're adding into the starters
implementation project(':laa-ccms-java-gradle-plugin')
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md
new file mode 100644
index 0000000..9b81853
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md
@@ -0,0 +1,170 @@
+# LAA CCMS SpringBoot Authentication Starter
+
+This starter will enable authentication on endpoints you have specified in your application configuration.
+Roles can be defined to categorise groups of endpoints under different levels of access. These roles can then be assigned
+to clients.
+
+## Usage
+
+### Declare the dependency
+
+To enable this in your application, declare the following:
+
+```groovy
+dependencies {
+ implementation 'uk.gov.laa.ccms.springboot:laa-ccms-spring-boot-starter-auth'
+}
+```
+
+### Configure via application properties
+
+Here you will need to define several properties to ensure authentication behaves as expected:
+
+- `authentication-header` - The name of the HTTP header used to send and receive the API access token.
+- `authorized-clients` - The list of clients who are authorized to access the API, and their roles. This is a JSON formatted string, with the top level being a list and each contained item representing a client's credentials, containing the name of the client, the roles it has access to and the access token associated with it.
+- `authorized-roles` - The list of roles that can be used to access the API, and the URIs they enable access to. This is a JSON formatted string, with the top level being a list and each contained item representing an authorized role, containing the name of the role and the URIs that it enables access to.
+- `unprotected-uris` - The list of URIs which do not require any authentication. These may be relating to API documentation, static resources or any other content which is not sensitive.
+
+Access tokens should be generated as a `UUID4` string.
+
+```yaml
+laa.ccms.springboot.starter.auth:
+ authentication-header: "Authorization"
+ authorized-clients: '[
+ {
+ "name": "client1",
+ "roles": [
+ "GROUP1"
+ ],
+ "token": "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf"
+ },
+ {
+ "name": "client2",
+ "roles": [
+ "GROUP2"
+ ],
+ "token": "1fd84ad9-760d-401f-8cf0-7a80aa42566c"
+ },
+ {
+ "name": "client3",
+ "roles": [
+ "GROUP1",
+ "GROUP2"
+ ],
+ "token": "5d925478-a8a2-4b76-863a-3fb87dcbcb95"
+ }
+ ]'
+ authorized-roles: '[
+ {
+ "name": "GROUP1",
+ "URIs": [
+ "/resource1/requires-group1-role/**"
+ ]
+ },
+ {
+ "name": "GROUP2",
+ "URIs": [
+ "/*/requires-group2-role/**"
+ ]
+ }
+ ]'
+ unprotected-uris: [ "/actuator/**", "/resource1/unrestricted/**" ]
+```
+
+## Behaviour
+
+Authentication of endpoints will behave as follows.
+
+### Unprotected URIs
+
+Unprotected URIs will not require any authentication. Authentication headers will be ignored.
+
+### Protected URIs
+
+If a client attempts to access a protected URI, they will receive one of 3 responses depending on the scenario:
+
+- Invalid or no access token present / wrong header used: 401 Unauthorized
+- Valid access token present, client's role **does not** permit access to the requested URI or the URI does not exist: 403 Forbidden
+- Valid access token present, client's role **does** permit access to the requested URI: 2XX (Success) / normal response
+
+```mermaid
+graph
+
+ subgraph key["Key"]
+ green["Spring Security"]
+ blue["Auth Starter"]
+ end
+
+ client["Client"]
+
+ subgraph api["API"]
+
+ subgraph filterChain["Filter Chain"]
+ authenticationFilter["API Authentication Filter"]
+ authorizationFilter["Authorization Filter"]
+ end
+
+ authenticationService["API Authentication Service"]
+
+ authorizationM["Authorization Manager"]
+ rmdAuthorizationM["RequestMatcherDelegatingAuthorizationManager"]
+ authorityAuthorizationM["Authority Authorization Manager"]
+
+ authorizationCheck{"Client
Authorized?"}
+ accessDeniedHandler["Access Denied Handler"]
+ businessLogic["Business Logic"]
+
+ subgraph securityContext["Security Context"]
+ creds["Credentials"]
+ end
+
+ authenticationCheck{"Client
Authenticated?"}
+
+ end
+
+ client -- START
1. Request (protected endpoint) --> authenticationFilter
+
+ authenticationFilter -- 2. Create authentication token --> authenticationService
+
+ authenticationFilter -- 3. check authentication --> authenticationCheck
+
+ authenticationCheck -- 4a. Yes - Store authentication token --> creds
+
+ authenticationCheck -- 4b. No - 401 Unauthorized --> client
+
+ authenticationFilter -- 5. doFilter --> authorizationFilter
+
+ authorizationFilter -- 6. Get authentication token --> creds
+
+ authorizationFilter -- 7. Check authorization --> authorizationM
+
+ authorizationM --> rmdAuthorizationM
+
+ rmdAuthorizationM -- 8. Identify matching request mapping--> rmdAuthorizationM
+
+ rmdAuthorizationM --> authorityAuthorizationM
+
+ authorityAuthorizationM -- 9. Compare client's role
against role required
for endpoint --> authorityAuthorizationM
+
+ authorityAuthorizationM --> authorizationCheck
+
+ authorizationCheck -- 10a. No --> accessDeniedHandler
+ accessDeniedHandler -- 11a. 403 Forbidden --> client
+
+ authorizationCheck -- 10b. Yes --> businessLogic
+ businessLogic -- 11b. Normal response --> client
+
+
+ classDef green fill:#206020,stroke:#333,stroke-width:2px;
+ classDef blue fill:#002db3,stroke:#333,stroke-width:2px;
+ class green,authorizationFilter,authM,authP,providerM,securityContext,creds,authorizationM,rmdAuthorizationM,authorizationCheck,authorityAuthorizationM green
+ class blue,authenticationFilter,authenticationService,accessDeniedHandler,authenticationCheck blue
+ linkStyle 0 stroke-width:3px,stroke:black,color:black
+
+ linkStyle 4 stroke:red,color:red
+ linkStyle 14 stroke:red,color:red
+ linkStyle 16 stroke:green,color:green
+
+```
+
+
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle
new file mode 100644
index 0000000..0776a33
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle
@@ -0,0 +1,39 @@
+plugins {
+ id 'spring-boot-starter-conventions'
+}
+
+dependencies {
+
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ implementation(project(':laa-ccms-java-gradle-plugin')) {
+ transitive = false
+ }
+
+ implementation 'io.swagger.core.v3:swagger-models:2.2.22'
+
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+
+ implementation 'jakarta.servlet:jakarta.servlet-api'
+
+ implementation 'jakarta.ws.rs:jakarta.ws.rs-api'
+
+ implementation 'com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-json-provider'
+
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ testImplementation 'org.assertj:assertj-core:3.4.1'
+}
+
+publishing.publications {
+ library(MavenPublication) {
+ from components.java
+ }
+}
+
+
+test {
+ useJUnitPlatform()
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java
new file mode 100644
index 0000000..bde9820
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java
@@ -0,0 +1,64 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.ws.rs.core.Response;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+/**
+ * Exception Handler for requests that have been authenticated, but do not have sufficient privileges to access
+ * the requested endpoint.
+ */
+@Slf4j
+@Component
+public class ApiAccessDeniedHandler implements AccessDeniedHandler {
+
+ ObjectMapper objectMapper;
+
+ /**
+ * Creates an instance of the handler, with an object mapper to write the request body.
+ *
+ * @param objectMapper for writing the request body.
+ */
+ @Autowired
+ ApiAccessDeniedHandler(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Constructs the response object to return to the client, with a 403 Forbidden status and matching
+ * response body using the {@link ErrorResponse} model.
+ *
+ * @param request that resulted in an AccessDeniedException
+ * @param response so that the client can be advised of the failure
+ * @param accessDeniedException that caused the invocation
+ * @throws IOException -
+ * @throws ServletException -
+ */
+ @Override
+ public void handle(HttpServletRequest request, HttpServletResponse response,
+ AccessDeniedException accessDeniedException) throws IOException, ServletException {
+ int code = HttpServletResponse.SC_FORBIDDEN;
+ response.setStatus(code);
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+
+ String status = Response.Status.FORBIDDEN.getReasonPhrase();
+ String message = accessDeniedException.getMessage();
+
+ ErrorResponse errorResponse = new ErrorResponse(code, status, message);
+
+ response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
+
+ log.info("Request rejected for endpoint '{} {}': {}", request.getMethod(), request.getRequestURI(), message);
+ }
+
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java
new file mode 100644
index 0000000..008a85f
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java
@@ -0,0 +1,74 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.ws.rs.core.Response;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.GenericFilterBean;
+
+import java.io.IOException;
+
+/**
+ * API access token authentication filter.
+ */
+@Slf4j
+public class ApiAuthenticationFilter extends GenericFilterBean {
+
+ ApiAuthenticationService authenticationService;
+
+ ObjectMapper objectMapper;
+
+ @Autowired
+ protected ApiAuthenticationFilter(ApiAuthenticationService authenticationService, ObjectMapper objectMapper) {
+ this.authenticationService = authenticationService;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Filter reponsible for authenticating the client which made the request. Successful authentication results in the
+ * authentication details being stored in the security context for further processing, and continuation of the
+ * filter chain. Unsuccessful authentication results in a 401 UNAUTHORIZED response.
+ *
+ * @param request the http request object
+ * @param response the http response object
+ * @param filterChain the current filter chain
+ * @throws IOException -
+ * @throws ServletException -
+ */
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
+ throws IOException, ServletException {
+ try {
+ Authentication authentication = authenticationService.getAuthentication((HttpServletRequest) request);
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ log.info("Endpoint '{} {}' requested by {}.", ((HttpServletRequest) request).getMethod(),
+ ((HttpServletRequest) request).getRequestURI(), authentication.getPrincipal().toString());
+ filterChain.doFilter(request, response);
+ } catch (AuthenticationException ex) {
+ int code = HttpServletResponse.SC_UNAUTHORIZED;
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+ httpResponse.setStatus(code);
+ httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
+
+ String status = Response.Status.UNAUTHORIZED.getReasonPhrase();
+ String message = ex.getMessage();
+
+ ErrorResponse errorResponse = new ErrorResponse(code, status, message);
+
+ httpResponse.getWriter().write(objectMapper.writeValueAsString(errorResponse));
+
+ log.info("Request rejected for endpoint '{} {}': {}", ((HttpServletRequest) request).getMethod(),
+ ((HttpServletRequest) request).getRequestURI(), message);
+ }
+ }
+}
\ No newline at end of file
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java
new file mode 100644
index 0000000..e42525c
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java
@@ -0,0 +1,140 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PostConstruct;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.InvalidPropertyException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * API authentication service responsible for determining the authentication outcome for a given client request.
+ */
+@Slf4j
+@Component
+@EnableConfigurationProperties(AuthenticationProperties.class)
+public class ApiAuthenticationService {
+
+ private final AuthenticationProperties authenticationProperties;
+
+ private Set clientCredentials;
+
+ @Autowired
+ protected ApiAuthenticationService(AuthenticationProperties authenticationProperties) {
+ this.authenticationProperties = authenticationProperties;
+ }
+
+ /**
+ * Initialise a set of {@link ClientCredential} from those configured as a JSON string in the application
+ * properties.
+ */
+ @PostConstruct
+ private void initialise() {
+ try {
+ clientCredentials = new ObjectMapper().readValue(authenticationProperties.getAuthorizedClients()
+ , new TypeReference>(){});
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (clientCredentials.isEmpty()) throw new InvalidPropertyException(AuthenticationProperties.class,
+ "authorizedClients", "At least one authorized client must be provided.");
+
+ for (ClientCredential clientCredential : clientCredentials) {
+ log.info("Authorized Client Registered: '{}' Roles: {}", clientCredential.name(), clientCredential.roles().toString());
+ }
+ }
+
+ /**
+ * Authenticate the HTTP request, comparing the access token provided by the client against the list of authorized
+ * client details configured in the application properties.
+ *
+ * @param request the HTTP request made to the API
+ * @return {@link Authentication} outcome with the list of roles assumed by the client if successful
+ * @throws BadCredentialsException when authentication fails
+ */
+ protected Authentication getAuthentication(HttpServletRequest request) {
+ String accessToken = request.getHeader(authenticationProperties.getAuthenticationHeader());
+
+ if (accessToken == null) {
+ throw new MissingCredentialsException("No API access token provided.");
+ }
+
+ if (!isAuthorizedAccessToken(accessToken)) {
+ throw new BadCredentialsException("Invalid API access token provided.");
+ }
+
+ List grantedAuthorities = getClientRoles(accessToken)
+ .stream()
+ .map(role -> "ROLE_" + role)
+ .map(SimpleGrantedAuthority::new)
+ .collect(Collectors.toUnmodifiableList());
+
+ if (grantedAuthorities.isEmpty()) grantedAuthorities = AuthorityUtils.NO_AUTHORITIES;
+
+ return new ApiAuthenticationToken(getPrincipal(accessToken), accessToken, grantedAuthorities);
+ }
+
+ /**
+ * Retrieve a list of roles associated with the client, based on the access token provided. If the client is
+ * not in the authorized list, no roles are returned.
+ *
+ * @param accessToken the client-provided access token
+ * @return the list of roles associated with the access token, if authorized
+ */
+ private Set getClientRoles(String accessToken) {
+ return getMatchingClientCredential(accessToken)
+ .map(ClientCredential::roles)
+ .orElse(Collections.emptySet());
+ }
+
+ /**
+ * Determine whether an access token is authorized.
+ *
+ * @param accessToken the client-provided access token
+ * @return {@code true} if the access token is authorized and {@code false} otherwise.
+ */
+ private boolean isAuthorizedAccessToken(String accessToken) {
+ return getMatchingClientCredential(accessToken)
+ .isPresent();
+ }
+
+ /**
+ * Retrieve the principal (client name) based on the access token provided.
+ *
+ * @param accessToken the client-provided access token
+ * @return the principal (client name) associated with the access token
+ */
+ private String getPrincipal(String accessToken) {
+ return getMatchingClientCredential(accessToken)
+ .map(ClientCredential::name)
+ .orElse(null);
+ }
+
+ /**
+ * Retrieve the client details based on the access token provided.
+ *
+ * @param accessToken the client-provided access token
+ * @return the {@link ClientCredential} associated with the access token
+ */
+ private Optional getMatchingClientCredential(String accessToken) {
+ return clientCredentials.stream()
+ .filter(credential -> credential.token().equals(accessToken))
+ .findFirst();
+ }
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java
new file mode 100644
index 0000000..9ac44fe
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java
@@ -0,0 +1,35 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+/**
+ * The API {@link Authentication} token representing a successfully authenticated client.
+ */
+public class ApiAuthenticationToken extends AbstractAuthenticationToken {
+
+ private final String clientName;
+ private final String accessToken;
+
+ public ApiAuthenticationToken(String clientName, String accessToken,
+ Collection extends GrantedAuthority> authorities) {
+ super(authorities);
+ this.clientName = clientName;
+ this.accessToken = accessToken;
+ this.setAuthenticated(true);
+ }
+
+ @Override
+ public Object getCredentials() {
+ return accessToken;
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return clientName;
+ }
+
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java
new file mode 100644
index 0000000..611f483
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java
@@ -0,0 +1,44 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Authentication properties that must be configured by an API that is implementing authentication using this library.
+ */
+@Getter
+@Setter
+@ConfigurationProperties(prefix = "laa.ccms.springboot.starter.auth")
+public class AuthenticationProperties {
+
+ /**
+ * The name of the HTTP header used to store the API access token.
+ */
+ @NotBlank(message = "authenticationHeader is required")
+ private String authenticationHeader;
+
+ /**
+ * The list of clients who are authorized to access the API, and their roles
+ * JSON formatted string, with the top level being a list and each contained item
+ * representing a {@link ClientCredential}.
+ */
+ @NotBlank(message = "authorizedClients is required")
+ private String authorizedClients;
+
+ /**
+ * The list of roles that can be used to access the API, and the URIs they enable access to.
+ * JSON formatted string, with the top level being a list and each contained item representing
+ * an {@link AuthorizedRole}.
+ */
+ @NotBlank(message = "authorizedRoles is required")
+ private String authorizedRoles;
+
+ /**
+ * The list of URIs which do not require any authentication.
+ */
+ @NotBlank(message = "unprotectedURIs is required")
+ private String[] unprotectedURIs;
+
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java
new file mode 100644
index 0000000..486249a
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java
@@ -0,0 +1,9 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+/**
+ * Holds a list of URIs available for a given role.
+ *
+ * @param name the name of the role
+ * @param URIs the URIs that are accessible to clients that have this role
+ */
+public record AuthorizedRole(String name, String[] URIs) { }
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java
new file mode 100644
index 0000000..0cb6e2e
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java
@@ -0,0 +1,13 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import java.util.Set;
+
+/**
+ * Holds reference authentication information for one client, populated from details configured in application
+ * properties.
+ *
+ * @param name the name of the client
+ * @param token the access token designated to the client
+ * @param roles the roles designated to the client, which determine level of access
+ */
+public record ClientCredential(String name, String token, Set roles) { }
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java
new file mode 100644
index 0000000..b0de1b2
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java
@@ -0,0 +1,26 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+public class ErrorResponse {
+
+ /**
+ * The HTTP Status code
+ */
+ private int code;
+
+ /**
+ * The HTTP Status description
+ */
+ private String status;
+
+ /**
+ * A message providing further information about the error
+ */
+ private String message;
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java
new file mode 100644
index 0000000..9d5ade4
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java
@@ -0,0 +1,27 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+
+import org.springframework.security.core.AuthenticationException;
+
+public class MissingCredentialsException extends AuthenticationException {
+
+ /**
+ * Creates an instance of {@Code MissingCredentialsException} with a message and original cause.
+ *
+ * @param msg a message providing information about the exception
+ * @param cause the original cause of this exception
+ */
+ public MissingCredentialsException(String msg, Throwable cause) {
+ super(msg, cause);
+ }
+
+ /**
+ * Creates an instance of {@Code MissingCredentialsException} with a message.
+ *
+ * @param msg a message providing information about the exception
+ */
+ public MissingCredentialsException(String msg) {
+ super(msg);
+ }
+
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java
new file mode 100644
index 0000000..8a22977
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java
@@ -0,0 +1,144 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.InvalidPropertyException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureOrder;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+import java.util.Set;
+
+
+/**
+ * Configuration of security filter chains to determine authentication behavior per endpoint (group).
+ * See HTTP Security Configuration.
+ */
+@Slf4j
+@AutoConfiguration
+@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
+@ComponentScan
+@EnableConfigurationProperties(AuthenticationProperties.class)
+public class SecurityFilterChainAutoConfiguration {
+
+ private final AuthenticationProperties authenticationProperties;
+
+ private final ApiAuthenticationService apiAuthenticationService;
+
+ private Set authorizedRoles;
+
+ private final ObjectMapper objectMapper;
+
+ @Autowired
+ public SecurityFilterChainAutoConfiguration(AuthenticationProperties authenticationProperties,
+ ApiAuthenticationService apiAuthenticationService,
+ ObjectMapper objectMapper) {
+ this.authenticationProperties = authenticationProperties;
+ this.apiAuthenticationService = apiAuthenticationService;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Initialise a set of {@link AuthorizedRole} from those configured as a JSON string in the application properties.
+ */
+ @PostConstruct
+ private void initialise() {
+
+ try {
+ authorizedRoles = new ObjectMapper().readValue(authenticationProperties.getAuthorizedRoles()
+ , new TypeReference>(){});
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (authorizedRoles.isEmpty()) throw new InvalidPropertyException(AuthenticationProperties.class,
+ "authorizedRoles", "At least one authorized role must be provided.");
+
+ for (AuthorizedRole authorizedRole : authorizedRoles) {
+ log.info("Authorized Role Registered: '{}'", authorizedRole.name());
+ }
+ }
+
+
+ /**
+ * First security filter chain to allow requests to unprotected URLs regardless of whether authentication
+ * credentials have been provided.
+ *
+ * @param httpSecurity web based security configuration customizer
+ * @return The {@link SecurityFilterChain} to continue with successive security filters.
+ * @throws Exception -
+ */
+ @Bean
+ @Order(1)
+ public SecurityFilterChain filterUnprotectedURIs(HttpSecurity httpSecurity) throws Exception {
+
+ httpSecurity.securityMatcher(authenticationProperties.getUnprotectedURIs())
+ .authorizeHttpRequests(authorize -> authorize
+ .anyRequest().permitAll()
+ )
+ .csrf(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .logout(AbstractHttpConfigurer::disable)
+ .sessionManagement(sessionManagement ->
+ sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ );
+
+ return httpSecurity.build();
+
+ }
+
+ /**
+ * Second security filter chain to authenticate against endpoints based on roles configured in application
+ * properties.
+ *
+ * @param httpSecurity web based security configuration customizer
+ * @return The {@link SecurityFilterChain} to continue with successive security filters.
+ * @throws Exception -
+ */
+ @Bean
+ @Order(2)
+ public SecurityFilterChain filterProtectedURIs(HttpSecurity httpSecurity) throws Exception {
+
+ httpSecurity.authorizeHttpRequests(customizer -> {
+ for (AuthorizedRole authorizedRole : authorizedRoles) {
+ customizer.requestMatchers(authorizedRole.URIs()).hasRole(authorizedRole.name());
+ }
+ // Deny requests to any other endpoint by default
+ customizer.anyRequest().denyAll();
+ }
+ );
+
+ ApiAuthenticationFilter apiAuthenticationFilter = new ApiAuthenticationFilter(apiAuthenticationService,
+ objectMapper);
+
+ httpSecurity.csrf(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .logout(AbstractHttpConfigurer::disable)
+ .sessionManagement(sessionManagement ->
+ sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ )
+ .addFilterBefore(apiAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .exceptionHandling(exceptionHandling -> {
+ exceptionHandling.accessDeniedHandler(new ApiAccessDeniedHandler(objectMapper));
+ });
+
+ return httpSecurity.build();
+
+ }
+
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java
new file mode 100644
index 0000000..38c2824
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java
@@ -0,0 +1,39 @@
+package uk.gov.laa.ccms.springboot.auth.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Slf4j
+@Configuration
+@ConditionalOnProperty(value = "laa.ccms.springboot.starter.open-api.security-scheme.enabled", matchIfMissing = true)
+public class OpenApiConfiguration {
+
+ @Value("${laa.ccms.springboot.starter.auth.authentication-header}")
+ String authenticationHeader;
+
+ @Bean
+ public OpenAPI openAPI() {
+ String securitySchemeName = "ApiKeyAuth";
+ OpenAPI openApiSpec = new OpenAPI()
+ .components(
+ new Components()
+ .addSecuritySchemes(securitySchemeName,
+ new SecurityScheme()
+ .type(SecurityScheme.Type.APIKEY)
+ .in(SecurityScheme.In.HEADER)
+ .name(authenticationHeader)))
+ .addSecurityItem(
+ new SecurityRequirement()
+ .addList(securitySchemeName));
+ log.info("OpenAPI Security Scheme '{}' added for all endpoints.", securitySchemeName);
+ return openApiSpec;
+ }
+
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 0000000..e32432a
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+uk.gov.laa.ccms.springboot.auth.SecurityFilterChainAutoConfiguration
\ No newline at end of file
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java
new file mode 100644
index 0000000..ded5c92
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java
@@ -0,0 +1,98 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+public class ApiEndpointAuthenticationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ public void testNonExistentEndpointUnauthorized() throws Exception {
+ mockMvc.perform(get("/resource1/restricted/invalid"))
+ .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()));
+ }
+
+ @Test
+ public void testInvalidUnrestrictedEndpointNotFound() throws Exception {
+ mockMvc.perform(get("/resource1/unrestricted/invalid"))
+ .andExpect(status().is(HttpStatus.NOT_FOUND.value()));
+ }
+
+ @Test
+ public void testRestrictedEndpointInvalidTokenUnauthorized() throws Exception {
+ mockMvc.perform(get("/resource1/restricted").header(HttpHeaders.AUTHORIZATION, "invalid-token"))
+ .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()));
+ }
+
+ @Test
+ public void testRestrictedEndpointInvalidRoleForbidden() throws Exception {
+ mockMvc.perform(get("/resource1/restricted").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf"))
+ .andExpect(status().is(HttpStatus.FORBIDDEN.value()));
+ }
+
+ @Test
+ public void testGroup1EndpointValidTokenAuthorized() throws Exception {
+ mockMvc.perform(get("/resource1/requires-group1-role").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf"))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
+ }
+
+ @Test
+ public void testGroup1EndpointInvalidRoleForbidden() throws Exception {
+ mockMvc.perform(get("/resource1/requires-group1-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c"))
+ .andExpect(status().is(HttpStatus.FORBIDDEN.value()));
+ }
+
+ @Test
+ public void testGroup2EndpointValidTokenAuthorized() throws Exception {
+ mockMvc.perform(get("/resource1/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c"))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
+ }
+
+ @Test
+ public void testGroup2EndpointInvalidRoleForbidden() throws Exception {
+ mockMvc.perform(get("/resource1/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf"))
+ .andExpect(status().is(HttpStatus.FORBIDDEN.value()));
+ }
+
+ @Test
+ public void testUnrestrictedEndpointNoTokenAuthorized() throws Exception {
+ mockMvc.perform(get("/resource1/unrestricted"))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
+ }
+
+ @Test
+ public void testClientWithMultipleRolesAuthorized() throws Exception {
+ mockMvc.perform(get("/resource1/requires-group1-role").header(HttpHeaders.AUTHORIZATION, "5d925478-a8a2-4b76-863a-3fb87dcbcb95"))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
+
+ mockMvc.perform(get("/resource2/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "5d925478-a8a2-4b76-863a-3fb87dcbcb95"))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
+ }
+
+ @Test
+ public void testRoleWithMultipleEndpointsAuthorized() throws Exception {
+ mockMvc.perform(get("/resource1/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c"))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
+
+ mockMvc.perform(get("/resource2/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c"))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()));
+ }
+
+ @Test void testNonExistentRestrictedEndpointNotFound() throws Exception {
+ mockMvc.perform(get("/resource1/requires-group1-role/does-not-exist").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf"))
+ .andExpect(status().is(HttpStatus.NOT_FOUND.value()));
+ }
+
+}
\ No newline at end of file
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java
new file mode 100644
index 0000000..aba11f1
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java
@@ -0,0 +1,52 @@
+package uk.gov.laa.ccms.springboot.auth;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@SpringBootApplication
+public class TestAPI {
+
+ @RestController
+ @RequestMapping(value = "/resource1")
+ public class TestResource1Controller {
+
+ @GetMapping(path = "/unrestricted")
+ public ResponseEntity> returnUnrestricted() {
+ return noContent();
+ }
+
+ @GetMapping(path = "/restricted")
+ public ResponseEntity> returnRestricted() {
+ return noContent();
+ }
+
+ @GetMapping(path = "/requires-group1-role")
+ public ResponseEntity> returnGroup1RoleRestricted() {
+ return noContent();
+ }
+
+ @GetMapping(path = "/requires-group2-role")
+ public ResponseEntity> returnGroup2RoleRestricted() {
+ return noContent();
+ }
+
+ }
+
+ @RestController
+ @RequestMapping(value = "/resource2")
+ public class TestResource2Controller {
+
+ @GetMapping(path = "/requires-group2-role")
+ public ResponseEntity> returnGroup2RoleRestricted() {
+ return noContent();
+ }
+
+ }
+
+ private ResponseEntity> noContent() {
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java
new file mode 100644
index 0000000..f34b8ff
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java
@@ -0,0 +1,59 @@
+package uk.gov.laa.ccms.springboot.auth.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class OpenApiConfigurationTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withBean(OpenApiConfiguration.class)
+ .withPropertyValues("laa.ccms.springboot.starter.auth.authentication-header=Authorization");
+
+ private final String OPEN_API_CONFIGURATION_BEAN = "openApiConfiguration";
+ private final String OPEN_API_BEAN = "openAPI";
+ private final String SECURITY_SCHEME_NAME = "ApiKeyAuth";
+
+ @Test
+ void testOpenApiBeanIsCreatedWhenApplicationPropertyOmitted() {
+ contextRunner.run((context) -> {
+ assertThat(context).hasBean(OPEN_API_CONFIGURATION_BEAN);
+ assertSecuritySchemeApplied(context);
+ });
+ }
+
+ @Test
+ void testOpenApiBeanIsCreatedWhenApplicationPropertyEnabled() {
+ contextRunner.withPropertyValues("laa.ccms.springboot.starter.open-api.security-scheme.enabled=true").run((context) -> {
+ assertThat(context).hasBean(OPEN_API_CONFIGURATION_BEAN);
+ assertSecuritySchemeApplied(context);
+ });
+ }
+
+ @Test
+ void testNoOpenApiBeanIsCreatedWhenApplicationPropertyDisabled() {
+ contextRunner.withPropertyValues("laa.ccms.springboot.starter.open-api.security-scheme.enabled=false").run((context) -> {
+ assertThat(context).doesNotHaveBean(OPEN_API_CONFIGURATION_BEAN);
+ assertThat(context).doesNotHaveBean(OPEN_API_BEAN);
+ });
+ }
+
+ private void assertSecuritySchemeApplied(AssertableApplicationContext context) {
+ OpenAPI openApiSpec = context.getBean(OPEN_API_BEAN, OpenAPI.class);
+ assertThat(openApiSpec.getComponents().getSecuritySchemes()).isEqualTo(Map.of(SECURITY_SCHEME_NAME, getSecurityScheme()));
+ }
+
+ private SecurityScheme getSecurityScheme() {
+ return new SecurityScheme()
+ .type(SecurityScheme.Type.APIKEY)
+ .in(SecurityScheme.In.HEADER)
+ .name("Authorization");
+ }
+
+}
diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml
new file mode 100644
index 0000000..7a453be
--- /dev/null
+++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml
@@ -0,0 +1,45 @@
+laa.ccms.springboot.starter.auth:
+ authentication-header: "Authorization"
+ authorized-clients: '[
+ {
+ "name": "client1",
+ "roles": [
+ "GROUP1"
+ ],
+ "token": "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf"
+ },
+ {
+ "name": "client2",
+ "roles": [
+ "GROUP2"
+ ],
+ "token": "1fd84ad9-760d-401f-8cf0-7a80aa42566c"
+ },
+ {
+ "name": "client3",
+ "roles": [
+ "GROUP1",
+ "GROUP2"
+ ],
+ "token": "5d925478-a8a2-4b76-863a-3fb87dcbcb95"
+ }
+ ]'
+ authorized-roles: '[
+ {
+ "name": "GROUP1",
+ "URIs": [
+ "/resource1/requires-group1-role/**"
+ ]
+ },
+ {
+ "name": "GROUP2",
+ "URIs": [
+ "/*/requires-group2-role/**"
+ ]
+ }
+ ]'
+ unprotected-uris: [ "/actuator/**", "/resource1/unrestricted/**" ]
+
+logging:
+ level:
+ root: INFO
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 8042e0e..fc9a209 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -2,5 +2,6 @@ rootProject.name = 'laa-ccms-spring-boot-common'
include ':laa-ccms-spring-boot-dependencies'
include ':laa-ccms-spring-boot-gradle-plugin'
-include ':laa-ccms-spring-boot-starters'
include ':laa-ccms-java-gradle-plugin'
+include ':laa-ccms-spring-boot-starters'
+include ':laa-ccms-spring-boot-starters:laa-ccms-spring-boot-starter-auth'