From c029b5dba5724af9051e0719ddd7ef281d82c260 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 02:22:17 +0000 Subject: [PATCH 1/4] Add GPT OAuth implementation scaffold --- .env.example | 4 + README.md | 61 +++++ .../jobtracker/config/GptOAuthProperties.java | 107 ++++++++ .../config/GptOAuthSecurityConfig.java | 135 ++++++++++ .../com/jobtracker/config/OpenApiConfig.java | 30 ++- .../com/jobtracker/config/SecurityConfig.java | 3 + .../controller/GptActionController.java | 160 ++++++++++++ .../controller/GptOAuthController.java | 102 ++++++++ .../dto/gpt/GptAuthorizationLoginRequest.java | 29 +++ .../dto/gpt/GptAuthorizationRequest.java | 19 ++ .../jobtracker/dto/gpt/GptTokenRequest.java | 17 ++ .../jobtracker/dto/gpt/GptTokenResponse.java | 16 ++ .../entity/GptOAuthAuthorizationCode.java | 153 +++++++++++ .../GptOAuthAuthorizationCodeRepository.java | 15 ++ .../service/GptAuthorizationPageRenderer.java | 69 +++++ .../service/GptOAuthAuthorizationService.java | 184 +++++++++++++ .../service/GptOAuthClientService.java | 116 +++++++++ .../service/GptOAuthTokenService.java | 57 ++++ src/main/resources/application.yml | 9 + ...V17__add_gpt_oauth_authorization_codes.sql | 17 ++ .../integration/GptOAuthFlowIT.java | 243 ++++++++++++++++++ .../integration/OpenApiDocumentationIT.java | 12 + .../unit/GptOAuthPropertiesTest.java | 34 +++ src/test/resources/application-test.yml | 9 + 24 files changed, 1600 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/jobtracker/config/GptOAuthProperties.java create mode 100644 src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java create mode 100644 src/main/java/com/jobtracker/controller/GptActionController.java create mode 100644 src/main/java/com/jobtracker/controller/GptOAuthController.java create mode 100644 src/main/java/com/jobtracker/dto/gpt/GptAuthorizationLoginRequest.java create mode 100644 src/main/java/com/jobtracker/dto/gpt/GptAuthorizationRequest.java create mode 100644 src/main/java/com/jobtracker/dto/gpt/GptTokenRequest.java create mode 100644 src/main/java/com/jobtracker/dto/gpt/GptTokenResponse.java create mode 100644 src/main/java/com/jobtracker/entity/GptOAuthAuthorizationCode.java create mode 100644 src/main/java/com/jobtracker/repository/GptOAuthAuthorizationCodeRepository.java create mode 100644 src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java create mode 100644 src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java create mode 100644 src/main/java/com/jobtracker/service/GptOAuthClientService.java create mode 100644 src/main/java/com/jobtracker/service/GptOAuthTokenService.java create mode 100644 src/main/resources/db/migration/V17__add_gpt_oauth_authorization_codes.sql create mode 100644 src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java create mode 100644 src/test/java/com/jobtracker/unit/GptOAuthPropertiesTest.java diff --git a/.env.example b/.env.example index 7b8e021e..c7ffda6f 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,7 @@ GOOGLE_DRIVE_CLIENT_ID= GOOGLE_DRIVE_CLIENT_SECRET= GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8080/api/v1/google-drive/oauth/callback GOOGLE_DRIVE_OAUTH_COMPLETE_URL=http://localhost:5173/settings/google-drive/callback +OPENAI_GPT_CLIENT_ID= +OPENAI_GPT_CLIENT_SECRET= +OPENAI_GPT_REDIRECT_URIS=https://chat.openai.com/aip/default/callback +OPENAI_GPT_SCOPES=read:profile,read:applications,write:applications,read:resume,read:google-drive,read:metrics diff --git a/README.md b/README.md index 10ebf805..8f7d2d98 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,10 @@ export GOOGLE_DRIVE_CLIENT_ID=your-google-client-id export GOOGLE_DRIVE_CLIENT_SECRET=your-google-client-secret export GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8080/api/v1/google-drive/oauth/callback export GOOGLE_DRIVE_OAUTH_COMPLETE_URL=http://localhost:5173/settings/google-drive/callback +export OPENAI_GPT_CLIENT_ID=your-openai-gpt-client-id +export OPENAI_GPT_CLIENT_SECRET=your-openai-gpt-client-secret +export OPENAI_GPT_REDIRECT_URIS=https://chat.openai.com/aip/default/callback +export OPENAI_GPT_SCOPES=read:profile,read:applications,write:applications,read:resume,read:google-drive,read:metrics mvn spring-boot:run ``` @@ -310,6 +314,59 @@ Response: } ``` +## GPT Actions OAuth integration + +This backend now exposes a dedicated OAuth 2.0 Authorization Code + PKCE flow for GPT Actions without changing the existing JWT login flow for human users. GPT-issued access tokens are scoped, bearer-only, and isolated to `/api/v1/gpt/**`. + +### Required environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `OPENAI_GPT_CLIENT_ID` | Yes | OAuth client ID configured for the GPT Action | +| `OPENAI_GPT_CLIENT_SECRET` | Yes | OAuth client secret configured for the GPT Action | +| `OPENAI_GPT_REDIRECT_URIS` | Yes | Comma-separated list of allowed GPT Action redirect URIs | +| `OPENAI_GPT_SCOPES` | No | Comma-separated allowed GPT scopes; defaults to the built-in GPT scopes | + +### Supported GPT scopes + +- `read:profile` +- `read:applications` +- `write:applications` +- `read:resume` +- `read:google-drive` +- `read:metrics` + +### OAuth endpoints + +- Authorization endpoint: `GET/POST /oauth2/authorize` +- Token endpoint: `POST /oauth2/token` + +### GPT Action setup steps + +1. Create or update your GPT Action OAuth client with the backend base URL. +2. Register the same callback URL in `OPENAI_GPT_REDIRECT_URIS`. +3. Configure the client ID and client secret with `OPENAI_GPT_CLIENT_ID` and `OPENAI_GPT_CLIENT_SECRET`. +4. Set the action scopes to the minimum required set from `OPENAI_GPT_SCOPES`. +5. In the GPT Action OAuth settings, use: + - Authorization URL: `https:///oauth2/authorize` + - Token URL: `https:///oauth2/token` +6. After OAuth succeeds, call the GPT-friendly endpoints under `/api/v1/gpt/**`. + +### GPT-friendly endpoints + +- `GET /api/v1/gpt/profile` +- `GET /api/v1/gpt/applications` +- `GET /api/v1/gpt/applications/{id}` +- `POST /api/v1/gpt/applications` +- `PATCH /api/v1/gpt/applications/{id}/status` +- `GET /api/v1/gpt/resumes/base` +- `GET /api/v1/gpt/resumes/base/{resumeId}/content` +- `GET /api/v1/gpt/resumes/generated/{applicationId}/content` +- `GET /api/v1/gpt/google-drive/status` +- `GET /api/v1/gpt/metrics/summary` + +Google Drive and resume GPT endpoints still enforce the user's existing `BETA` role in addition to the new OAuth scopes, so the GPT flow does not bypass the repository's current authorization rules. + `GET /api/v1/google-drive/status` ```json @@ -438,6 +495,10 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use | `GOOGLE_DRIVE_CLIENT_SECRET` | *(empty)* | Google OAuth client secret for Drive integration | | `GOOGLE_DRIVE_REDIRECT_URI` | `http://localhost:8080/api/v1/google-drive/oauth/callback` | OAuth callback URL registered in Google Cloud | | `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | *(empty)* | Frontend URL that receives OAuth completion redirects | +| `OPENAI_GPT_CLIENT_ID` | *(empty)* | OAuth client ID for GPT Actions | +| `OPENAI_GPT_CLIENT_SECRET` | *(empty)* | OAuth client secret for GPT Actions | +| `OPENAI_GPT_REDIRECT_URIS` | *(empty)* | Comma-separated GPT Action redirect URIs | +| `OPENAI_GPT_SCOPES` | `read:profile,read:applications,write:applications,read:resume,read:google-drive,read:metrics` | Allowed GPT Action scopes | | `RATE_LIMIT_AUTH_LOGIN_LIMIT_FOR_PERIOD` | `10` | Max login requests allowed per refresh period | | `RATE_LIMIT_AUTH_LOGIN_REFRESH_PERIOD` | `1m` | Window used by the login rate limiter | | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | OTLP gRPC endpoint (Jaeger/OpenTelemetry collector) | diff --git a/src/main/java/com/jobtracker/config/GptOAuthProperties.java b/src/main/java/com/jobtracker/config/GptOAuthProperties.java new file mode 100644 index 00000000..5e119da3 --- /dev/null +++ b/src/main/java/com/jobtracker/config/GptOAuthProperties.java @@ -0,0 +1,107 @@ +package com.jobtracker.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Component +public class GptOAuthProperties { + + private final String clientId; + private final String clientSecret; + private final List redirectUris; + private final List scopes; + private final String issuer; + private final String audience; + private final long authorizationCodeExpirationSeconds; + private final long accessTokenExpirationSeconds; + + public GptOAuthProperties( + @Value("${app.gpt-oauth.client-id:}") String clientId, + @Value("${app.gpt-oauth.client-secret:}") String clientSecret, + @Value("${app.gpt-oauth.redirect-uris:}") String redirectUrisValue, + @Value("${app.gpt-oauth.scopes:read:profile,read:applications,write:applications,read:resume,read:google-drive,read:metrics}") String scopesValue, + @Value("${app.gpt-oauth.issuer:${app.api.base-url:https://jobapply-api.hugojava.dev}}") String issuer, + @Value("${app.gpt-oauth.audience:jobtracker-gpt-actions}") String audience, + @Value("${app.gpt-oauth.authorization-code-expiration-seconds:300}") long authorizationCodeExpirationSeconds, + @Value("${app.gpt-oauth.access-token-expiration-seconds:900}") long accessTokenExpirationSeconds + ) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUris = splitCsv(redirectUrisValue); + this.scopes = splitCsv(scopesValue); + this.issuer = issuer; + this.audience = audience; + this.authorizationCodeExpirationSeconds = authorizationCodeExpirationSeconds; + this.accessTokenExpirationSeconds = accessTokenExpirationSeconds; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public List getRedirectUris() { + return redirectUris; + } + + public List getScopes() { + return scopes; + } + + public String getIssuer() { + return issuer; + } + + public String getAudience() { + return audience; + } + + public long getAuthorizationCodeExpirationSeconds() { + return authorizationCodeExpirationSeconds; + } + + public long getAccessTokenExpirationSeconds() { + return accessTokenExpirationSeconds; + } + + public boolean isConfigured() { + return hasText(clientId) && hasText(clientSecret) && !redirectUris.isEmpty() && !scopes.isEmpty(); + } + + public void validateConfigured() { + if (!isConfigured()) { + throw new IllegalStateException("GPT OAuth integration is not configured on the server"); + } + } + + public boolean supportsRedirectUri(String redirectUri) { + return redirectUris.contains(redirectUri); + } + + public boolean supportsScopes(Set requestedScopes) { + return new LinkedHashSet<>(scopes).containsAll(requestedScopes); + } + + private List splitCsv(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + return Arrays.stream(value.split(",")) + .map(String::trim) + .filter(entry -> !entry.isBlank()) + .distinct() + .toList(); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java new file mode 100644 index 00000000..17917d53 --- /dev/null +++ b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java @@ -0,0 +1,135 @@ +package com.jobtracker.config; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +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.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtAuthenticationToken; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +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; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.LinkedHashSet; + +@Configuration +public class GptOAuthSecurityConfig { + + @Bean + @Order(1) + public SecurityFilterChain gptOAuthSecurityFilterChain(HttpSecurity http, + Converter gptJwtAuthenticationConverter) throws Exception { + http + .securityMatcher("/oauth2/**", "/api/v1/gpt/**") + .csrf(AbstractHttpConfigurer::disable) + .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())); + + return http.build(); + } + + @Bean + public SecretKey gptOAuthSecretKey(@org.springframework.beans.factory.annotation.Value("${jwt.secret}") String jwtSecret) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(("gpt-oauth::" + jwtSecret).getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(digest, "HmacSHA256"); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("Unable to initialize GPT OAuth signing key", ex); + } + } + + @Bean + public JwtEncoder gptOAuthJwtEncoder(SecretKey gptOAuthSecretKey) { + OctetSequenceKey jwk = new OctetSequenceKey.Builder(gptOAuthSecretKey) + .keyID("gpt-oauth-hmac") + .build(); + JWKSource jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk)); + return new NimbusJwtEncoder(jwkSource); + } + + @Bean + public JwtDecoder gptOAuthJwtDecoder(SecretKey gptOAuthSecretKey, GptOAuthProperties properties) { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(gptOAuthSecretKey) + .macAlgorithm(MacAlgorithm.HS256) + .build(); + + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.getIssuer()), + audienceValidator(properties.getAudience()), + tokenUseValidator() + ); + jwtDecoder.setJwtValidator(validator); + return jwtDecoder; + } + + @Bean + public Converter gptJwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter scopeConverter = new JwtGrantedAuthoritiesConverter(); + scopeConverter.setAuthoritiesClaimName("scope"); + scopeConverter.setAuthorityPrefix("SCOPE_"); + + return jwt -> { + Collection authorities = new LinkedHashSet<>(scopeConverter.convert(jwt)); + Object rolesClaim = jwt.getClaim("roles"); + if (rolesClaim instanceof Collection roles) { + for (Object role : roles) { + if (role instanceof String roleValue) { + authorities.add(new SimpleGrantedAuthority(roleValue)); + } + } + } + return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject()); + }; + } + + private OAuth2TokenValidator audienceValidator(String expectedAudience) { + return token -> { + if (token.getAudience() != null && token.getAudience().contains(expectedAudience)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid GPT OAuth audience", null)); + }; + } + + private OAuth2TokenValidator tokenUseValidator() { + return token -> "gpt_action_access".equals(token.getClaimAsString("token_use")) + ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid GPT OAuth token_use", null)); + } +} diff --git a/src/main/java/com/jobtracker/config/OpenApiConfig.java b/src/main/java/com/jobtracker/config/OpenApiConfig.java index c70d7f61..25ff373a 100644 --- a/src/main/java/com/jobtracker/config/OpenApiConfig.java +++ b/src/main/java/com/jobtracker/config/OpenApiConfig.java @@ -4,6 +4,9 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.OAuthFlow; +import io.swagger.v3.oas.models.security.OAuthFlows; +import io.swagger.v3.oas.models.security.Scopes; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; import org.springdoc.core.models.GroupedOpenApi; @@ -17,6 +20,7 @@ public class OpenApiConfig { private static final String SECURITY_SCHEME_NAME = "bearerAuth"; + private static final String GPT_OAUTH_SCHEME_NAME = "gptOAuth"; private static final String CONTROLLER_PACKAGE = "com.jobtracker.controller"; @Bean @@ -33,7 +37,21 @@ public OpenAPI openAPI(@Value("${app.api.base-url:https://jobapply-api.hugojava. .name(SECURITY_SCHEME_NAME) .type(SecurityScheme.Type.HTTP) .scheme("bearer") - .bearerFormat("JWT"))); + .bearerFormat("JWT")) + .addSecuritySchemes(GPT_OAUTH_SCHEME_NAME, new SecurityScheme() + .name(GPT_OAUTH_SCHEME_NAME) + .type(SecurityScheme.Type.OAUTH2) + .flows(new OAuthFlows() + .authorizationCode(new OAuthFlow() + .authorizationUrl(apiBaseUrl + "/oauth2/authorize") + .tokenUrl(apiBaseUrl + "/oauth2/token") + .scopes(new Scopes() + .addString("read:profile", "Read the authenticated user's profile") + .addString("read:applications", "Read the authenticated user's applications") + .addString("write:applications", "Create or update the authenticated user's applications") + .addString("read:resume", "Read resume content for the authenticated user") + .addString("read:google-drive", "Read Google Drive integration status for the authenticated user") + .addString("read:metrics", "Read dashboard metrics for the authenticated user")))))); } @Bean @@ -55,4 +73,14 @@ public GroupedOpenApi googleDriveOpenApi() { .pathsToMatch("/api/v1/google-drive/**", "/api/v1/google-drive") .build(); } + + @Bean + public GroupedOpenApi gptOpenApi() { + return GroupedOpenApi.builder() + .group("gpt-actions") + .displayName("GPT Actions API") + .packagesToScan(CONTROLLER_PACKAGE) + .pathsToMatch("/api/v1/gpt/**", "/oauth2/authorize", "/oauth2/token") + .build(); + } } diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index cc22c810..92714117 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -3,6 +3,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.core.annotation.Order; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -29,6 +30,7 @@ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, } @Bean + @Order(2) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(Customizer.withDefaults()) @@ -50,6 +52,7 @@ 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. diff --git a/src/main/java/com/jobtracker/controller/GptActionController.java b/src/main/java/com/jobtracker/controller/GptActionController.java new file mode 100644 index 00000000..20512813 --- /dev/null +++ b/src/main/java/com/jobtracker/controller/GptActionController.java @@ -0,0 +1,160 @@ +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/controller/GptOAuthController.java b/src/main/java/com/jobtracker/controller/GptOAuthController.java new file mode 100644 index 00000000..e97beefa --- /dev/null +++ b/src/main/java/com/jobtracker/controller/GptOAuthController.java @@ -0,0 +1,102 @@ +package com.jobtracker.controller; + +import com.jobtracker.dto.gpt.GptAuthorizationLoginRequest; +import com.jobtracker.dto.gpt.GptAuthorizationRequest; +import com.jobtracker.dto.gpt.GptTokenRequest; +import com.jobtracker.dto.gpt.GptTokenResponse; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.UnauthorizedException; +import com.jobtracker.service.GptAuthorizationPageRenderer; +import com.jobtracker.service.GptOAuthAuthorizationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +@RequestMapping("/oauth2") +@Tag(name = "GPT OAuth", description = "OAuth 2.0 Authorization Code with PKCE endpoints for GPT Actions") +public class GptOAuthController { + + private final GptOAuthAuthorizationService authorizationService; + private final GptAuthorizationPageRenderer pageRenderer; + + public GptOAuthController(GptOAuthAuthorizationService authorizationService, + GptAuthorizationPageRenderer pageRenderer) { + this.authorizationService = authorizationService; + this.pageRenderer = pageRenderer; + } + + @Operation(summary = "Render GPT Action authorization page") + @GetMapping(value = "/authorize", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + public ResponseEntity authorize(@Valid GptAuthorizationRequest request) { + authorizationService.validateAuthorizationRequest(request); + return ResponseEntity.ok() + .contentType(pageRenderer.mediaType()) + .body(pageRenderer.render(request, null)); + } + + @PostMapping(value = "/authorize", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity authorizeLogin(@Valid GptAuthorizationLoginRequest request) { + String redirectUri = authorizationService.authorize(request); + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, redirectUri) + .build(); + } + + @Operation(summary = "Exchange an authorization code for a GPT Action access token") + @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @ResponseBody + public ResponseEntity token(@Valid GptTokenRequest request, + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) + String authorizationHeader) { + GptTokenResponse response = authorizationService.exchangeToken(request, authorizationHeader); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.PRAGMA, "no-cache") + .body(response); + } + + @org.springframework.web.bind.annotation.ExceptionHandler({BadRequestException.class, UnauthorizedException.class}) + @ResponseBody + public ResponseEntity handleAuthorizationError(Exception ex, jakarta.servlet.http.HttpServletRequest servletRequest) { + String responseType = servletRequest.getParameter("response_type"); + String clientId = servletRequest.getParameter("client_id"); + String redirectUri = servletRequest.getParameter("redirect_uri"); + String scope = servletRequest.getParameter("scope"); + String state = servletRequest.getParameter("state"); + String codeChallenge = servletRequest.getParameter("code_challenge"); + String codeChallengeMethod = servletRequest.getParameter("code_challenge_method"); + + if ("/authorize".equals(servletRequest.getServletPath()) && responseType != null && clientId != null + && redirectUri != null && codeChallenge != null && codeChallengeMethod != null) { + GptAuthorizationRequest request = new GptAuthorizationRequest( + responseType, + clientId, + redirectUri, + scope, + state, + codeChallenge, + codeChallengeMethod + ); + return ResponseEntity.status(ex instanceof UnauthorizedException ? HttpStatus.UNAUTHORIZED : HttpStatus.BAD_REQUEST) + .contentType(pageRenderer.mediaType()) + .body(pageRenderer.render(request, ex.getMessage())); + } + + return ResponseEntity.status(ex instanceof UnauthorizedException ? HttpStatus.UNAUTHORIZED : HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"message\":\"" + ex.getMessage().replace("\"", "'") + "\"}"); + } +} diff --git a/src/main/java/com/jobtracker/dto/gpt/GptAuthorizationLoginRequest.java b/src/main/java/com/jobtracker/dto/gpt/GptAuthorizationLoginRequest.java new file mode 100644 index 00000000..279b1914 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gpt/GptAuthorizationLoginRequest.java @@ -0,0 +1,29 @@ +package com.jobtracker.dto.gpt; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record GptAuthorizationLoginRequest( + @NotBlank(message = "response_type is required") + String response_type, + @NotBlank(message = "client_id is required") + String client_id, + @NotBlank(message = "redirect_uri is required") + String redirect_uri, + String scope, + String state, + @NotBlank(message = "code_challenge is required") + String code_challenge, + @NotBlank(message = "code_challenge_method is required") + String code_challenge_method, + @Email(message = "email must be valid") + @NotBlank(message = "email is required") + String email, + @NotBlank(message = "password is required") + String password, + String approve +) { + public boolean approved() { + return approve != null && !approve.isBlank(); + } +} diff --git a/src/main/java/com/jobtracker/dto/gpt/GptAuthorizationRequest.java b/src/main/java/com/jobtracker/dto/gpt/GptAuthorizationRequest.java new file mode 100644 index 00000000..4ae00b1b --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gpt/GptAuthorizationRequest.java @@ -0,0 +1,19 @@ +package com.jobtracker.dto.gpt; + +import jakarta.validation.constraints.NotBlank; + +public record GptAuthorizationRequest( + @NotBlank(message = "response_type is required") + String response_type, + @NotBlank(message = "client_id is required") + String client_id, + @NotBlank(message = "redirect_uri is required") + String redirect_uri, + String scope, + String state, + @NotBlank(message = "code_challenge is required") + String code_challenge, + @NotBlank(message = "code_challenge_method is required") + String code_challenge_method +) { +} diff --git a/src/main/java/com/jobtracker/dto/gpt/GptTokenRequest.java b/src/main/java/com/jobtracker/dto/gpt/GptTokenRequest.java new file mode 100644 index 00000000..75962059 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gpt/GptTokenRequest.java @@ -0,0 +1,17 @@ +package com.jobtracker.dto.gpt; + +import jakarta.validation.constraints.NotBlank; + +public record GptTokenRequest( + @NotBlank(message = "grant_type is required") + String grant_type, + @NotBlank(message = "code is required") + String code, + @NotBlank(message = "redirect_uri is required") + String redirect_uri, + @NotBlank(message = "code_verifier is required") + String code_verifier, + String client_id, + String client_secret +) { +} diff --git a/src/main/java/com/jobtracker/dto/gpt/GptTokenResponse.java b/src/main/java/com/jobtracker/dto/gpt/GptTokenResponse.java new file mode 100644 index 00000000..cbf5c942 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gpt/GptTokenResponse.java @@ -0,0 +1,16 @@ +package com.jobtracker.dto.gpt; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "OAuth token response for GPT Actions") +public record GptTokenResponse( + @Schema(description = "Access token") + String access_token, + @Schema(description = "Token type", example = "Bearer") + String token_type, + @Schema(description = "Lifetime in seconds", example = "900") + long expires_in, + @Schema(description = "Granted scope value") + String scope +) { +} diff --git a/src/main/java/com/jobtracker/entity/GptOAuthAuthorizationCode.java b/src/main/java/com/jobtracker/entity/GptOAuthAuthorizationCode.java new file mode 100644 index 00000000..99577e27 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/GptOAuthAuthorizationCode.java @@ -0,0 +1,153 @@ +package com.jobtracker.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import org.hibernate.annotations.UuidGenerator; + +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "gpt_oauth_authorization_codes", indexes = { + @Index(name = "uk_gpt_oauth_code_hash", columnList = "code_hash", unique = true), + @Index(name = "idx_gpt_oauth_code_user", columnList = "user_id"), + @Index(name = "idx_gpt_oauth_code_expires", columnList = "expires_at") +}) +public class GptOAuthAuthorizationCode { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "client_id", nullable = false, length = 255) + private String clientId; + + @Column(name = "redirect_uri", nullable = false, length = 500) + private String redirectUri; + + @Column(name = "scope_value", nullable = false, length = 1000) + private String scopeValue; + + @Column(name = "code_hash", nullable = false, length = 128) + private String codeHash; + + @Column(name = "code_challenge", nullable = false, length = 255) + private String codeChallenge; + + @Column(name = "code_challenge_method", nullable = false, length = 20) + private String codeChallengeMethod; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getScopeValue() { + return scopeValue; + } + + public void setScopeValue(String scopeValue) { + this.scopeValue = scopeValue; + } + + public String getCodeHash() { + return codeHash; + } + + public void setCodeHash(String codeHash) { + this.codeHash = codeHash; + } + + public String getCodeChallenge() { + return codeChallenge; + } + + public void setCodeChallenge(String codeChallenge) { + this.codeChallenge = codeChallenge; + } + + public String getCodeChallengeMethod() { + return codeChallengeMethod; + } + + public void setCodeChallengeMethod(String codeChallengeMethod) { + this.codeChallengeMethod = codeChallengeMethod; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getUsedAt() { + return usedAt; + } + + public void setUsedAt(LocalDateTime usedAt) { + this.usedAt = usedAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/jobtracker/repository/GptOAuthAuthorizationCodeRepository.java b/src/main/java/com/jobtracker/repository/GptOAuthAuthorizationCodeRepository.java new file mode 100644 index 00000000..ecd6f370 --- /dev/null +++ b/src/main/java/com/jobtracker/repository/GptOAuthAuthorizationCodeRepository.java @@ -0,0 +1,15 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.GptOAuthAuthorizationCode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +public interface GptOAuthAuthorizationCodeRepository extends JpaRepository { + + Optional findByCodeHash(String codeHash); + + void deleteByExpiresAtBefore(LocalDateTime expiresAt); +} diff --git a/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java b/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java new file mode 100644 index 00000000..938267a2 --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java @@ -0,0 +1,69 @@ +package com.jobtracker.service; + +import com.jobtracker.dto.gpt.GptAuthorizationRequest; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +@Component +public class GptAuthorizationPageRenderer { + + public String render(GptAuthorizationRequest request, String errorMessage) { + String scopes = request.scope() == null ? "" : request.scope(); + String errorBlock = errorMessage == null || errorMessage.isBlank() + ? "" + : "

" + HtmlUtils.htmlEscape(errorMessage) + "

"; + + return """ + + + + + + Authorize GPT Action + + +
+

Authorize GPT Action

+

Sign in with your JobApplyTracker account and approve the requested scopes.

+ %s +

Client: %s

+

Scopes: %s

+
+ + + + + + + + + + + +
+
+ + + """.formatted( + errorBlock, + escape(request.client_id()), + escape(scopes.isBlank() ? "default configured scopes" : scopes), + escape(request.response_type()), + escape(request.client_id()), + escape(request.redirect_uri()), + escape(scopes), + escape(request.state()), + escape(request.code_challenge()), + escape(request.code_challenge_method()) + ); + } + + public MediaType mediaType() { + return MediaType.TEXT_HTML; + } + + private String escape(String value) { + return HtmlUtils.htmlEscape(value == null ? "" : value); + } +} diff --git a/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java b/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java new file mode 100644 index 00000000..13db3295 --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java @@ -0,0 +1,184 @@ +package com.jobtracker.service; + +import com.jobtracker.config.GptOAuthProperties; +import com.jobtracker.dto.gpt.GptAuthorizationLoginRequest; +import com.jobtracker.dto.gpt.GptAuthorizationRequest; +import com.jobtracker.dto.gpt.GptTokenRequest; +import com.jobtracker.dto.gpt.GptTokenResponse; +import com.jobtracker.entity.GptOAuthAuthorizationCode; +import com.jobtracker.entity.User; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.UnauthorizedException; +import com.jobtracker.repository.GptOAuthAuthorizationCodeRepository; +import com.jobtracker.repository.UserRepository; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Base64; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class GptOAuthAuthorizationService { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final AuthenticationManager authenticationManager; + private final UserRepository userRepository; + private final GptOAuthAuthorizationCodeRepository authorizationCodeRepository; + private final GptOAuthClientService clientService; + private final GptOAuthTokenService tokenService; + private final GptOAuthProperties properties; + + public GptOAuthAuthorizationService(AuthenticationManager authenticationManager, + UserRepository userRepository, + GptOAuthAuthorizationCodeRepository authorizationCodeRepository, + GptOAuthClientService clientService, + GptOAuthTokenService tokenService, + GptOAuthProperties properties) { + this.authenticationManager = authenticationManager; + this.userRepository = userRepository; + this.authorizationCodeRepository = authorizationCodeRepository; + this.clientService = clientService; + this.tokenService = tokenService; + this.properties = properties; + } + + public GptOAuthClientService.ValidatedAuthorizationRequest validateAuthorizationRequest(GptAuthorizationRequest request) { + return clientService.validateAuthorizationRequest(request); + } + + @Transactional + public String authorize(GptAuthorizationLoginRequest request) { + GptOAuthClientService.ValidatedAuthorizationRequest validated = clientService.validateAuthorizationRequest( + new GptAuthorizationRequest( + request.response_type(), + request.client_id(), + request.redirect_uri(), + request.scope(), + request.state(), + request.code_challenge(), + request.code_challenge_method() + ) + ); + + if (!request.approved()) { + return buildRedirect(validated.redirectUri(), validated.state(), null, "access_denied", "User denied access"); + } + + User user = authenticateUser(request.email(), request.password()); + authorizationCodeRepository.deleteByExpiresAtBefore(LocalDateTime.now()); + + String code = generateCode(); + GptOAuthAuthorizationCode authorizationCode = new GptOAuthAuthorizationCode(); + authorizationCode.setUser(user); + authorizationCode.setClientId(validated.clientId()); + authorizationCode.setRedirectUri(validated.redirectUri()); + authorizationCode.setScopeValue(validated.scopeValue()); + authorizationCode.setCodeHash(hash(code)); + authorizationCode.setCodeChallenge(validated.codeChallenge()); + authorizationCode.setCodeChallengeMethod(validated.codeChallengeMethod()); + authorizationCode.setExpiresAt(LocalDateTime.now().plusSeconds(properties.getAuthorizationCodeExpirationSeconds())); + authorizationCodeRepository.save(authorizationCode); + + return buildRedirect(validated.redirectUri(), validated.state(), code, null, null); + } + + @Transactional + public GptTokenResponse exchangeToken(GptTokenRequest request, String authorizationHeader) { + clientService.validateClientAuthentication(request, authorizationHeader); + if (!"authorization_code".equals(request.grant_type())) { + throw new BadRequestException("Unsupported grant_type"); + } + + authorizationCodeRepository.deleteByExpiresAtBefore(LocalDateTime.now()); + GptOAuthAuthorizationCode authorizationCode = authorizationCodeRepository.findByCodeHash(hash(request.code())) + .orElseThrow(() -> new UnauthorizedException("Invalid authorization code")); + + if (authorizationCode.getUsedAt() != null) { + throw new UnauthorizedException("Authorization code has already been used"); + } + if (authorizationCode.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new UnauthorizedException("Authorization code has expired"); + } + if (!authorizationCode.getRedirectUri().equals(request.redirect_uri())) { + throw new UnauthorizedException("redirect_uri does not match authorization code"); + } + if (!authorizationCode.getClientId().equals(properties.getClientId())) { + throw new UnauthorizedException("Authorization code does not belong to the configured client"); + } + if (!verifyPkce(request.code_verifier(), authorizationCode.getCodeChallenge(), authorizationCode.getCodeChallengeMethod())) { + throw new UnauthorizedException("Invalid code_verifier"); + } + + authorizationCode.setUsedAt(LocalDateTime.now()); + authorizationCodeRepository.save(authorizationCode); + + Set scopes = Arrays.stream(authorizationCode.getScopeValue().split(" ")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + GptOAuthTokenService.IssuedAccessToken issuedToken = tokenService.issueAccessToken(authorizationCode.getUser(), scopes); + return new GptTokenResponse( + issuedToken.tokenValue(), + "Bearer", + issuedToken.expiresIn(), + issuedToken.scopeValue() + ); + } + + private User authenticateUser(String email, String password) { + try { + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(email, password)); + } catch (AuthenticationException ex) { + throw new UnauthorizedException("Invalid credentials"); + } + + return userRepository.findByEmail(email) + .orElseThrow(() -> new UnauthorizedException("User not found")); + } + + private boolean verifyPkce(String verifier, String expectedChallenge, String method) { + if (!"S256".equals(method)) { + return false; + } + byte[] hashedVerifier = DigestUtils.sha256(verifier.getBytes(StandardCharsets.US_ASCII)); + String actualChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(hashedVerifier); + return actualChallenge.equals(expectedChallenge); + } + + private String buildRedirect(String redirectUri, String state, String code, String error, String errorDescription) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(redirectUri); + if (state != null && !state.isBlank()) { + builder.queryParam("state", state); + } + if (code != null) { + builder.queryParam("code", code); + } + if (error != null) { + builder.queryParam("error", error); + } + if (errorDescription != null) { + builder.queryParam("error_description", errorDescription); + } + return builder.build(true).toUriString(); + } + + private String generateCode() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hash(String value) { + return DigestUtils.sha256Hex(value); + } +} diff --git a/src/main/java/com/jobtracker/service/GptOAuthClientService.java b/src/main/java/com/jobtracker/service/GptOAuthClientService.java new file mode 100644 index 00000000..18413cc1 --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptOAuthClientService.java @@ -0,0 +1,116 @@ +package com.jobtracker.service; + +import com.jobtracker.config.GptOAuthProperties; +import com.jobtracker.dto.gpt.GptAuthorizationRequest; +import com.jobtracker.dto.gpt.GptTokenRequest; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.UnauthorizedException; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.Set; + +@Service +public class GptOAuthClientService { + + private final GptOAuthProperties properties; + + public GptOAuthClientService(GptOAuthProperties properties) { + this.properties = properties; + } + + public ValidatedAuthorizationRequest validateAuthorizationRequest(GptAuthorizationRequest request) { + properties.validateConfigured(); + + if (!"code".equals(request.response_type())) { + throw new BadRequestException("Unsupported response_type"); + } + if (!properties.getClientId().equals(request.client_id())) { + throw new UnauthorizedException("Unknown OAuth client"); + } + if (!properties.supportsRedirectUri(request.redirect_uri())) { + throw new BadRequestException("redirect_uri is not allowed"); + } + if (!"S256".equals(request.code_challenge_method())) { + throw new BadRequestException("Only S256 PKCE is supported"); + } + + Set requestedScopes = parseScopes(request.scope()); + if (requestedScopes.isEmpty()) { + requestedScopes = new LinkedHashSet<>(properties.getScopes()); + } + if (!properties.supportsScopes(requestedScopes)) { + throw new BadRequestException("Requested scope is not allowed"); + } + + return new ValidatedAuthorizationRequest( + request.client_id(), + request.redirect_uri(), + requestedScopes, + request.state(), + request.code_challenge(), + request.code_challenge_method() + ); + } + + public void validateClientAuthentication(GptTokenRequest request, String authorizationHeader) { + properties.validateConfigured(); + ClientCredentials credentials = extractCredentials(request, authorizationHeader); + if (!properties.getClientId().equals(credentials.clientId())) { + throw new UnauthorizedException("Invalid client credentials"); + } + if (!properties.getClientSecret().equals(credentials.clientSecret())) { + throw new UnauthorizedException("Invalid client credentials"); + } + } + + private ClientCredentials extractCredentials(GptTokenRequest request, String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith("Basic ")) { + String decoded = new String(Base64.getDecoder().decode(authorizationHeader.substring(6)), StandardCharsets.UTF_8); + String[] parts = decoded.split(":", 2); + if (parts.length == 2) { + return new ClientCredentials(parts[0], parts[1]); + } + } + if (hasText(request.client_id()) && hasText(request.client_secret())) { + return new ClientCredentials(request.client_id(), request.client_secret()); + } + throw new UnauthorizedException("Client authentication is required"); + } + + private Set parseScopes(String scopeValue) { + Set scopes = new LinkedHashSet<>(); + if (!hasText(scopeValue)) { + return scopes; + } + for (String scope : scopeValue.split(" ")) { + String trimmed = scope.trim(); + if (!trimmed.isBlank()) { + scopes.add(trimmed); + } + } + return scopes; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + public record ValidatedAuthorizationRequest( + String clientId, + String redirectUri, + Set scopes, + String state, + String codeChallenge, + String codeChallengeMethod + ) { + public String scopeValue() { + return String.join(" ", scopes); + } + } + + private record ClientCredentials(String clientId, String clientSecret) { + } +} diff --git a/src/main/java/com/jobtracker/service/GptOAuthTokenService.java b/src/main/java/com/jobtracker/service/GptOAuthTokenService.java new file mode 100644 index 00000000..03fe778f --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptOAuthTokenService.java @@ -0,0 +1,57 @@ +package com.jobtracker.service; + +import com.jobtracker.config.GptOAuthProperties; +import com.jobtracker.entity.User; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +@Service +public class GptOAuthTokenService { + + private final JwtEncoder gptOAuthJwtEncoder; + private final GptOAuthProperties properties; + + public GptOAuthTokenService(JwtEncoder gptOAuthJwtEncoder, GptOAuthProperties properties) { + this.gptOAuthJwtEncoder = gptOAuthJwtEncoder; + this.properties = properties; + } + + public IssuedAccessToken issueAccessToken(User user, Set scopes) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(properties.getAccessTokenExpirationSeconds()); + + JwtClaimsSet claimsSet = JwtClaimsSet.builder() + .issuer(properties.getIssuer()) + .subject(user.getEmail()) + .audience(List.of(properties.getAudience())) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .claim("scope", String.join(" ", scopes)) + .claim("roles", user.getRoles().stream() + .map(role -> "ROLE_" + role.getName().name()) + .toList()) + .claim("user_id", user.getId().toString()) + .claim("token_use", "gpt_action_access") + .build(); + + String tokenValue = gptOAuthJwtEncoder.encode( + JwtEncoderParameters.from( + JwsHeader.with(MacAlgorithm.HS256).build(), + claimsSet + ) + ).getTokenValue(); + + return new IssuedAccessToken(tokenValue, properties.getAccessTokenExpirationSeconds(), String.join(" ", scopes)); + } + + public record IssuedAccessToken(String tokenValue, long expiresIn, String scopeValue) { + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 95c6b09d..99e13e93 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -79,6 +79,15 @@ app: authorization-uri: ${GOOGLE_DRIVE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth} token-uri: ${GOOGLE_DRIVE_TOKEN_URI:https://oauth2.googleapis.com/token} scopes: ${GOOGLE_DRIVE_SCOPES:https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/documents.readonly} + gpt-oauth: + client-id: ${OPENAI_GPT_CLIENT_ID:} + client-secret: ${OPENAI_GPT_CLIENT_SECRET:} + redirect-uris: ${OPENAI_GPT_REDIRECT_URIS:} + scopes: ${OPENAI_GPT_SCOPES:read:profile,read:applications,write:applications,read:resume,read:google-drive,read:metrics} + issuer: ${OPENAI_GPT_ISSUER:${app.api.base-url}} + audience: ${OPENAI_GPT_AUDIENCE:jobtracker-gpt-actions} + authorization-code-expiration-seconds: ${OPENAI_GPT_AUTHORIZATION_CODE_EXPIRATION_SECONDS:300} + access-token-expiration-seconds: ${OPENAI_GPT_ACCESS_TOKEN_EXPIRATION_SECONDS:900} webauthn: rp-id: ${APP_WEBAUTHN_RP_ID:localhost} rp-name: ${APP_WEBAUTHN_RP_NAME:JobApplyTracker} diff --git a/src/main/resources/db/migration/V17__add_gpt_oauth_authorization_codes.sql b/src/main/resources/db/migration/V17__add_gpt_oauth_authorization_codes.sql new file mode 100644 index 00000000..eb637171 --- /dev/null +++ b/src/main/resources/db/migration/V17__add_gpt_oauth_authorization_codes.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS gpt_oauth_authorization_codes ( + id BINARY(16) NOT NULL PRIMARY KEY, + user_id BINARY(16) NOT NULL, + client_id VARCHAR(255) NOT NULL, + redirect_uri VARCHAR(500) NOT NULL, + scope_value VARCHAR(1000) NOT NULL, + code_hash VARCHAR(128) NOT NULL, + code_challenge VARCHAR(255) NOT NULL, + code_challenge_method VARCHAR(20) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + created_at DATETIME NOT NULL, + CONSTRAINT uk_gpt_oauth_code_hash UNIQUE (code_hash), + INDEX idx_gpt_oauth_code_user (user_id), + INDEX idx_gpt_oauth_code_expires (expires_at), + CONSTRAINT fk_gpt_oauth_code_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java b/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java new file mode 100644 index 00000000..fe043321 --- /dev/null +++ b/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java @@ -0,0 +1,243 @@ +package com.jobtracker.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobtracker.dto.auth.AuthResponse; +import com.jobtracker.dto.auth.RegisterRequest; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.repository.GoogleDriveOAuthStateRepository; +import com.jobtracker.repository.GptOAuthAuthorizationCodeRepository; +import com.jobtracker.repository.InterviewEventRepository; +import com.jobtracker.repository.PasswordResetTokenRepository; +import com.jobtracker.repository.RefreshTokenRepository; +import com.jobtracker.repository.UserAchievementRepository; +import com.jobtracker.repository.UserGamificationRepository; +import com.jobtracker.repository.UserInterviewMetricsRepository; +import com.jobtracker.repository.UserRepository; +import com.jobtracker.repository.WebAuthnChallengeRepository; +import com.jobtracker.repository.WebAuthnCredentialRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class GptOAuthFlowIT extends AbstractIntegrationTest { + + private static final String CLIENT_ID = "test-openai-client-id"; + private static final String CLIENT_SECRET = "test-openai-client-secret"; + private static final String REDIRECT_URI = "https://chat.openai.com/aip/test/callback"; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtDecoder gptOAuthJwtDecoder; + @Autowired private UserRepository userRepository; + @Autowired private GoogleDriveConnectionRepository googleDriveConnectionRepository; + @Autowired private GoogleDriveOAuthStateRepository googleDriveOAuthStateRepository; + @Autowired private GptOAuthAuthorizationCodeRepository gptOAuthAuthorizationCodeRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; + @Autowired private PasswordResetTokenRepository passwordResetTokenRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private InterviewEventRepository interviewEventRepository; + @Autowired private UserGamificationRepository userGamificationRepository; + @Autowired private UserAchievementRepository userAchievementRepository; + @Autowired private UserInterviewMetricsRepository userInterviewMetricsRepository; + @Autowired private WebAuthnChallengeRepository webAuthnChallengeRepository; + @Autowired private WebAuthnCredentialRepository webAuthnCredentialRepository; + + @BeforeEach + void cleanDb() { + googleDriveOAuthStateRepository.deleteAll(); + googleDriveConnectionRepository.deleteAll(); + gptOAuthAuthorizationCodeRepository.deleteAll(); + userAchievementRepository.deleteAll(); + userGamificationRepository.deleteAll(); + interviewEventRepository.deleteAll(); + applicationRepository.deleteAll(); + passwordResetTokenRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + webAuthnChallengeRepository.deleteAll(); + webAuthnCredentialRepository.deleteAll(); + userInterviewMetricsRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void oauthCodeFlow_shouldIssueScopedTokenAndAllowGptEndpoints() throws Exception { + registerUser("gpt-user@example.com", "pass1234"); + PkcePair pkcePair = generatePkcePair(); + + String authorizationCode = authorize("gpt-user@example.com", "pass1234", + "read:profile read:applications write:applications read:metrics", pkcePair); + String accessToken = exchangeToken(authorizationCode, pkcePair.verifier()); + + Jwt jwt = gptOAuthJwtDecoder.decode(accessToken); + assertThat(jwt.getSubject()).isEqualTo("gpt-user@example.com"); + assertThat(jwt.getClaimAsString("scope")).contains("write:applications"); + assertThat(jwt.getClaimAsString("token_use")).isEqualTo("gpt_action_access"); + + mockMvc.perform(post("/api/v1/gpt/applications") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "vacancyName": "GPT Backend Engineer", + "organization": "OpenAI", + "vacancyLink": "https://example.com/jobs/gpt-backend", + "applicationDate": "2026-05-01", + "rhAcceptedConnection": false, + "interviewScheduled": false, + "status": "RH", + "recruiterDmReminderEnabled": false + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.vacancyName").value("GPT Backend Engineer")); + + mockMvc.perform(get("/api/v1/gpt/profile") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("gpt-user@example.com")); + + mockMvc.perform(get("/api/v1/gpt/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 + void readOnlyGptToken_shouldRejectWriteEndpoints() throws Exception { + registerUser("readonly-gpt@example.com", "pass1234"); + PkcePair pkcePair = generatePkcePair(); + + String authorizationCode = authorize("readonly-gpt@example.com", "pass1234", + "read:profile read:applications", pkcePair); + String accessToken = exchangeToken(authorizationCode, pkcePair.verifier()); + + mockMvc.perform(post("/api/v1/gpt/applications") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "vacancyName": "Denied Write", + "organization": "OpenAI", + "vacancyLink": "https://example.com/jobs/readonly", + "applicationDate": "2026-05-01", + "rhAcceptedConnection": false, + "interviewScheduled": false, + "status": "RH", + "recruiterDmReminderEnabled": false + } + """)) + .andExpect(status().isForbidden()); + } + + @Test + void legacyJwtFlow_shouldStillWork_andNotAuthenticateAgainstGptEndpoints() throws Exception { + AuthResponse authResponse = registerUser("legacy-user@example.com", "pass1234"); + + mockMvc.perform(get("/api/v1/auth/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + authResponse.accessToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("legacy-user@example.com")); + + mockMvc.perform(get("/api/v1/gpt/profile") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + authResponse.accessToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void googleDriveCallback_shouldRemainPublic() throws Exception { + mockMvc.perform(get("/api/v1/google-drive/oauth/callback") + .param("error", "access_denied")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string(HttpHeaders.LOCATION, org.hamcrest.Matchers.containsString("status=error"))); + } + + private AuthResponse registerUser(String email, String password) throws Exception { + RegisterRequest request = new RegisterRequest("GPT User", email, password, password); + MvcResult result = mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readValue(result.getResponse().getContentAsString(), AuthResponse.class); + } + + private String authorize(String email, String password, String scope, PkcePair pkcePair) throws Exception { + MvcResult result = mockMvc.perform(post("/oauth2/authorize") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("response_type", "code") + .param("client_id", CLIENT_ID) + .param("redirect_uri", REDIRECT_URI) + .param("scope", scope) + .param("state", "test-state") + .param("code_challenge", pkcePair.challenge()) + .param("code_challenge_method", "S256") + .param("email", email) + .param("password", password) + .param("approve", "true")) + .andExpect(status().isFound()) + .andExpect(redirectedUrlPattern(REDIRECT_URI + "?*")) + .andReturn(); + + String redirectLocation = result.getResponse().getHeader(HttpHeaders.LOCATION); + assertThat(redirectLocation).contains("state=test-state"); + return Arrays.stream(java.net.URI.create(redirectLocation).getQuery().split("&")) + .filter(entry -> entry.startsWith("code=")) + .map(entry -> entry.substring("code=".length())) + .findFirst() + .orElseThrow(); + } + + private String exchangeToken(String authorizationCode, String verifier) throws Exception { + String basicAuth = Base64.getEncoder().encodeToString((CLIENT_ID + ":" + CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)); + + MvcResult result = mockMvc.perform(post("/oauth2/token") + .header(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", "authorization_code") + .param("code", authorizationCode) + .param("redirect_uri", REDIRECT_URI) + .param("code_verifier", verifier)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token_type").value("Bearer")) + .andReturn(); + + JsonNode json = objectMapper.readTree(result.getResponse().getContentAsString()); + return json.get("access_token").asText(); + } + + private PkcePair generatePkcePair() throws Exception { + String verifier = Base64.getUrlEncoder().withoutPadding() + .encodeToString("test-code-verifier-1234567890".getBytes(StandardCharsets.US_ASCII)); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + return new PkcePair(verifier, challenge); + } + + private record PkcePair(String verifier, String challenge) { + } +} diff --git a/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java b/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java index c0fd517e..972b60c6 100644 --- a/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java +++ b/src/test/java/com/jobtracker/integration/OpenApiDocumentationIT.java @@ -40,6 +40,18 @@ void applicationsGroup_shouldContainApplicationPathsAndServer() throws Exception assertThat(openApi.path("paths").has("/api/v1/google-drive/status")).isFalse(); } + @Test + void gptActionsGroup_shouldContainOauthSchemeAndGptPaths() 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("/oauth2/token")).isTrue(); + assertThat(openApi.at("/components/securitySchemes/gptOAuth/type").asText()).isEqualTo("oauth2"); + assertThat(openApi.at("/components/securitySchemes/gptOAuth/flows/authorizationCode/authorizationUrl").asText()) + .isEqualTo("https://jobapply-api.hugojava.dev/oauth2/authorize"); + } + private JsonNode fetchOpenApiGroup(String group) throws Exception { String response = mockMvc.perform(get("/v3/api-docs/{group}", group)) .andExpect(status().isOk()) diff --git a/src/test/java/com/jobtracker/unit/GptOAuthPropertiesTest.java b/src/test/java/com/jobtracker/unit/GptOAuthPropertiesTest.java new file mode 100644 index 00000000..c0444304 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/GptOAuthPropertiesTest.java @@ -0,0 +1,34 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.GptOAuthProperties; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class GptOAuthPropertiesTest { + + @Test + void shouldParseConfiguredRedirectUrisAndScopes() { + GptOAuthProperties properties = new GptOAuthProperties( + "openai-client", + "openai-secret", + "https://chat.openai.com/aip/callback-one, https://chat.openai.com/aip/callback-two", + "read:profile, read:applications, write:applications", + "https://jobapply-api.hugojava.dev", + "jobtracker-gpt-actions", + 300, + 900 + ); + + assertThat(properties.isConfigured()).isTrue(); + assertThat(properties.getRedirectUris()) + .containsExactly("https://chat.openai.com/aip/callback-one", "https://chat.openai.com/aip/callback-two"); + assertThat(properties.getScopes()) + .containsExactly("read:profile", "read:applications", "write:applications"); + assertThat(properties.supportsRedirectUri("https://chat.openai.com/aip/callback-two")).isTrue(); + assertThat(properties.supportsScopes(Set.of("read:profile", "write:applications"))).isTrue(); + assertThat(properties.supportsScopes(Set.of("read:metrics"))).isFalse(); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 0d02cfe4..03fb4e74 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -38,6 +38,15 @@ app: client-secret: test-google-client-secret redirect-uri: http://localhost:8080/api/v1/google-drive/oauth/callback oauth-complete-url: http://localhost:5173/settings/google-drive/callback + gpt-oauth: + client-id: test-openai-client-id + client-secret: test-openai-client-secret + redirect-uris: https://chat.openai.com/aip/test/callback + scopes: read:profile,read:applications,write:applications,read:resume,read:google-drive,read:metrics + issuer: https://jobapply-api.hugojava.dev + audience: jobtracker-gpt-actions + authorization-code-expiration-seconds: 300 + access-token-expiration-seconds: 900 webauthn: rp-id: localhost rp-name: JobApplyTracker Test From e6de9c89e41894038546b6ad324beeebe21e73db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 02:24:12 +0000 Subject: [PATCH 2/4] Add GPT action endpoints and docs --- pom.xml | 4 ++++ .../java/com/jobtracker/config/GptOAuthSecurityConfig.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index efa3353b..53dbfe29 100644 --- a/pom.xml +++ b/pom.xml @@ -164,6 +164,10 @@ org.springframework.boot spring-boot-starter-oauth2-client + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + diff --git a/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java index 17917d53..d80b787d 100644 --- a/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java +++ b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java @@ -21,12 +21,12 @@ import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtAuthenticationToken; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; 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; From 0f88b84d343d90fd1dc74ec04309af8782779a7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 02:32:59 +0000 Subject: [PATCH 3/4] Address GPT OAuth validation feedback --- .../config/GptOAuthSecurityConfig.java | 3 +-- .../com/jobtracker/config/SecurityConfig.java | 2 ++ .../controller/GptOAuthController.java | 17 +++++++++++++++-- .../service/GptAuthorizationPageRenderer.java | 6 ++++-- .../service/GptOAuthAuthorizationService.java | 12 +++++++++--- .../service/GptOAuthClientService.java | 5 +++-- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java index d80b787d..d471a9f1 100644 --- a/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java +++ b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java @@ -11,7 +11,6 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; 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.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -49,7 +48,7 @@ public SecurityFilterChain gptOAuthSecurityFilterChain(HttpSecurity http, Converter gptJwtAuthenticationConverter) throws Exception { http .securityMatcher("/oauth2/**", "/api/v1/gpt/**") - .csrf(AbstractHttpConfigurer::disable) + .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**", "/api/v1/gpt/**")) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/oauth2/authorize", "/oauth2/token").permitAll() diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index 92714117..ba490720 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -32,6 +32,8 @@ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, @Bean @Order(2) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // Keep the legacy JWT application chain after the dedicated GPT OAuth chain so + // `/oauth2/**` and `/api/v1/gpt/**` stay isolated from the existing JWT filter. http .cors(Customizer.withDefaults()) // CSRF is safe to disable: this API uses stateless JWT Bearer token diff --git a/src/main/java/com/jobtracker/controller/GptOAuthController.java b/src/main/java/com/jobtracker/controller/GptOAuthController.java index e97beefa..6a1fbb9a 100644 --- a/src/main/java/com/jobtracker/controller/GptOAuthController.java +++ b/src/main/java/com/jobtracker/controller/GptOAuthController.java @@ -1,5 +1,7 @@ package com.jobtracker.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.jobtracker.dto.gpt.GptAuthorizationLoginRequest; import com.jobtracker.dto.gpt.GptAuthorizationRequest; import com.jobtracker.dto.gpt.GptTokenRequest; @@ -30,11 +32,14 @@ public class GptOAuthController { private final GptOAuthAuthorizationService authorizationService; private final GptAuthorizationPageRenderer pageRenderer; + private final ObjectMapper objectMapper; public GptOAuthController(GptOAuthAuthorizationService authorizationService, - GptAuthorizationPageRenderer pageRenderer) { + GptAuthorizationPageRenderer pageRenderer, + ObjectMapper objectMapper) { this.authorizationService = authorizationService; this.pageRenderer = pageRenderer; + this.objectMapper = objectMapper; } @Operation(summary = "Render GPT Action authorization page") @@ -97,6 +102,14 @@ public ResponseEntity handleAuthorizationError(Exception ex, jakarta.ser return ResponseEntity.status(ex instanceof UnauthorizedException ? HttpStatus.UNAUTHORIZED : HttpStatus.BAD_REQUEST) .contentType(MediaType.APPLICATION_JSON) - .body("{\"message\":\"" + ex.getMessage().replace("\"", "'") + "\"}"); + .body(toJsonError(ex.getMessage())); + } + + private String toJsonError(String message) { + try { + return objectMapper.writeValueAsString(java.util.Map.of("message", message)); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Unable to serialize OAuth error response", ex); + } } } diff --git a/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java b/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java index 938267a2..1b6a847a 100644 --- a/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java +++ b/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java @@ -37,8 +37,10 @@ public String render(GptAuthorizationRequest request, String errorMessage) { - - + + + + diff --git a/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java b/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java index 13db3295..e927eed8 100644 --- a/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java +++ b/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java @@ -29,7 +29,6 @@ @Service public class GptOAuthAuthorizationService { - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final AuthenticationManager authenticationManager; private final UserRepository userRepository; @@ -122,7 +121,7 @@ public GptTokenResponse exchangeToken(GptTokenRequest request, String authorizat authorizationCode.setUsedAt(LocalDateTime.now()); authorizationCodeRepository.save(authorizationCode); - Set scopes = Arrays.stream(authorizationCode.getScopeValue().split(" ")) + Set scopes = Arrays.stream(authorizationCode.getScopeValue().split(GptOAuthClientService.SCOPE_DELIMITER)) .map(String::trim) .filter(value -> !value.isBlank()) .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); @@ -174,11 +173,18 @@ private String buildRedirect(String redirectUri, String state, String code, Stri private String generateCode() { byte[] bytes = new byte[32]; - SECURE_RANDOM.nextBytes(bytes); + SecureRandomHolder.INSTANCE.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } private String hash(String value) { return DigestUtils.sha256Hex(value); } + + private static final class SecureRandomHolder { + private static final SecureRandom INSTANCE = new SecureRandom(); + + private SecureRandomHolder() { + } + } } diff --git a/src/main/java/com/jobtracker/service/GptOAuthClientService.java b/src/main/java/com/jobtracker/service/GptOAuthClientService.java index 18413cc1..ae7ce830 100644 --- a/src/main/java/com/jobtracker/service/GptOAuthClientService.java +++ b/src/main/java/com/jobtracker/service/GptOAuthClientService.java @@ -14,6 +14,7 @@ @Service public class GptOAuthClientService { + public static final String SCOPE_DELIMITER = " "; private final GptOAuthProperties properties; @@ -85,7 +86,7 @@ private Set parseScopes(String scopeValue) { if (!hasText(scopeValue)) { return scopes; } - for (String scope : scopeValue.split(" ")) { + for (String scope : scopeValue.split(SCOPE_DELIMITER)) { String trimmed = scope.trim(); if (!trimmed.isBlank()) { scopes.add(trimmed); @@ -107,7 +108,7 @@ public record ValidatedAuthorizationRequest( String codeChallengeMethod ) { public String scopeValue() { - return String.join(" ", scopes); + return String.join(SCOPE_DELIMITER, scopes); } } From e6957a138b7df0f311bdd50a05884e93232b7fa5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 08:50:28 -0300 Subject: [PATCH 4/4] Add OAuth 2.0 + PKCE support for GPT Actions with unified API surface (#39) * Initial plan * 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 * 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. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../config/GptOAuthSecurityConfig.java | 16 +- .../config/JwtAuthenticationFilter.java | 53 +++++- .../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, 104 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 d471a9f1..cca64fbd 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 3de0442e..93fd86c4 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,53 @@ 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; } + log.debug("User JWT authentication failed: token invalid for email={}", userEmail); + return false; } } 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 25ff373a..350d7c9f 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 ba490720..7de3e05c 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 0b331e2e..04a8dd81 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 fc3f8897..83dfdea0 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 3e0b51f9..68a04239 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 20512813..00000000 --- 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 03fe778f..16a8d290 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 fe043321..dbaf23bd 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 972b60c6..4a419f45 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())