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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ MAIL_USERNAME=no-reply@example.dev
MAIL_SMTP_AUTH=true
MAIL_SMTP_SSL=true

# Google Drive OAuth
# Redirect URI must exactly match the OAuth client config in Google Cloud Console.
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

# ============================================
# Synkra AIOX Environment Configuration
# ============================================
Expand Down
164 changes: 164 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ Flyway seeds the roles catalog (`USER`, `BETA`, `ADMIN`) and then assigns `ROLE_
| GET | `/api/v1/gamification/achievements` | List achievement catalog with unlocked state |
| POST | `/api/v1/gamification/events` | Apply a tracked XP event and return updated profile |

### Google Drive

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/google-drive/oauth/start` | Generate the Google OAuth authorization URL for the authenticated user |
| GET | `/api/v1/google-drive/oauth/callback` | Google OAuth callback endpoint used by Google Cloud |
| GET | `/api/v1/google-drive/status` | Return current Google Drive connection status, configured root folder, and base resumes |
| DELETE | `/api/v1/google-drive/connection` | Disconnect the current user's Google account and remove stored Drive preferences |
| PUT | `/api/v1/google-drive/root-folder` | Validate and save the user's base Drive folder |
| POST | `/api/v1/google-drive/base-resumes` | Register a Google Docs base resume by Google Docs URL or file ID |
| DELETE | `/api/v1/google-drive/base-resumes/{baseResumeId}` | Remove a configured base resume |
| POST | `/api/v1/google-drive/applications/{applicationId}/resume-copies` | Copy a configured base resume into the application's Drive subfolder |

## Application Status Values

- `RH`
Expand Down Expand Up @@ -210,9 +223,156 @@ export DB_URL=jdbc:mariadb://localhost:3306/jobtracker?createDatabaseIfNotExist=
export DB_USERNAME=jobtracker
export DB_PASSWORD=jobtracker
export JWT_SECRET=your-secret-key-at-least-256-bits-long
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
mvn spring-boot:run
```

## Google Drive integration

This backend supports per-user Google Drive OAuth2 for resume-copy automation. It does **not** replace the app's JWT auth flow; users stay authenticated with the existing bearer token, and Google is connected separately with `POST /api/v1/google-drive/oauth/start`.

### Required Google Cloud setup

1. Create a Google Cloud OAuth client for a web application.
2. Enable the **Google Drive API**.
3. Add the backend callback URL as an authorized redirect URI. Example local value:
- `http://localhost:8080/api/v1/google-drive/oauth/callback`
4. Configure these environment variables:

| Variable | Required | Description |
|----------|----------|-------------|
| `GOOGLE_DRIVE_CLIENT_ID` | Yes | Google OAuth client ID |
| `GOOGLE_DRIVE_CLIENT_SECRET` | Yes | Google OAuth client secret |
| `GOOGLE_DRIVE_REDIRECT_URI` | Yes | Backend callback URL registered in Google Cloud |
| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | Yes | Frontend page that receives the final `status` and `message` query params after OAuth finishes |
| `GOOGLE_DRIVE_AUTHORIZATION_URI` | No | Override Google authorization endpoint |
| `GOOGLE_DRIVE_TOKEN_URI` | No | Override Google token endpoint |

### OAuth flow expectations

1. Frontend calls `POST /api/v1/google-drive/oauth/start` with the user's JWT bearer token.
2. Backend creates a short-lived OAuth state tied to that authenticated user and returns:
- `authorizationUrl`
- `state`
- `redirectUri`
- `scopes`
3. Frontend opens `authorizationUrl` in a new tab or popup.
4. Google redirects back to `GET /api/v1/google-drive/oauth/callback`.
5. Backend exchanges the authorization code for user-scoped Drive credentials, stores them, and redirects the browser to `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` with:
- `status=success|error`
- `message=<url-encoded message>`

### Scope used

- `https://www.googleapis.com/auth/drive`

This scope is used so the backend can validate user-selected Drive folders, read chosen Google Docs metadata, create vacancy subfolders, and copy Google Docs files on behalf of the authenticated user.

### Supported files

- Base resumes must be **Google Docs** (`application/vnd.google-apps.document`).
- The root folder must be a **Google Drive folder**.
- The frontend Gemini button that opens `https://gemini.google.com/gem/f8ed7c14b062` is frontend-only and does not require a backend endpoint.

### Resume copy behavior

When the frontend later calls `POST /api/v1/google-drive/applications/{applicationId}/resume-copies`:

1. Backend verifies the current user owns the application.
2. Backend refreshes the user's Google access token if needed.
3. Backend verifies the configured root folder still exists and is a folder.
4. Backend finds or creates a vacancy subfolder under that root folder using the application identity.
5. Backend copies the selected base Google Doc into that subfolder.
6. Backend renames the copy with an `APP-<application-uuid>` prefix plus vacancy context.
7. Backend returns a Google Docs web URL for the copied file.

### Google Drive request/response shapes

`POST /api/v1/google-drive/oauth/start`

```json
{}
```

Response:

```json
{
"authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth?...",
"state": "generated-state",
"redirectUri": "http://localhost:8080/api/v1/google-drive/oauth/callback",
"scopes": [
"https://www.googleapis.com/auth/drive"
]
}
```

`GET /api/v1/google-drive/status`

```json
{
"configured": true,
"connected": true,
"googleEmail": "user@gmail.com",
"googleDisplayName": "User Name",
"googleAccountId": "permission-id",
"rootFolderId": "drive-folder-id",
"rootFolderName": "Job Tracker Root",
"connectedAt": "2026-05-05T12:00:00",
"baseResumes": [
{
"id": "uuid",
"googleFileId": "google-doc-id",
"documentName": "Resume Base",
"webViewLink": "https://docs.google.com/document/d/google-doc-id/edit",
"createdAt": "2026-05-05T12:05:00"
}
]
}
```

`PUT /api/v1/google-drive/root-folder`

```json
{
"folderIdOrUrl": "https://drive.google.com/drive/folders/drive-folder-id"
}
```

`POST /api/v1/google-drive/base-resumes`

```json
{
"documentIdOrUrl": "https://docs.google.com/document/d/google-doc-id/edit"
}
```

`POST /api/v1/google-drive/applications/{applicationId}/resume-copies`

```json
{
"baseResumeId": "base-resume-uuid"
}
```

Response:

```json
{
"applicationId": "application-uuid",
"baseResumeId": "base-resume-uuid",
"copiedFileId": "copied-google-doc-id",
"copiedFileName": "APP-application-uuid - Backend Engineer - Resume Base",
"documentWebViewLink": "https://docs.google.com/document/d/copied-google-doc-id/edit",
"vacancyFolderId": "vacancy-folder-id",
"vacancyFolderName": "Backend Engineer - APP-application-uuid",
"vacancyFolderWebViewLink": "https://drive.google.com/drive/folders/vacancy-folder-id"
}
```

## Running Tests

```bash
Expand Down Expand Up @@ -274,6 +434,10 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use
| `JWT_ACCESS_TOKEN_EXPIRATION_MS` | `900000` | Access token TTL (15 min) |
| `JWT_REFRESH_TOKEN_EXPIRATION_MS` | `604800000` | Refresh token TTL (7 days) |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000,http://localhost:5173` | Allowed CORS origins |
| `GOOGLE_DRIVE_CLIENT_ID` | *(empty)* | Google OAuth client ID for Drive integration |
| `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 |
| `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
24 changes: 24 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,30 @@
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>

<!-- Spring OAuth2 Client (token exchange infrastructure) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

<!-- Google Drive SDK -->
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-drive</artifactId>
<version>v3-rev20240521-2.0.0</version>
</dependency>

<!-- Google Auth Library (Credentials adapter for the Drive SDK) -->
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.25.0</version>
</dependency>

<!-- Test -->
<dependency>
Expand Down
119 changes: 119 additions & 0 deletions src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.jobtracker.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
import org.springframework.web.client.RestTemplate;

import java.time.Duration;
import java.util.List;

/**
* Configures the Spring OAuth2 client infrastructure used by the Google Drive integration.
* <p>
* The {@link ClientRegistration} bean captures all Google OAuth2 parameters in one place so that
* Spring's standard {@link DefaultAuthorizationCodeTokenResponseClient} and
* {@link DefaultRefreshTokenTokenResponseClient} can handle token exchange and refresh without any
* manual HTTP calls.
* <p>
* <b>How the Spring Security context provides the authorized client:</b><br>
* When a user completes the OAuth2 consent flow, {@code GoogleDriveOAuthService.handleCallback()}
* calls {@code authorizationCodeTokenResponseClient.getTokenResponse(grantRequest)}. The resulting
* access and refresh tokens are stored in our {@code google_drive_connections} table via
* {@code GoogleDriveConnectionRepository}. For every subsequent Drive API call,
* {@code GoogleDriveService} loads the connection, refreshes the token if needed using
* {@code refreshTokenResponseClient}, and passes the fresh access token to {@link
* com.jobtracker.service.DriveClientFactory#create} – which wraps it in a Google
* {@link com.google.auth.oauth2.OAuth2Credentials} so the SDK can authenticate automatically.
*/
@Configuration
public class GoogleDriveClientConfig {

private static final int CONNECT_TIMEOUT_MS = 10_000;
private static final int READ_TIMEOUT_MS = 30_000;

/**
* The {@link ClientRegistration} for Google Drive. Built programmatically from
* {@link GoogleDriveProperties} so we have a single source of truth for OAuth2 parameters.
* Note: a {@code ClientRegistrationRepository} bean is intentionally <em>not</em> created;
* that would trigger Spring Security's OAuth2 login auto-configuration which is incompatible
* with this application's stateless JWT architecture.
*/
@Bean
public ClientRegistration googleDriveClientRegistration(GoogleDriveProperties properties) {
return ClientRegistration.withRegistrationId("google-drive")
.clientId(properties.getClientId())
.clientSecret(properties.getClientSecret())
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(properties.getRedirectUri())
.scope(properties.getScopes().toArray(new String[0]))
.authorizationUri(properties.getAuthorizationUri())
.tokenUri(properties.getTokenUri())
.build();
}

/**
* Token response client for the Authorization Code grant. Replaces manual RestClient HTTP
* calls with Spring Security's type-safe implementation. Configured with the same timeouts
* used elsewhere in the application.
*/
@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> authorizationCodeTokenResponseClient() {
DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();
client.setRestOperations(buildRestTemplate());
return client;
}

/**
* Token response client for the Refresh Token grant. Used by {@code GoogleDriveService} to
* silently renew expired access tokens before each Drive API call.
*/
@Bean
public OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> refreshTokenResponseClient() {
DefaultRefreshTokenTokenResponseClient client = new DefaultRefreshTokenTokenResponseClient();
client.setRestOperations(buildRestTemplate());
return client;
}

/**
* Builds a {@link RestTemplate} configured with the message converters and error handler
* required by Spring Security's OAuth2 token endpoint clients.
* <p>
* {@link FormHttpMessageConverter} encodes the token-exchange form body; without it the
* request body is empty and Google returns {@code invalid_grant}.
* {@link OAuth2AccessTokenResponseHttpMessageConverter} parses Google's JSON token response;
* without it the response body cannot be deserialized and the exchange fails.
* {@link OAuth2ErrorResponseErrorHandler} translates Google error responses into typed
* {@link org.springframework.security.oauth2.core.OAuth2AuthorizationException}s rather than
* raw {@link org.springframework.web.client.HttpClientErrorException}s.
* <p>
* This {@link RestTemplate} is <em>only</em> used for OAuth2 token-endpoint exchanges.
* It intentionally registers only the two converters required for that operation, which is
* why the default message converters ({@code StringHttpMessageConverter}, etc.) are not
* present.
*/
private RestTemplate buildRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout((int) Duration.ofMillis(CONNECT_TIMEOUT_MS).toMillis());
factory.setReadTimeout((int) Duration.ofMillis(READ_TIMEOUT_MS).toMillis());
RestTemplate restTemplate = new RestTemplate(factory);
restTemplate.setMessageConverters(List.of(
new FormHttpMessageConverter(),
new OAuth2AccessTokenResponseHttpMessageConverter()
));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
return restTemplate;
}
}
Loading
Loading