From 1a48600d4ae5367d2f0a67d84a1e2e8a3704bbb9 Mon Sep 17 00:00:00 2001 From: Lucas Ramage Date: Sun, 24 May 2026 19:55:42 -0500 Subject: [PATCH 1/3] fix(server): support browser extension auth via Bearer token - Add chrome-extension:// and moz-extension:// to CORS allowed origin patterns so the extension can make credentialed requests - Fix UserContext.getUserId() to handle JwtAuthenticationToken (set by oauth2ResourceServer for Bearer token requests) in addition to the existing UserAuthenticationToken (set by the cookie filter); the hard cast was causing a ClassCastException and 500 on all extension API calls --- .../java/dev/findfirst/FindFirstApplication.java | 5 +++-- .../security/userauth/context/UserContext.java | 13 +++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/dev/findfirst/FindFirstApplication.java b/server/src/main/java/dev/findfirst/FindFirstApplication.java index ba0d3c1e..5f1427c6 100644 --- a/server/src/main/java/dev/findfirst/FindFirstApplication.java +++ b/server/src/main/java/dev/findfirst/FindFirstApplication.java @@ -47,8 +47,9 @@ public FilterRegistrationBean simpleCorsFilter() { config.setAllowCredentials(true); // *** URL below needs to match the Vue client URL and port *** // Local host and 127.0.0.1 are the same - config.setAllowedOrigins(Arrays.asList("https://localhost:3000", "http://localhost:3000", - "https://findfirst.dev", "http://localhost", "http://127.0.0.1")); + config.setAllowedOriginPatterns(Arrays.asList("https://localhost:3000", "http://localhost:3000", + "https://findfirst.dev", "http://localhost", "http://127.0.0.1", + "chrome-extension://*", "moz-extension://*")); config.setAllowedMethods(Collections.singletonList("*")); config.setAllowedHeaders(Collections.singletonList("*")); source.registerCorsConfiguration("/**", config); diff --git a/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java b/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java index 84ca5c2c..0a8dd594 100644 --- a/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java +++ b/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java @@ -2,14 +2,23 @@ import dev.findfirst.security.jwt.UserAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Component; @Component public class UserContext { public int getUserId() { - return ((UserAuthenticationToken) SecurityContextHolder.getContext().getAuthentication()) - .getUserId(); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof UserAuthenticationToken uat) { + return uat.getUserId(); + } + if (auth instanceof JwtAuthenticationToken jat) { + Number userId = jat.getToken().getClaim("userId"); + return userId.intValue(); + } + throw new IllegalStateException("Unexpected authentication type: " + auth.getClass()); } } From a4eec29718e3721a4159f2923ae884e49bc2db2b Mon Sep 17 00:00:00 2001 From: Lucas Ramage Date: Sun, 24 May 2026 20:21:26 -0500 Subject: [PATCH 2/3] fix(frontend): resolve failing SonarQube frontend test suite - Mock next/font/google in vitestSetup to fix Libre_Baskerville not-a-function error in app.test.tsx - Always render brand logo in Navbar (remove isMobile conditional that hid it on desktop); drop unused isMobile state and resize effect - Update default avatar test to assert SVG icon presence instead of stale img/src expectation --- frontend/__tests__/Navbar/Navbar.test.tsx | 13 +------- frontend/components/Navbar/Navbar.tsx | 39 +++++++---------------- frontend/vitestSetup.ts | 5 +++ 3 files changed, 18 insertions(+), 39 deletions(-) diff --git a/frontend/__tests__/Navbar/Navbar.test.tsx b/frontend/__tests__/Navbar/Navbar.test.tsx index 4fdfef39..dee9e6e0 100644 --- a/frontend/__tests__/Navbar/Navbar.test.tsx +++ b/frontend/__tests__/Navbar/Navbar.test.tsx @@ -172,20 +172,9 @@ describe("GlobalNavbar", () => { AuthStatus.Authorized, ); - const mock = vi.fn().mockImplementation(authService.getUser); - mock.mockImplementationOnce(() => { - return { - id: 1, - username: "test", - refreshToken: "test", - profileImage: "", - }; - }); - render(); - const avatar = screen.getByAltText("Profile") as HTMLImageElement; + const avatar = document.querySelector(".bi-file-person-fill"); expect(avatar).toBeInTheDocument(); - expect(avatar.src).toContain("/img_avatar.png"); }); it("renders user avatar when authorized and profileImage exists", () => { diff --git a/frontend/components/Navbar/Navbar.tsx b/frontend/components/Navbar/Navbar.tsx index 38a2a3e4..d99ee4d8 100644 --- a/frontend/components/Navbar/Navbar.tsx +++ b/frontend/components/Navbar/Navbar.tsx @@ -9,24 +9,11 @@ import Export from "./Export"; import Image from "next/image"; import Searchbar from "./Searchbar"; import navbarView from "styles/navbar.module.scss"; -import { useEffect, useState } from "react"; import AccountModal from "./AccountModal"; const GlobalNavbar: React.FC = () => { const userAuth = useAuth(); const user = authService.getUser(); - let [isMobile, setIsMobile] = useState(null); - - useEffect(() => { - setIsMobile(window.innerWidth <= 767.98); - - const checkWindowWidth = () => { - setIsMobile(window.innerWidth <= 767.98); - }; - - window.addEventListener("resize", checkWindowWidth); - }, []); - const router = useRouter(); function authButton() { if (userAuth == AuthStatus.Unauthorized || userAuth === undefined) { @@ -75,20 +62,18 @@ const GlobalNavbar: React.FC = () => { className="bg-body-tertiary" > - {isMobile ? ( - router.push("/")} - className={` ${navbarView.navBrand}`} - > - FindFirst Logo - - ) : null} + router.push("/")} + className={` ${navbarView.navBrand}`} + > + FindFirst Logo + {/* Search bar stays visible always */} {userAuth === AuthStatus.Authorized ? : null} diff --git a/frontend/vitestSetup.ts b/frontend/vitestSetup.ts index 6c846768..f46d431e 100644 --- a/frontend/vitestSetup.ts +++ b/frontend/vitestSetup.ts @@ -6,6 +6,11 @@ import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap-icons/font/bootstrap-icons.min.css"; loadEnvConfig(process.cwd()); +vi.mock("next/font/google", () => ({ + Libre_Baskerville: vi.fn(() => ({ className: "mock-font" })), + Inter: vi.fn(() => ({ className: "mock-font" })), +})); + vi.mock("next/navigation", () => { const actual = vi.importActual("next/navigation"); return { From e05519417b8d7a0cd9b9614a008183ff8aa1a18b Mon Sep 17 00:00:00 2001 From: Lucas Ramage Date: Mon, 15 Jun 2026 17:01:47 -0500 Subject: [PATCH 3/3] fix(server): support browser extension Bearer token auth - Add jwtAuthenticationConverter inline in securityFilterChain so Bearer token requests produce UserAuthenticationToken directly; avoids registering a Converter bean that breaks Spring MVC startup - Remove JwtAuthenticationToken fallback from UserContext since the converter now handles Bearer token auth uniformly - Add accessToken to TokenRefreshResponse so the extension can read the JWT from the sign-in response body (httpOnly cookie is not accessible from moz-extension:// origin) - Build server image from source in docker-compose for local dev --- docker-compose.yml | 3 +++ docker/server/Dockerfile | 4 ++-- .../security/config/SecSecurityConfig.java | 14 +++++++++++++- .../security/userauth/context/UserContext.java | 5 ----- .../userauth/models/TokenRefreshResponse.java | 6 +++--- .../findfirst/users/controller/UserController.java | 4 ++-- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8bc2a7ad..f341aded 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,9 @@ services: - dev - backend server: + build: + context: ./server + dockerfile: ../docker/server/Dockerfile image: ghcr.io/r-sandor/findfirst-server:latest ports: - "9000:9000" diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index 8776bdbd..ec6addf8 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -1,8 +1,8 @@ #syntax=docker/dockerfile:1.7-labs -FROM gradle:jdk21-alpine AS builder +FROM gradle:8.9-jdk21-alpine AS builder WORKDIR /app COPY --exclude=build/ . . -RUN ./gradlew assemble +RUN gradle assemble --no-daemon FROM openjdk:26-ea-slim AS runner WORKDIR /app diff --git a/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java b/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java index 8e4d5914..832c17b7 100644 --- a/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java +++ b/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java @@ -2,12 +2,15 @@ import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; +import java.util.Collections; import dev.findfirst.security.conditions.OAuthClientsCondition; import dev.findfirst.security.filters.CookieAuthenticationFilter; import dev.findfirst.security.jwt.AuthEntryPointJwt; +import dev.findfirst.security.jwt.UserAuthenticationToken; import dev.findfirst.security.oauth2client.handlers.Oauth2LoginSuccessHandler; import dev.findfirst.security.userauth.service.UserDetailsServiceImpl; +import dev.findfirst.security.userauth.utils.Constants; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSet; @@ -29,6 +32,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jwt.JwtDecoder; @@ -102,7 +106,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.csrf(csrf -> csrf.disable()) .sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.decoder(jwtDecoder()))); + .oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.decoder(jwtDecoder()) + .jwtAuthenticationConverter(token -> { + Number userId = token.getClaim(Constants.USER_ID_CLAIM); + Number roleId = token.getClaim(Constants.ROLE_ID_CLAIM); + String roleName = token.getClaim(Constants.ROLE_NAME_CLAIM); + return new UserAuthenticationToken(token.getSubject(), roleId.intValue(), + Collections.singletonList(new SimpleGrantedAuthority(roleName)), + userId.intValue()); + }))); http.httpBasic( httpBasicCustomizer -> httpBasicCustomizer.authenticationEntryPoint(unauthorizedHandler)) diff --git a/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java b/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java index 0a8dd594..e21cabae 100644 --- a/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java +++ b/server/src/main/java/dev/findfirst/security/userauth/context/UserContext.java @@ -4,7 +4,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Component; @Component @@ -15,10 +14,6 @@ public int getUserId() { if (auth instanceof UserAuthenticationToken uat) { return uat.getUserId(); } - if (auth instanceof JwtAuthenticationToken jat) { - Number userId = jat.getToken().getClaim("userId"); - return userId.intValue(); - } throw new IllegalStateException("Unexpected authentication type: " + auth.getClass()); } } diff --git a/server/src/main/java/dev/findfirst/security/userauth/models/TokenRefreshResponse.java b/server/src/main/java/dev/findfirst/security/userauth/models/TokenRefreshResponse.java index 320d4cd4..e4f92397 100644 --- a/server/src/main/java/dev/findfirst/security/userauth/models/TokenRefreshResponse.java +++ b/server/src/main/java/dev/findfirst/security/userauth/models/TokenRefreshResponse.java @@ -1,7 +1,7 @@ package dev.findfirst.security.userauth.models; -public record TokenRefreshResponse(String tokenType, String refreshToken, String error) { - public TokenRefreshResponse(String refreshToken) { - this("Bearer", refreshToken, null); +public record TokenRefreshResponse(String tokenType, String accessToken, String refreshToken, String error) { + public TokenRefreshResponse(String accessToken, String refreshToken) { + this("Bearer", accessToken, refreshToken, null); } } diff --git a/server/src/main/java/dev/findfirst/users/controller/UserController.java b/server/src/main/java/dev/findfirst/users/controller/UserController.java index 7995c7af..82ccc2c5 100644 --- a/server/src/main/java/dev/findfirst/users/controller/UserController.java +++ b/server/src/main/java/dev/findfirst/users/controller/UserController.java @@ -173,14 +173,14 @@ public ResponseEntity token( log.debug("User Signing in"); tkns = userService.signinUser(authorization); } catch (NoUserFoundException e) { - return ResponseEntity.badRequest().body(new TokenRefreshResponse(null, null, e.toString())); + return ResponseEntity.badRequest().body(new TokenRefreshResponse(null, null, null, e.toString())); } ResponseCookie cookie = ResponseCookie.from("findfirst", tkns.jwt()).secure(secure).path("/") .domain(domain).httpOnly(true).build(); return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(new TokenRefreshResponse(tkns.refreshToken())); + .body(new TokenRefreshResponse(tkns.jwt(), tkns.refreshToken())); } @PostMapping("/refreshToken")