Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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://<your-api-host>/oauth2/authorize`
- Token URL: `https://<your-api-host>/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
Expand Down Expand Up @@ -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) |
Expand Down
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<!-- Google Drive SDK -->
<dependency>
Expand Down
107 changes: 107 additions & 0 deletions src/main/java/com/jobtracker/config/GptOAuthProperties.java
Original file line number Diff line number Diff line change
@@ -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<String> redirectUris;
private final List<String> 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<String> getRedirectUris() {
return redirectUris;
}

public List<String> 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<String> requestedScopes) {
return new LinkedHashSet<>(scopes).containsAll(requestedScopes);
}

private List<String> 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();
}
}
126 changes: 126 additions & 0 deletions src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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<SecurityContext> 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<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(properties.getIssuer()),
audienceValidator(properties.getAudience()),
tokenUseValidator()
);
jwtDecoder.setJwtValidator(validator);
return jwtDecoder;
}

@Bean
public Converter<Jwt, ? extends AbstractAuthenticationToken> gptJwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopeConverter = new JwtGrantedAuthoritiesConverter();
scopeConverter.setAuthoritiesClaimName("scope");
scopeConverter.setAuthorityPrefix("SCOPE_");

return jwt -> {
Collection<GrantedAuthority> 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<Jwt> 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<Jwt> 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));
}
}
Loading
Loading