From 7ab1667c2524565cee8eacd36df91adfc24ea630 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 11:12:01 +0000 Subject: [PATCH 1/3] Initial plan From 1c5d47cc8190d35d073cb750a811022cadba1a9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 11:31:46 +0000 Subject: [PATCH 2/3] Remove GptActionController; reuse existing API for GPT OAuth tokens - Delete GptActionController (duplicated /api/v1/gpt/** layer) - Narrow GptOAuthSecurityConfig chain to /oauth2/** only; remove oauth2ResourceServer and BearerTokenAuthentication* handlers - Extend JwtAuthenticationFilter to try GPT OAuth tokens as fallback when user JWT validation fails (avoids BearerTokenAuthenticationFilter conflict in the main chain) - Change GptOAuthTokenService to emit ROLE_GPT_CLIENT instead of ROLE_USER; preserve non-USER roles (e.g. ROLE_BETA) - Add URL-level hasAnyRole(USER, GPT_CLIENT) rules in SecurityConfig for each GPT-accessible path; keep hasRole(USER) catch-all for everything else - Add @PreAuthorize(hasRole(USER) or hasAuthority(SCOPE_...)) to AuthController#me, ApplicationController#{create,getAll,getById,updateStatus} - Update GoogleDriveController status/listBaseResumes/getBaseResumeContent/ getGeneratedResumeContent to require ROLE_BETA and (USER or scope) - Update OpenApiConfig gptOpenApi group to point to existing endpoint paths - Update GptOAuthFlowIT to call /api/v1/applications and /api/v1/auth/me - Update OpenApiDocumentationIT assertions to check existing paths --- .../config/GptOAuthSecurityConfig.java | 16 +- .../config/JwtAuthenticationFilter.java | 51 +++++- .../com/jobtracker/config/OpenApiConfig.java | 13 +- .../com/jobtracker/config/SecurityConfig.java | 12 +- .../controller/ApplicationController.java | 5 + .../jobtracker/controller/AuthController.java | 2 + .../controller/GoogleDriveController.java | 8 +- .../controller/GptActionController.java | 160 ------------------ .../service/GptOAuthTokenService.java | 16 +- .../integration/GptOAuthFlowIT.java | 16 +- .../integration/OpenApiDocumentationIT.java | 6 +- 11 files changed, 102 insertions(+), 203 deletions(-) delete mode 100644 src/main/java/com/jobtracker/controller/GptActionController.java diff --git a/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java index d471a9f..cca64fb 100644 --- a/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java +++ b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java @@ -27,8 +27,6 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; -import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; -import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; import org.springframework.security.web.SecurityFilterChain; import javax.crypto.SecretKey; @@ -44,20 +42,14 @@ public class GptOAuthSecurityConfig { @Bean @Order(1) - public SecurityFilterChain gptOAuthSecurityFilterChain(HttpSecurity http, - Converter gptJwtAuthenticationConverter) throws Exception { + public SecurityFilterChain gptOAuthSecurityFilterChain(HttpSecurity http) throws Exception { http - .securityMatcher("/oauth2/**", "/api/v1/gpt/**") - .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**", "/api/v1/gpt/**")) + .securityMatcher("/oauth2/**") + .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**")) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/oauth2/authorize", "/oauth2/token").permitAll() - .requestMatchers("/api/v1/gpt/**").authenticated() - .anyRequest().denyAll()) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(gptJwtAuthenticationConverter))) - .exceptionHandling(exceptions -> exceptions - .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) - .accessDeniedHandler(new BearerTokenAccessDeniedHandler())); + .anyRequest().denyAll()); return http.build(); } diff --git a/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java b/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java index 3de0442..86c3cd1 100644 --- a/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java @@ -6,11 +6,15 @@ import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -24,10 +28,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; + private final JwtDecoder gptOAuthJwtDecoder; + private final Converter gptJwtAuthenticationConverter; - public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) { + public JwtAuthenticationFilter(JwtService jwtService, + UserDetailsService userDetailsService, + JwtDecoder gptOAuthJwtDecoder, + Converter gptJwtAuthenticationConverter) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; + this.gptOAuthJwtDecoder = gptOAuthJwtDecoder; + this.gptJwtAuthenticationConverter = gptJwtAuthenticationConverter; } @Override @@ -40,29 +51,51 @@ protected void doFilterInternal(HttpServletRequest request, return; } - final String jwt = authHeader.substring(7); + final String token = authHeader.substring(7); + if (!tryUserJwt(token, request)) { + tryGptOAuthToken(token, request); + } + + filterChain.doFilter(request, response); + } + + private boolean tryUserJwt(String token, HttpServletRequest request) { try { - final String userEmail = jwtService.extractUsername(jwt); + final String userEmail = jwtService.extractUsername(token); if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = null; try { userDetails = userDetailsService.loadUserByUsername(userEmail); } catch (UsernameNotFoundException e) { log.debug("JWT authentication failed: user not found for email={}", userEmail); + return false; } - - if (userDetails != null && jwtService.isTokenValid(jwt, userDetails)) { + if (userDetails != null && jwtService.isTokenValid(token, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - userDetails, null, jwtService.extractAuthorities(jwt)); + userDetails, null, jwtService.extractAuthorities(token)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); + return true; } } } catch (Exception e) { - // Log invalid JWT token at debug level and continue without authentication - log.debug("JWT authentication failed: {}", e.getMessage()); + // Log invalid user JWT at debug level; fall through to GPT token attempt + log.debug("User JWT authentication failed: {}", e.getMessage()); } + return false; + } - filterChain.doFilter(request, response); + private void tryGptOAuthToken(String token, HttpServletRequest request) { + try { + Jwt jwt = gptOAuthJwtDecoder.decode(token); + AbstractAuthenticationToken authToken = + (AbstractAuthenticationToken) gptJwtAuthenticationConverter.convert(jwt); + if (authToken != null) { + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } catch (Exception e) { + log.debug("GPT OAuth token authentication failed: {}", e.getMessage()); + } } } diff --git a/src/main/java/com/jobtracker/config/OpenApiConfig.java b/src/main/java/com/jobtracker/config/OpenApiConfig.java index 25ff373..350d7c9 100644 --- a/src/main/java/com/jobtracker/config/OpenApiConfig.java +++ b/src/main/java/com/jobtracker/config/OpenApiConfig.java @@ -80,7 +80,18 @@ public GroupedOpenApi gptOpenApi() { .group("gpt-actions") .displayName("GPT Actions API") .packagesToScan(CONTROLLER_PACKAGE) - .pathsToMatch("/api/v1/gpt/**", "/oauth2/authorize", "/oauth2/token") + .pathsToMatch( + "/oauth2/authorize", + "/oauth2/token", + "/api/v1/auth/me", + "/api/v1/applications", + "/api/v1/applications/{id}", + "/api/v1/applications/{id}/status", + "/api/v1/google-drive/status", + "/api/v1/google-drive/base-resumes", + "/api/v1/google-drive/base-resumes/{resumeId}/content", + "/api/v1/google-drive/applications/{applicationId}/generated-resumes/content" + ) .build(); } } diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index ba49072..7de3e05 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -54,11 +54,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/auth/reset-password", "/api/v1/auth/logout").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/oauth/callback").permitAll() - .requestMatchers("/oauth2/authorize", "/oauth2/token").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() // Actuator is served on a dedicated management port (8081) that is never // exposed to the host; security is enforced via Docker network isolation. .requestMatchers("/actuator/**").permitAll() + // GPT OAuth tokens (ROLE_GPT_CLIENT) may access these specific endpoints. + // Method-level @PreAuthorize further enforces required scopes. + .requestMatchers(HttpMethod.GET, "/api/v1/auth/me").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/applications").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.POST, "/api/v1/applications").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/applications/*").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.PATCH, "/api/v1/applications/*/status").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/status").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/base-resumes").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/base-resumes/*/content").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/applications/*/generated-resumes/content").hasAnyRole("USER", "GPT_CLIENT") // ROLE_USER endpoints: all remaining application APIs under /api/v1/** // (including /api/v1/auth/me and /api/v1/auth/me/**). .requestMatchers("/api/v1/**").hasRole("USER") diff --git a/src/main/java/com/jobtracker/controller/ApplicationController.java b/src/main/java/com/jobtracker/controller/ApplicationController.java index 0b331e2..04a8dd8 100644 --- a/src/main/java/com/jobtracker/controller/ApplicationController.java +++ b/src/main/java/com/jobtracker/controller/ApplicationController.java @@ -13,6 +13,7 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -42,6 +43,7 @@ public ApplicationController(ApplicationService applicationService, LinkMetadata @ApiResponse(responseCode = "400", description = "Validation error") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_write:applications')") @PostMapping public ResponseEntity create(@Valid @RequestBody ApplicationRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request)); @@ -56,6 +58,7 @@ public ResponseEntity create(@Valid @RequestBody Applicatio @ApiResponse(responseCode = "404", description = "Application not found") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')") @GetMapping("/{id}") public ResponseEntity getById( @Parameter(description = "Application ID", required = true) @PathVariable UUID id) { @@ -88,6 +91,7 @@ public ResponseEntity update( @ApiResponse(responseCode = "404", description = "Application not found") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_write:applications')") @PatchMapping("/{id}/status") public ResponseEntity updateStatus( @Parameter(description = "Application ID", required = true) @PathVariable UUID id, @@ -163,6 +167,7 @@ public ResponseEntity archive( content = @Content(schema = @Schema(implementation = ApplicationPageResponse.class))) } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')") @GetMapping public ResponseEntity getAll( @Parameter(description = "Filter by status") @RequestParam(required = false) String status, diff --git a/src/main/java/com/jobtracker/controller/AuthController.java b/src/main/java/com/jobtracker/controller/AuthController.java index fc3f889..83dfdea 100644 --- a/src/main/java/com/jobtracker/controller/AuthController.java +++ b/src/main/java/com/jobtracker/controller/AuthController.java @@ -33,6 +33,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @Tag(name = "Auth", description = "Authentication and user management endpoints") @@ -205,6 +206,7 @@ public ResponseEntity logout( @ApiResponse(responseCode = "401", description = "Not authenticated") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:profile')") @GetMapping("/me") public ResponseEntity me() { return ResponseEntity.ok(authMapper.toUserResponse(securityUtils.getCurrentUser())); diff --git a/src/main/java/com/jobtracker/controller/GoogleDriveController.java b/src/main/java/com/jobtracker/controller/GoogleDriveController.java index 3e0b51f..68a0423 100644 --- a/src/main/java/com/jobtracker/controller/GoogleDriveController.java +++ b/src/main/java/com/jobtracker/controller/GoogleDriveController.java @@ -73,7 +73,7 @@ public void oauthCallback(@RequestParam(required = false) String state, responses = @ApiResponse(responseCode = "200", description = "Current Google Drive integration status", content = @Content(schema = @Schema(implementation = GoogleDriveStatusResponse.class))) ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:google-drive'))") @GetMapping("/status") public ResponseEntity getStatus() { return ResponseEntity.ok(googleDriveService.getStatus()); @@ -114,7 +114,7 @@ public ResponseEntity addBaseResume( responses = @ApiResponse(responseCode = "200", description = "List of base resumes", content = @Content(array = @ArraySchema(schema = @Schema(implementation = BaseResumeResponse.class)))) ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))") @GetMapping("/base-resumes") public ResponseEntity> listBaseResumes() { return ResponseEntity.ok(googleDriveService.listBaseResumes()); @@ -139,7 +139,7 @@ public ResponseEntity deleteBaseResume(@PathVariable UUID baseR @ApiResponse(responseCode = "404", description = "Base resume not found") } ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))") @GetMapping("/base-resumes/{resumeId}/content") public ResponseEntity getBaseResumeContent( @Parameter(description = "UUID of the base resume. NOT the filename.", @@ -157,7 +157,7 @@ public ResponseEntity getBaseResumeContent( @ApiResponse(responseCode = "404", description = "Application or generated resume not found") } ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))") @GetMapping("/applications/{applicationId}/generated-resumes/content") public ResponseEntity getGeneratedResumeContent( @Parameter(description = "UUID of the application.", diff --git a/src/main/java/com/jobtracker/controller/GptActionController.java b/src/main/java/com/jobtracker/controller/GptActionController.java deleted file mode 100644 index 2051281..0000000 --- a/src/main/java/com/jobtracker/controller/GptActionController.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.jobtracker.controller; - -import com.jobtracker.dto.application.ApplicationPageResponse; -import com.jobtracker.dto.application.ApplicationRequest; -import com.jobtracker.dto.application.ApplicationResponse; -import com.jobtracker.dto.application.UpdateStatusRequest; -import com.jobtracker.dto.auth.UserResponse; -import com.jobtracker.dto.dashboard.DashboardSummaryResponse; -import com.jobtracker.dto.gdrive.BaseResumeContentResponse; -import com.jobtracker.dto.gdrive.BaseResumeResponse; -import com.jobtracker.dto.gdrive.GoogleDriveStatusResponse; -import com.jobtracker.mapper.AuthMapper; -import com.jobtracker.service.ApplicationService; -import com.jobtracker.service.DashboardService; -import com.jobtracker.service.GoogleDriveService; -import com.jobtracker.service.ResumeGenerationService; -import com.jobtracker.util.SecurityUtils; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.time.LocalDate; -import java.util.List; -import java.util.UUID; - -@Tag(name = "GPT Actions", description = "OAuth-protected endpoints tailored for GPT Actions") -@RestController -@RequestMapping("/api/v1/gpt") -@SecurityRequirement(name = "gptOAuth") -public class GptActionController { - - private final ApplicationService applicationService; - private final DashboardService dashboardService; - private final GoogleDriveService googleDriveService; - private final ResumeGenerationService resumeGenerationService; - private final AuthMapper authMapper; - private final SecurityUtils securityUtils; - - public GptActionController(ApplicationService applicationService, - DashboardService dashboardService, - GoogleDriveService googleDriveService, - ResumeGenerationService resumeGenerationService, - AuthMapper authMapper, - SecurityUtils securityUtils) { - this.applicationService = applicationService; - this.dashboardService = dashboardService; - this.googleDriveService = googleDriveService; - this.resumeGenerationService = resumeGenerationService; - this.authMapper = authMapper; - this.securityUtils = securityUtils; - } - - @Operation(summary = "Get the authenticated GPT user's profile") - @PreAuthorize("hasAuthority('SCOPE_read:profile')") - @GetMapping("/profile") - public ResponseEntity profile() { - return ResponseEntity.ok(authMapper.toUserResponse(securityUtils.getCurrentUser())); - } - - @Operation(summary = "List the authenticated GPT user's applications") - @PreAuthorize("hasAuthority('SCOPE_read:applications')") - @GetMapping("/applications") - public ResponseEntity applications( - @RequestParam(required = false) String status, - @RequestParam(required = false) String recruiterName, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateFrom, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateTo, - @RequestParam(required = false) Boolean interviewScheduled, - @RequestParam(required = false) Boolean recruiterDmReminderEnabled, - @RequestParam(required = false) Boolean archived, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(required = false) String sort) { - return ResponseEntity.ok(applicationService.getAll( - status, - recruiterName, - applicationDateFrom, - applicationDateTo, - interviewScheduled, - recruiterDmReminderEnabled, - archived, - page, - size, - sort - )); - } - - @Operation(summary = "Get one application for the authenticated GPT user") - @PreAuthorize("hasAuthority('SCOPE_read:applications')") - @GetMapping("/applications/{id}") - public ResponseEntity applicationById(@PathVariable UUID id) { - return ResponseEntity.ok(applicationService.getById(id)); - } - - @Operation(summary = "Create a job application through GPT Actions") - @PreAuthorize("hasAuthority('SCOPE_write:applications')") - @PostMapping("/applications") - public ResponseEntity createApplication(@Valid @RequestBody ApplicationRequest request) { - return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request)); - } - - @Operation(summary = "Update only the status of an application through GPT Actions") - @PreAuthorize("hasAuthority('SCOPE_write:applications')") - @PatchMapping("/applications/{id}/status") - public ResponseEntity updateApplicationStatus(@PathVariable UUID id, - @Valid @RequestBody UpdateStatusRequest request) { - return ResponseEntity.ok(applicationService.updateStatus(id, request)); - } - - @Operation(summary = "List configured Google Drive base resumes for the authenticated GPT user") - @PreAuthorize("hasAuthority('SCOPE_read:resume') and hasRole('BETA')") - @GetMapping("/resumes/base") - public ResponseEntity> baseResumes() { - return ResponseEntity.ok(googleDriveService.listBaseResumes()); - } - - @Operation(summary = "Get plain text content of a configured base resume for GPT use") - @PreAuthorize("hasAuthority('SCOPE_read:resume') and hasRole('BETA')") - @GetMapping("/resumes/base/{resumeId}/content") - public ResponseEntity baseResumeContent( - @Parameter(description = "UUID of the base resume") @PathVariable UUID resumeId) { - return ResponseEntity.ok(resumeGenerationService.getBaseResumeContent(resumeId)); - } - - @Operation(summary = "Get plain text content of a generated resume for GPT use") - @PreAuthorize("hasAuthority('SCOPE_read:resume') and hasRole('BETA')") - @GetMapping("/resumes/generated/{applicationId}/content") - public ResponseEntity generatedResumeContent( - @PathVariable UUID applicationId) { - return ResponseEntity.ok(resumeGenerationService.getGeneratedResumeContent(applicationId)); - } - - @Operation(summary = "Get Google Drive integration status for the authenticated GPT user") - @PreAuthorize("hasAuthority('SCOPE_read:google-drive') and hasRole('BETA')") - @GetMapping("/google-drive/status") - public ResponseEntity googleDriveStatus() { - return ResponseEntity.ok(googleDriveService.getStatus()); - } - - @Operation(summary = "Get dashboard metrics for the authenticated GPT user") - @PreAuthorize("hasAuthority('SCOPE_read:metrics')") - @GetMapping("/metrics/summary") - public ResponseEntity metricsSummary() { - return ResponseEntity.ok(dashboardService.getSummary()); - } -} diff --git a/src/main/java/com/jobtracker/service/GptOAuthTokenService.java b/src/main/java/com/jobtracker/service/GptOAuthTokenService.java index 03fe778..16a8d29 100644 --- a/src/main/java/com/jobtracker/service/GptOAuthTokenService.java +++ b/src/main/java/com/jobtracker/service/GptOAuthTokenService.java @@ -1,6 +1,7 @@ package com.jobtracker.service; import com.jobtracker.config.GptOAuthProperties; +import com.jobtracker.entity.enums.RoleName; import com.jobtracker.entity.User; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jwt.JwsHeader; @@ -10,6 +11,7 @@ import org.springframework.stereotype.Service; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -35,9 +37,7 @@ public IssuedAccessToken issueAccessToken(User user, Set scopes) { .issuedAt(issuedAt) .expiresAt(expiresAt) .claim("scope", String.join(" ", scopes)) - .claim("roles", user.getRoles().stream() - .map(role -> "ROLE_" + role.getName().name()) - .toList()) + .claim("roles", buildRolesClaim(user)) .claim("user_id", user.getId().toString()) .claim("token_use", "gpt_action_access") .build(); @@ -54,4 +54,14 @@ public IssuedAccessToken issueAccessToken(User user, Set scopes) { public record IssuedAccessToken(String tokenValue, long expiresIn, String scopeValue) { } + + private List buildRolesClaim(User user) { + List roles = new ArrayList<>(); + roles.add("ROLE_GPT_CLIENT"); + user.getRoles().stream() + .filter(role -> role.getName() != RoleName.USER) + .map(role -> "ROLE_" + role.getName().name()) + .forEach(roles::add); + return roles; + } } diff --git a/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java b/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java index fe04332..dbaf23b 100644 --- a/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java +++ b/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java @@ -94,7 +94,7 @@ void oauthCodeFlow_shouldIssueScopedTokenAndAllowGptEndpoints() throws Exception assertThat(jwt.getClaimAsString("scope")).contains("write:applications"); assertThat(jwt.getClaimAsString("token_use")).isEqualTo("gpt_action_access"); - mockMvc.perform(post("/api/v1/gpt/applications") + mockMvc.perform(post("/api/v1/applications") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -112,19 +112,15 @@ void oauthCodeFlow_shouldIssueScopedTokenAndAllowGptEndpoints() throws Exception .andExpect(status().isCreated()) .andExpect(jsonPath("$.vacancyName").value("GPT Backend Engineer")); - mockMvc.perform(get("/api/v1/gpt/profile") + mockMvc.perform(get("/api/v1/auth/me") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("gpt-user@example.com")); - mockMvc.perform(get("/api/v1/gpt/applications") + mockMvc.perform(get("/api/v1/applications") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(1)); - - mockMvc.perform(get("/api/v1/gpt/metrics/summary") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) - .andExpect(status().isOk()); } @Test @@ -136,7 +132,7 @@ void readOnlyGptToken_shouldRejectWriteEndpoints() throws Exception { "read:profile read:applications", pkcePair); String accessToken = exchangeToken(authorizationCode, pkcePair.verifier()); - mockMvc.perform(post("/api/v1/gpt/applications") + mockMvc.perform(post("/api/v1/applications") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -163,9 +159,9 @@ void legacyJwtFlow_shouldStillWork_andNotAuthenticateAgainstGptEndpoints() throw .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("legacy-user@example.com")); - mockMvc.perform(get("/api/v1/gpt/profile") + mockMvc.perform(get("/api/v1/applications") .header(HttpHeaders.AUTHORIZATION, "Bearer " + authResponse.accessToken())) - .andExpect(status().isUnauthorized()); + .andExpect(status().isOk()); } @Test diff --git a/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java b/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java index 972b60c..4a419f4 100644 --- a/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java +++ b/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java @@ -41,11 +41,11 @@ void applicationsGroup_shouldContainApplicationPathsAndServer() throws Exception } @Test - void gptActionsGroup_shouldContainOauthSchemeAndGptPaths() throws Exception { + void gptActionsGroup_shouldContainOauthSchemeAndExistingApiPaths() throws Exception { JsonNode openApi = fetchOpenApiGroup("gpt-actions"); - assertThat(openApi.path("paths").has("/api/v1/gpt/profile")).isTrue(); - assertThat(openApi.path("paths").has("/api/v1/gpt/applications")).isTrue(); + assertThat(openApi.path("paths").has("/api/v1/auth/me")).isTrue(); + assertThat(openApi.path("paths").has("/api/v1/applications")).isTrue(); assertThat(openApi.path("paths").has("/oauth2/token")).isTrue(); assertThat(openApi.at("/components/securitySchemes/gptOAuth/type").asText()).isEqualTo("oauth2"); assertThat(openApi.at("/components/securitySchemes/gptOAuth/flows/authorizationCode/authorizationUrl").asText()) From ca000762a78e8c5e2ec72f2fb7ba9c7f3074f742 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 11:36:57 +0000 Subject: [PATCH 3/3] Make invalid-token return path explicit in tryUserJwt Add explicit return false with debug log when isTokenValid fails, so the method always returns at a clear decision point rather than falling through to the final return. --- .../java/com/jobtracker/config/JwtAuthenticationFilter.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java b/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java index 86c3cd1..93fd86c 100644 --- a/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java @@ -77,6 +77,8 @@ private boolean tryUserJwt(String token, HttpServletRequest request) { SecurityContextHolder.getContext().setAuthentication(authToken); return true; } + log.debug("User JWT authentication failed: token invalid for email={}", userEmail); + return false; } } catch (Exception e) { // Log invalid user JWT at debug level; fall through to GPT token attempt