diff --git a/.env.example b/.env.example index 7b8e021..c7ffda6 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 10ebf80..8f7d2d9 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/pom.xml b/pom.xml index efa3353..53dbfe2 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/GptOAuthProperties.java b/src/main/java/com/jobtracker/config/GptOAuthProperties.java new file mode 100644 index 0000000..5e119da --- /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 0000000..cca64fb --- /dev/null +++ b/src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java @@ -0,0 +1,126 @@ +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.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.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.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) throws Exception { + http + .securityMatcher("/oauth2/**") + .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**")) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/oauth2/authorize", "/oauth2/token").permitAll() + .anyRequest().denyAll()); + + 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/JwtAuthenticationFilter.java b/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java index 3de0442..93fd86c 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 c70d7f6..350d7c9 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,25 @@ 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( + "/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 cc22c81..7de3e05 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,7 +30,10 @@ 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 @@ -54,6 +58,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // Actuator is served on a dedicated management port (8081) that is never // exposed to the host; security is enforced via Docker network isolation. .requestMatchers("/actuator/**").permitAll() + // GPT OAuth tokens (ROLE_GPT_CLIENT) may access these specific endpoints. + // Method-level @PreAuthorize further enforces required scopes. + .requestMatchers(HttpMethod.GET, "/api/v1/auth/me").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/applications").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.POST, "/api/v1/applications").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/applications/*").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.PATCH, "/api/v1/applications/*/status").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/status").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/base-resumes").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/base-resumes/*/content").hasAnyRole("USER", "GPT_CLIENT") + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/applications/*/generated-resumes/content").hasAnyRole("USER", "GPT_CLIENT") // ROLE_USER endpoints: all remaining application APIs under /api/v1/** // (including /api/v1/auth/me and /api/v1/auth/me/**). .requestMatchers("/api/v1/**").hasRole("USER") diff --git a/src/main/java/com/jobtracker/controller/ApplicationController.java b/src/main/java/com/jobtracker/controller/ApplicationController.java index 0b331e2..04a8dd8 100644 --- a/src/main/java/com/jobtracker/controller/ApplicationController.java +++ b/src/main/java/com/jobtracker/controller/ApplicationController.java @@ -13,6 +13,7 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -42,6 +43,7 @@ public ApplicationController(ApplicationService applicationService, LinkMetadata @ApiResponse(responseCode = "400", description = "Validation error") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_write:applications')") @PostMapping public ResponseEntity create(@Valid @RequestBody ApplicationRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request)); @@ -56,6 +58,7 @@ public ResponseEntity create(@Valid @RequestBody Applicatio @ApiResponse(responseCode = "404", description = "Application not found") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')") @GetMapping("/{id}") public ResponseEntity getById( @Parameter(description = "Application ID", required = true) @PathVariable UUID id) { @@ -88,6 +91,7 @@ public ResponseEntity update( @ApiResponse(responseCode = "404", description = "Application not found") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_write:applications')") @PatchMapping("/{id}/status") public ResponseEntity updateStatus( @Parameter(description = "Application ID", required = true) @PathVariable UUID id, @@ -163,6 +167,7 @@ public ResponseEntity archive( content = @Content(schema = @Schema(implementation = ApplicationPageResponse.class))) } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')") @GetMapping public ResponseEntity getAll( @Parameter(description = "Filter by status") @RequestParam(required = false) String status, diff --git a/src/main/java/com/jobtracker/controller/AuthController.java b/src/main/java/com/jobtracker/controller/AuthController.java index fc3f889..83dfdea 100644 --- a/src/main/java/com/jobtracker/controller/AuthController.java +++ b/src/main/java/com/jobtracker/controller/AuthController.java @@ -33,6 +33,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @Tag(name = "Auth", description = "Authentication and user management endpoints") @@ -205,6 +206,7 @@ public ResponseEntity logout( @ApiResponse(responseCode = "401", description = "Not authenticated") } ) + @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:profile')") @GetMapping("/me") public ResponseEntity me() { return ResponseEntity.ok(authMapper.toUserResponse(securityUtils.getCurrentUser())); diff --git a/src/main/java/com/jobtracker/controller/GoogleDriveController.java b/src/main/java/com/jobtracker/controller/GoogleDriveController.java index 3e0b51f..68a0423 100644 --- a/src/main/java/com/jobtracker/controller/GoogleDriveController.java +++ b/src/main/java/com/jobtracker/controller/GoogleDriveController.java @@ -73,7 +73,7 @@ public void oauthCallback(@RequestParam(required = false) String state, responses = @ApiResponse(responseCode = "200", description = "Current Google Drive integration status", content = @Content(schema = @Schema(implementation = GoogleDriveStatusResponse.class))) ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:google-drive'))") @GetMapping("/status") public ResponseEntity getStatus() { return ResponseEntity.ok(googleDriveService.getStatus()); @@ -114,7 +114,7 @@ public ResponseEntity addBaseResume( responses = @ApiResponse(responseCode = "200", description = "List of base resumes", content = @Content(array = @ArraySchema(schema = @Schema(implementation = BaseResumeResponse.class)))) ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))") @GetMapping("/base-resumes") public ResponseEntity> listBaseResumes() { return ResponseEntity.ok(googleDriveService.listBaseResumes()); @@ -139,7 +139,7 @@ public ResponseEntity deleteBaseResume(@PathVariable UUID baseR @ApiResponse(responseCode = "404", description = "Base resume not found") } ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))") @GetMapping("/base-resumes/{resumeId}/content") public ResponseEntity getBaseResumeContent( @Parameter(description = "UUID of the base resume. NOT the filename.", @@ -157,7 +157,7 @@ public ResponseEntity getBaseResumeContent( @ApiResponse(responseCode = "404", description = "Application or generated resume not found") } ) - @PreAuthorize("hasRole('BETA')") + @PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))") @GetMapping("/applications/{applicationId}/generated-resumes/content") public ResponseEntity getGeneratedResumeContent( @Parameter(description = "UUID of the application.", diff --git a/src/main/java/com/jobtracker/controller/GptOAuthController.java b/src/main/java/com/jobtracker/controller/GptOAuthController.java new file mode 100644 index 0000000..6a1fbb9 --- /dev/null +++ b/src/main/java/com/jobtracker/controller/GptOAuthController.java @@ -0,0 +1,115 @@ +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; +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; + private final ObjectMapper objectMapper; + + public GptOAuthController(GptOAuthAuthorizationService authorizationService, + GptAuthorizationPageRenderer pageRenderer, + ObjectMapper objectMapper) { + this.authorizationService = authorizationService; + this.pageRenderer = pageRenderer; + this.objectMapper = objectMapper; + } + + @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(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/dto/gpt/GptAuthorizationLoginRequest.java b/src/main/java/com/jobtracker/dto/gpt/GptAuthorizationLoginRequest.java new file mode 100644 index 0000000..279b191 --- /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 0000000..4ae00b1 --- /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 0000000..7596205 --- /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 0000000..cbf5c94 --- /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 0000000..99577e2 --- /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 0000000..ecd6f37 --- /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 0000000..1b6a847 --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptAuthorizationPageRenderer.java @@ -0,0 +1,71 @@ +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 0000000..e927eed --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptOAuthAuthorizationService.java @@ -0,0 +1,190 @@ +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 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(GptOAuthClientService.SCOPE_DELIMITER)) + .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]; + 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 new file mode 100644 index 0000000..ae7ce83 --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptOAuthClientService.java @@ -0,0 +1,117 @@ +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 { + public static final String SCOPE_DELIMITER = " "; + + 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(SCOPE_DELIMITER)) { + 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(SCOPE_DELIMITER, 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 0000000..16a8d29 --- /dev/null +++ b/src/main/java/com/jobtracker/service/GptOAuthTokenService.java @@ -0,0 +1,67 @@ +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; +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.ArrayList; +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", buildRolesClaim(user)) + .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) { + } + + 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/main/resources/application.yml b/src/main/resources/application.yml index 95c6b09..99e13e9 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 0000000..eb63717 --- /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 0000000..dbaf23b --- /dev/null +++ b/src/test/java/com/jobtracker/integration/GptOAuthFlowIT.java @@ -0,0 +1,239 @@ +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/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/auth/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("gpt-user@example.com")); + + mockMvc.perform(get("/api/v1/applications") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @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/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/applications") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + authResponse.accessToken())) + .andExpect(status().isOk()); + } + + @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 c0fd517..4a419f4 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_shouldContainOauthSchemeAndExistingApiPaths() throws Exception { + JsonNode openApi = fetchOpenApiGroup("gpt-actions"); + + 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()) + .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 0000000..c044430 --- /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 0d02cfe..03fb4e7 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