From a267bff84f5923f87bdb51b34fde95a9cdc0a287 Mon Sep 17 00:00:00 2001 From: SethSorrellDev Date: Thu, 1 Oct 2026 19:37:33 -0400 Subject: [PATCH] Phase 3b: RouteBook validates identity-service JWTs via JWKS, admin allowlist by sub --- pom.xml | 4 + .../seth/routebook/config/SecurityConfig.java | 100 +++++++++++++----- src/main/resources/application.properties | 13 ++- .../RouteBookApiIntegrationTest.java | 69 ++++++------ .../com/seth/routebook/support/TestAuth.java | 29 +++++ src/test/resources/application.properties | 9 +- 6 files changed, 156 insertions(+), 68 deletions(-) create mode 100644 src/test/java/com/seth/routebook/support/TestAuth.java diff --git a/pom.xml b/pom.xml index e2ca93e..d04e8e9 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.postgresql postgresql diff --git a/src/main/java/com/seth/routebook/config/SecurityConfig.java b/src/main/java/com/seth/routebook/config/SecurityConfig.java index 4b4c2eb..619a0a5 100644 --- a/src/main/java/com/seth/routebook/config/SecurityConfig.java +++ b/src/main/java/com/seth/routebook/config/SecurityConfig.java @@ -11,21 +11,35 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfigurationSource; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + /** * Reads (GET) are public - anyone can browse the live demo without - * credentials. Writes (POST/PUT/DELETE) require HTTP Basic auth against - * a single admin account, provisioned in-memory from environment - * variables rather than a database-backed user table, since this app - * has exactly one operator, not a multi-user system. + * credentials. Writes (POST/PUT/DELETE) require a valid identity-service + * access token whose subject is on this app's admin allowlist. + * + * Authentication (who are you?) is delegated to identity-service: tokens + * are verified against its public JWKS. Authorization (what may you do + * here?) stays local to RouteBook. */ @Configuration @EnableWebSecurity @@ -36,28 +50,60 @@ public class SecurityConfig { private final ApiAccessDeniedHandler accessDeniedHandler; private final CorsConfigurationSource corsConfigurationSource; - @Value("${app.security.admin-username}") - private String adminUsername; + @Value("${app.security.jwk-set-uri}") + private String jwkSetUri; + + @Value("${app.security.issuer}") + private String issuer; - @Value("${app.security.admin-password}") - private String adminPassword; + @Value("${app.security.admin-subjects:}") + private String adminSubjects; + /** + * Verifies RS256 signature (public key fetched lazily from the JWKS + * endpoint, so RouteBook still boots if identity-service is down), + * expiry, and issuer. Also rejects anything that isn't an access + * token: refresh tokens are signed with the same key, so signature + * validity alone would let a refresh token act as an access token. + */ @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + public JwtDecoder jwtDecoder() { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri) + .jwsAlgorithm(SignatureAlgorithm.RS256) + .build(); + + OAuth2TokenValidator accessTokenOnly = jwt -> + "access".equals(jwt.getClaimAsString("type")) + ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure( + new OAuth2Error("invalid_token", "Not an access token", null)); + + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuer), accessTokenOnly)); + return decoder; } @Bean - public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { - UserDetails admin = User.withUsername(adminUsername) - .password(passwordEncoder.encode(adminPassword)) - .roles("ADMIN") - .build(); - return new InMemoryUserDetailsManager(admin); + public JwtAuthenticationConverter jwtAuthenticationConverter() { + Set admins = Arrays.stream(adminSubjects.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(jwt -> { + Collection authorities = admins.contains(jwt.getSubject()) + ? List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) + : List.of(); + return authorities; + }); + return converter; } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationConverter jwtAuthenticationConverter) + throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource)) @@ -66,9 +112,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/actuator/**", "/h2-console/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/**").permitAll() - .anyRequest().authenticated() + .anyRequest().hasRole("ADMIN") + ) + .oauth2ResourceServer(oauth -> oauth + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)) + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) ) - .httpBasic(basic -> basic.authenticationEntryPoint(authenticationEntryPoint)) .exceptionHandling(ex -> ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 484efa3..d2e9df7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -41,8 +41,11 @@ spring.servlet.multipart.max-request-size=250MB # CORS - dev frontend runs on Vite's default port app.cors.allowed-origins=http://localhost:5173 -# CORS - dev frontend runs on Vite's default port -app.cors.allowed-origins=http://localhost:5173 - -app.security.admin-username=${ADMIN_USERNAME:admin} -app.security.admin-password=${ADMIN_PASSWORD:admin} +# --- Identity service (shared SSO) --- +# Tokens are issued and signed by identity-service; RouteBook only verifies +# them against its public JWKS. No private key and no password hashes here. +app.security.jwk-set-uri=${IDENTITY_JWKS_URI:http://localhost:8081/.well-known/jwks.json} +app.security.issuer=${IDENTITY_ISSUER:identity-service} +# Comma-separated identity-service user IDs (the JWT "sub") allowed to write. +# Empty means nobody can write. RouteBook owns this authorization decision. +app.security.admin-subjects=${ADMIN_SUBJECTS:} diff --git a/src/test/java/com/seth/routebook/controller/RouteBookApiIntegrationTest.java b/src/test/java/com/seth/routebook/controller/RouteBookApiIntegrationTest.java index 2891171..42986d1 100644 --- a/src/test/java/com/seth/routebook/controller/RouteBookApiIntegrationTest.java +++ b/src/test/java/com/seth/routebook/controller/RouteBookApiIntegrationTest.java @@ -10,7 +10,8 @@ import java.util.Map; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static com.seth.routebook.support.TestAuth.adminJwt; +import static com.seth.routebook.support.TestAuth.nonAdminJwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -68,7 +69,7 @@ void createDriver_withoutCredentials_returns401() throws Exception { } @Test - void createDriver_withWrongCredentials_returns401() throws Exception { + void createDriver_withNonAdminToken_returns403() throws Exception { Map newDriver = Map.of( "employeeId", "EMP-9003", "firstName", "Wrong", @@ -77,10 +78,10 @@ void createDriver_withWrongCredentials_returns401() throws Exception { ); mockMvc.perform(post("/api/drivers") - .with(httpBasic("test-admin", "not-the-real-password")) + .with(nonAdminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newDriver))) - .andExpect(status().isUnauthorized()); + .andExpect(status().isForbidden()); } @Test @@ -93,7 +94,7 @@ void createDriver_thenFetchById_succeeds() throws Exception { ); String response = mockMvc.perform(post("/api/drivers") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newDriver))) .andExpect(status().isOk()) @@ -116,7 +117,7 @@ void createRoute_withNonexistentDriverId_returns404() throws Exception { ); mockMvc.perform(post("/api/routes") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newRoute))) .andExpect(status().isNotFound()) @@ -134,7 +135,7 @@ void createKnowledgeEntry_withBothRouteAndStopId_returns400() throws Exception { ); mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badEntry))) .andExpect(status().isBadRequest()) @@ -151,7 +152,7 @@ void createKnowledgeEntry_withNeitherRouteNorStopId_returns400() throws Exceptio ); mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badEntry))) .andExpect(status().isBadRequest()) @@ -168,7 +169,7 @@ void createKnowledgeEntry_missingTitle_returns400WithFieldError() throws Excepti ); mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badEntry))) .andExpect(status().isBadRequest()) @@ -191,7 +192,7 @@ void fullChain_createLocationStopAndKnowledgeEntry_succeeds() throws Exception { ); String stopResponse = mockMvc.perform(post("/api/routes/1/stops") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newStop))) .andExpect(status().isOk()) @@ -207,7 +208,7 @@ void fullChain_createLocationStopAndKnowledgeEntry_succeeds() throws Exception { ); mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newEntry))) .andExpect(status().isOk()) @@ -221,9 +222,9 @@ void fullChain_createLocationStopAndKnowledgeEntry_succeeds() throws Exception { @Test void verify_withCorrectCredentials_returnsUsername() throws Exception { mockMvc.perform(post("/api/auth/verify") - .with(httpBasic("test-admin", "test-password"))) + .with(adminJwt())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.username").value("test-admin")); + .andExpect(jsonPath("$.username").value("test-admin-subject")); } @Test @@ -234,10 +235,10 @@ void verify_withoutCredentials_returns401() throws Exception { } @Test - void verify_withWrongCredentials_returns401() throws Exception { + void verify_withNonAdminToken_returns403() throws Exception { mockMvc.perform(post("/api/auth/verify") - .with(httpBasic("test-admin", "wrong-password"))) - .andExpect(status().isUnauthorized()); + .with(nonAdminJwt())) + .andExpect(status().isForbidden()); } @Test @@ -250,7 +251,7 @@ void deleteRoute_withoutCredentials_returns401() throws Exception { void updateRoute_changesNameAndDescription() throws Exception { Map newRoute = Map.of("name", "Route to Update", "description", "Original"); String createResponse = mockMvc.perform(post("/api/routes") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newRoute))) .andReturn().getResponse().getContentAsString(); @@ -258,7 +259,7 @@ void updateRoute_changesNameAndDescription() throws Exception { Map updateRequest = Map.of("name", "Updated Name", "description", "Updated description"); mockMvc.perform(put("/api/routes/" + routeId) - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isOk()) @@ -273,7 +274,7 @@ void deleteRoute_cascadesToStopsAndKnowledgeEntriesAndTheirAttachments() throws // shared seeded data other tests in this class depend on. Map newRoute = Map.of("name", "Route to Delete", "description", "temp"); String routeResponse = mockMvc.perform(post("/api/routes") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newRoute))) .andReturn().getResponse().getContentAsString(); @@ -284,7 +285,7 @@ void deleteRoute_cascadesToStopsAndKnowledgeEntriesAndTheirAttachments() throws Map newStop = Map.of( "customerName", "Delete Test Stop", "sequenceOrder", 1, "location", newLocation); String stopResponse = mockMvc.perform(post("/api/routes/" + routeId + "/stops") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newStop))) .andReturn().getResponse().getContentAsString(); @@ -293,7 +294,7 @@ void deleteRoute_cascadesToStopsAndKnowledgeEntriesAndTheirAttachments() throws Map routeNote = Map.of( "title", "Route note", "body", "x", "category", "OTHER", "routeId", routeId); String routeNoteResponse = mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(routeNote))) .andReturn().getResponse().getContentAsString(); @@ -302,7 +303,7 @@ void deleteRoute_cascadesToStopsAndKnowledgeEntriesAndTheirAttachments() throws Map stopNote = Map.of( "title", "Stop note", "body", "x", "category", "OTHER", "stopId", stopId); String stopNoteResponse = mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(stopNote))) .andReturn().getResponse().getContentAsString(); @@ -310,7 +311,7 @@ void deleteRoute_cascadesToStopsAndKnowledgeEntriesAndTheirAttachments() throws // Delete the route - should cascade through everything above. mockMvc.perform(delete("/api/routes/" + routeId) - .with(httpBasic("test-admin", "test-password"))) + .with(adminJwt())) .andExpect(status().isNoContent()); mockMvc.perform(get("/api/routes/" + routeId)).andExpect(status().isNotFound()); @@ -326,7 +327,7 @@ void updateStop_changesCustomerNameAndSequenceOrder() throws Exception { Map newStop = Map.of( "customerName", "Original Name", "sequenceOrder", 5, "location", newLocation); String stopResponse = mockMvc.perform(post("/api/routes/1/stops") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newStop))) .andReturn().getResponse().getContentAsString(); @@ -336,7 +337,7 @@ void updateStop_changesCustomerNameAndSequenceOrder() throws Exception { Map updateRequest = Map.of( "customerName", "Renamed Customer", "sequenceOrder", 9, "locationId", locationId); mockMvc.perform(put("/api/stops/" + stopId) - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isOk()) @@ -351,7 +352,7 @@ void deleteStop_cascadesToItsKnowledgeEntries() throws Exception { Map newStop = Map.of( "customerName", "Stop To Delete", "sequenceOrder", 1, "location", newLocation); String stopResponse = mockMvc.perform(post("/api/routes/1/stops") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newStop))) .andReturn().getResponse().getContentAsString(); @@ -360,14 +361,14 @@ void deleteStop_cascadesToItsKnowledgeEntries() throws Exception { Map note = Map.of( "title", "Note on stop to delete", "body", "x", "category", "OTHER", "stopId", stopId); String noteResponse = mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(note))) .andReturn().getResponse().getContentAsString(); int noteId = objectMapper.readTree(noteResponse).get("id").asInt(); mockMvc.perform(delete("/api/stops/" + stopId) - .with(httpBasic("test-admin", "test-password"))) + .with(adminJwt())) .andExpect(status().isNoContent()); mockMvc.perform(get("/api/stops/" + stopId)).andExpect(status().isNotFound()); @@ -379,7 +380,7 @@ void updateKnowledgeEntry_changingTargetFromRouteToStop_succeeds() throws Except Map note = Map.of( "title", "Retargetable note", "body", "x", "category", "OTHER", "routeId", 1); String noteResponse = mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(note))) .andReturn().getResponse().getContentAsString(); @@ -388,7 +389,7 @@ void updateKnowledgeEntry_changingTargetFromRouteToStop_succeeds() throws Except Map updateRequest = Map.of( "title", "Now targets a stop", "body", "y", "category", "HAZARD", "stopId", 1); mockMvc.perform(put("/api/knowledge-entries/" + noteId) - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isOk()) @@ -402,7 +403,7 @@ void updateKnowledgeEntry_withBothTargets_returns400() throws Exception { Map note = Map.of( "title", "Note", "body", "x", "category", "OTHER", "routeId", 1); String noteResponse = mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(note))) .andReturn().getResponse().getContentAsString(); @@ -411,7 +412,7 @@ void updateKnowledgeEntry_withBothTargets_returns400() throws Exception { Map badUpdate = Map.of( "title", "Bad", "body", "x", "category", "OTHER", "routeId", 1, "stopId", 1); mockMvc.perform(put("/api/knowledge-entries/" + noteId) - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badUpdate))) .andExpect(status().isBadRequest()); @@ -422,14 +423,14 @@ void deleteKnowledgeEntry_removesIt() throws Exception { Map note = Map.of( "title", "Note to delete", "body", "x", "category", "OTHER", "routeId", 1); String noteResponse = mockMvc.perform(post("/api/knowledge-entries") - .with(httpBasic("test-admin", "test-password")) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(note))) .andReturn().getResponse().getContentAsString(); int noteId = objectMapper.readTree(noteResponse).get("id").asInt(); mockMvc.perform(delete("/api/knowledge-entries/" + noteId) - .with(httpBasic("test-admin", "test-password"))) + .with(adminJwt())) .andExpect(status().isNoContent()); mockMvc.perform(get("/api/knowledge-entries/" + noteId)).andExpect(status().isNotFound()); diff --git a/src/test/java/com/seth/routebook/support/TestAuth.java b/src/test/java/com/seth/routebook/support/TestAuth.java new file mode 100644 index 0000000..4d36c8a --- /dev/null +++ b/src/test/java/com/seth/routebook/support/TestAuth.java @@ -0,0 +1,29 @@ +package com.seth.routebook.support; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor; + +/** + * Stand-ins for identity-service tokens. The jwt() post-processor injects an + * already-authenticated principal, so signature verification is skipped here + * (and covered by the end-to-end check against the real service). It also + * bypasses our JwtAuthenticationConverter, so authorities are set explicitly. + */ +public final class TestAuth { + + private TestAuth() {} + + /** A valid user whose subject is on the admin allowlist. */ + public static JwtRequestPostProcessor adminJwt() { + return SecurityMockMvcRequestPostProcessors.jwt() + .jwt(j -> j.subject("test-admin-subject").claim("type", "access")) + .authorities(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + + /** A valid, authenticated user who is NOT on the allowlist. */ + public static JwtRequestPostProcessor nonAdminJwt() { + return SecurityMockMvcRequestPostProcessors.jwt() + .jwt(j -> j.subject("some-other-user").claim("type", "access")); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 3fb3ef8..a915e17 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -16,7 +16,8 @@ cloudflare.r2.presigned-url-expiry-minutes=15 app.cors.allowed-origins=http://localhost:5173 -app.cors.allowed-origins=http://localhost:5173 - -app.security.admin-username=test-admin -app.security.admin-password=test-password +# Tests mock the JWT via spring-security-test, so the decoder never fetches +# this URI. It only has to resolve so the context loads. +app.security.jwk-set-uri=http://localhost:0/unused-in-tests +app.security.issuer=identity-service +app.security.admin-subjects=test-admin-subject