Skip to content

Commit 74a444e

Browse files
authored
chore(deps): update package versions and add OAuth token revocation services (#1946)
1 parent d7f7af8 commit 74a444e

30 files changed

+514
-295
lines changed

apps/api/src/app/s3.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
1+
import { GetObjectCommand, S3Client, type GetObjectCommandOutput } from '@aws-sdk/client-s3';
22
import { Logger } from '@nestjs/common';
33
import '../config/load-env';
44

@@ -126,7 +126,7 @@ export async function getFleetAgent({
126126
os,
127127
}: {
128128
os: 'macos' | 'windows' | 'linux';
129-
}) {
129+
}): Promise<GetObjectCommandOutput['Body']> {
130130
if (!s3Client) {
131131
throw new Error('S3 client not configured');
132132
}

apps/api/src/integration-platform/integration-platform.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { CredentialVaultService } from './services/credential-vault.service';
1212
import { ConnectionService } from './services/connection.service';
1313
import { OAuthCredentialsService } from './services/oauth-credentials.service';
1414
import { AutoCheckRunnerService } from './services/auto-check-runner.service';
15+
import { ConnectionAuthTeardownService } from './services/connection-auth-teardown.service';
16+
import { OAuthTokenRevocationService } from './services/oauth-token-revocation.service';
1517
import { ProviderRepository } from './repositories/provider.repository';
1618
import { ConnectionRepository } from './repositories/connection.repository';
1719
import { CredentialRepository } from './repositories/credential.repository';
@@ -38,6 +40,8 @@ import { CheckRunRepository } from './repositories/check-run.repository';
3840
ConnectionService,
3941
OAuthCredentialsService,
4042
AutoCheckRunnerService,
43+
OAuthTokenRevocationService,
44+
ConnectionAuthTeardownService,
4145
// Repositories
4246
ProviderRepository,
4347
ConnectionRepository,

apps/api/src/integration-platform/repositories/credential.repository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,12 @@ export class CredentialRepository {
9595

9696
return result.count;
9797
}
98+
99+
async deleteAllByConnection(connectionId: string): Promise<number> {
100+
const result = await db.integrationCredentialVersion.deleteMany({
101+
where: { connectionId },
102+
});
103+
104+
return result.count;
105+
}
98106
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { CredentialVaultService } from './credential-vault.service';
3+
import { OAuthTokenRevocationService } from './oauth-token-revocation.service';
4+
import { ConnectionRepository } from '../repositories/connection.repository';
5+
import { CredentialRepository } from '../repositories/credential.repository';
6+
7+
@Injectable()
8+
export class ConnectionAuthTeardownService {
9+
private readonly logger = new Logger(ConnectionAuthTeardownService.name);
10+
11+
constructor(
12+
private readonly connectionRepository: ConnectionRepository,
13+
private readonly credentialVaultService: CredentialVaultService,
14+
private readonly credentialRepository: CredentialRepository,
15+
private readonly oauthTokenRevocationService: OAuthTokenRevocationService,
16+
) {}
17+
18+
/**
19+
* Best-effort teardown of a connection's auth:
20+
* - Revoke provider token if supported/configured
21+
* - Delete all stored credential versions
22+
* - Clear active credential pointer
23+
*/
24+
async teardown({ connectionId }: { connectionId: string }): Promise<void> {
25+
const connection = await this.connectionRepository.findById(connectionId);
26+
if (!connection) return;
27+
28+
const providerSlug = (connection as { provider?: { slug: string } })
29+
.provider?.slug;
30+
31+
const credentials =
32+
await this.credentialVaultService.getDecryptedCredentials(connectionId);
33+
const accessToken = credentials?.access_token;
34+
35+
if (providerSlug && accessToken) {
36+
try {
37+
await this.oauthTokenRevocationService.revokeAccessToken({
38+
providerSlug,
39+
accessToken,
40+
organizationId: connection.organizationId,
41+
});
42+
} catch (error) {
43+
this.logger.warn(
44+
`Failed to revoke OAuth token for ${providerSlug} connection ${connectionId}: ${
45+
error instanceof Error ? error.message : String(error)
46+
}`,
47+
);
48+
}
49+
}
50+
51+
try {
52+
await this.credentialRepository.deleteAllByConnection(connectionId);
53+
} catch (error) {
54+
this.logger.warn(
55+
`Failed deleting credential versions for connection ${connectionId}: ${
56+
error instanceof Error ? error.message : String(error)
57+
}`,
58+
);
59+
}
60+
61+
try {
62+
await this.connectionRepository.update(connectionId, {
63+
activeCredentialVersionId: null,
64+
});
65+
} catch (error) {
66+
this.logger.warn(
67+
`Failed clearing active credential pointer for connection ${connectionId}: ${
68+
error instanceof Error ? error.message : String(error)
69+
}`,
70+
);
71+
}
72+
}
73+
}

apps/api/src/integration-platform/services/connection.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from '@nestjs/common';
66
import { ConnectionRepository } from '../repositories/connection.repository';
77
import { ProviderRepository } from '../repositories/provider.repository';
8+
import { ConnectionAuthTeardownService } from './connection-auth-teardown.service';
89
import type {
910
IntegrationConnection,
1011
IntegrationConnectionStatus,
@@ -22,6 +23,7 @@ export class ConnectionService {
2223
constructor(
2324
private readonly connectionRepository: ConnectionRepository,
2425
private readonly providerRepository: ProviderRepository,
26+
private readonly connectionAuthTeardownService: ConnectionAuthTeardownService,
2527
) {}
2628

2729
async getConnection(connectionId: string): Promise<IntegrationConnection> {
@@ -117,11 +119,19 @@ export class ConnectionService {
117119
async disconnectConnection(
118120
connectionId: string,
119121
): Promise<IntegrationConnection> {
120-
return this.updateConnectionStatus(connectionId, 'disconnected');
122+
await this.connectionAuthTeardownService.teardown({ connectionId });
123+
124+
return this.connectionRepository.update(connectionId, {
125+
status: 'disconnected',
126+
errorMessage: null,
127+
activeCredentialVersionId: null,
128+
});
121129
}
122130

123131
async deleteConnection(connectionId: string): Promise<void> {
124132
await this.getConnection(connectionId); // Verify exists
133+
await this.connectionAuthTeardownService.teardown({ connectionId });
134+
125135
await this.connectionRepository.delete(connectionId);
126136
}
127137

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { getManifest } from '@comp/integration-platform';
3+
import { OAuthCredentialsService } from './oauth-credentials.service';
4+
5+
type OAuthRevokeConfig = {
6+
url: string;
7+
method?: 'POST' | 'DELETE';
8+
auth?: 'basic' | 'bearer' | 'none';
9+
body?: 'form' | 'json';
10+
tokenField?: string;
11+
extraBodyFields?: Record<string, string>;
12+
};
13+
14+
const isOAuthRevokeConfig = (value: unknown): value is OAuthRevokeConfig => {
15+
if (!value || typeof value !== 'object') return false;
16+
17+
const v = value as Record<string, unknown>;
18+
if (typeof v.url !== 'string') return false;
19+
20+
if (v.method !== undefined && v.method !== 'POST' && v.method !== 'DELETE') {
21+
return false;
22+
}
23+
24+
if (
25+
v.auth !== undefined &&
26+
v.auth !== 'basic' &&
27+
v.auth !== 'bearer' &&
28+
v.auth !== 'none'
29+
) {
30+
return false;
31+
}
32+
33+
if (v.body !== undefined && v.body !== 'form' && v.body !== 'json') {
34+
return false;
35+
}
36+
37+
if (v.tokenField !== undefined && typeof v.tokenField !== 'string') {
38+
return false;
39+
}
40+
41+
if (
42+
v.extraBodyFields !== undefined &&
43+
(typeof v.extraBodyFields !== 'object' || v.extraBodyFields === null)
44+
) {
45+
return false;
46+
}
47+
48+
return true;
49+
};
50+
51+
@Injectable()
52+
export class OAuthTokenRevocationService {
53+
private readonly logger = new Logger(OAuthTokenRevocationService.name);
54+
55+
constructor(
56+
private readonly oauthCredentialsService: OAuthCredentialsService,
57+
) {}
58+
59+
async revokeAccessToken({
60+
providerSlug,
61+
organizationId,
62+
accessToken,
63+
}: {
64+
providerSlug: string;
65+
organizationId: string;
66+
accessToken: string;
67+
}): Promise<void> {
68+
const manifest = getManifest(providerSlug);
69+
if (!manifest || manifest.auth.type !== 'oauth2') return;
70+
71+
const oauthConfig = manifest.auth.config;
72+
if (!('revoke' in oauthConfig)) return;
73+
74+
const revokeConfigUnknown = oauthConfig.revoke;
75+
if (!isOAuthRevokeConfig(revokeConfigUnknown)) return;
76+
77+
const revokeConfig = {
78+
url: revokeConfigUnknown.url,
79+
method: revokeConfigUnknown.method ?? 'POST',
80+
auth: revokeConfigUnknown.auth ?? 'basic',
81+
body: revokeConfigUnknown.body ?? 'form',
82+
tokenField: revokeConfigUnknown.tokenField ?? 'token',
83+
extraBodyFields: revokeConfigUnknown.extraBodyFields,
84+
};
85+
86+
const oauthCreds = await this.oauthCredentialsService.getCredentials(
87+
providerSlug,
88+
organizationId,
89+
);
90+
91+
const url = revokeConfig.url.replace(
92+
'{CLIENT_ID}',
93+
encodeURIComponent(oauthCreds?.clientId ?? ''),
94+
);
95+
96+
const headers: Record<string, string> = {
97+
Accept: 'application/json',
98+
'User-Agent': 'CompAI-Integration',
99+
};
100+
101+
if (revokeConfig.auth === 'basic') {
102+
if (!oauthCreds?.clientId || !oauthCreds.clientSecret) {
103+
this.logger.warn(
104+
`OAuth credentials not configured; cannot revoke token for ${providerSlug} org ${organizationId}`,
105+
);
106+
return;
107+
}
108+
109+
const basicAuth = Buffer.from(
110+
`${oauthCreds.clientId}:${oauthCreds.clientSecret}`,
111+
).toString('base64');
112+
headers.Authorization = `Basic ${basicAuth}`;
113+
}
114+
115+
if (revokeConfig.auth === 'bearer') {
116+
headers.Authorization = `Bearer ${accessToken}`;
117+
}
118+
119+
let body: string | undefined;
120+
if (revokeConfig.body === 'json') {
121+
headers['Content-Type'] = 'application/json';
122+
body = JSON.stringify({
123+
[revokeConfig.tokenField]: accessToken,
124+
...(revokeConfig.extraBodyFields ?? {}),
125+
});
126+
} else {
127+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
128+
const params = new URLSearchParams({
129+
[revokeConfig.tokenField]: accessToken,
130+
...(revokeConfig.extraBodyFields ?? {}),
131+
});
132+
body = params.toString();
133+
}
134+
135+
const response = await fetch(url, {
136+
method: revokeConfig.method,
137+
headers,
138+
body,
139+
});
140+
141+
// Treat 404 as already revoked.
142+
if (response.ok || response.status === 404) return;
143+
144+
const text = await response.text().catch(() => '');
145+
throw new Error(
146+
`Token revoke failed for ${providerSlug} (${response.status}): ${text.slice(0, 200)}`,
147+
);
148+
}
149+
}

bun.lock

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,56 +13,56 @@
1313
"react-syntax-highlighter": "^15.6.6",
1414
"unpdf": "^1.4.0",
1515
"xlsx": "^0.18.5",
16-
"zod": "^4.0.0",
16+
"zod": "^4.2.1",
1717
},
1818
"devDependencies": {
1919
"@azure/core-http": "^3.0.5",
20-
"@azure/core-rest-pipeline": "^1.21.0",
21-
"@azure/core-tracing": "^1.2.0",
22-
"@azure/identity": "^4.10.0",
20+
"@azure/core-rest-pipeline": "^1.22.2",
21+
"@azure/core-tracing": "^1.3.1",
22+
"@azure/identity": "^4.13.0",
2323
"@commitlint/cli": "^19.8.1",
2424
"@commitlint/config-conventional": "^19.8.1",
25-
"@hookform/resolvers": "^5.1.1",
26-
"@number-flow/react": "^0.5.9",
25+
"@hookform/resolvers": "^5.2.2",
26+
"@number-flow/react": "^0.5.10",
2727
"@prisma/adapter-pg": "6.10.1",
2828
"@react-email/components": "^0.0.41",
29-
"@react-email/render": "^1.1.2",
29+
"@react-email/render": "^1.4.0",
3030
"@semantic-release/changelog": "^6.0.3",
3131
"@semantic-release/commit-analyzer": "^13.0.1",
3232
"@semantic-release/git": "^10.0.1",
33-
"@semantic-release/github": "^11.0.3",
34-
"@semantic-release/npm": "^12.0.1",
35-
"@semantic-release/release-notes-generator": "^14.0.3",
36-
"@types/bun": "^1.2.15",
33+
"@semantic-release/github": "^11.0.6",
34+
"@semantic-release/npm": "^12.0.2",
35+
"@semantic-release/release-notes-generator": "^14.1.0",
36+
"@types/bun": "^1.3.4",
3737
"@types/d3": "^7.4.3",
38-
"@types/lodash": "^4.17.17",
39-
"@types/react": "^19.1.6",
40-
"@types/react-dom": "^19.1.1",
41-
"ai": "^5.0.0",
42-
"concurrently": "^9.1.2",
38+
"@types/lodash": "^4.17.21",
39+
"@types/react": "^19.2.7",
40+
"@types/react-dom": "^19.2.3",
41+
"ai": "^5.0.115",
42+
"concurrently": "^9.2.1",
4343
"d3": "^7.9.0",
4444
"date-fns": "^4.1.0",
45-
"dayjs": "^1.11.13",
46-
"execa": "^9.0.0",
45+
"dayjs": "^1.11.19",
46+
"execa": "^9.6.1",
4747
"gitmoji": "^1.1.1",
4848
"gray-matter": "^4.0.3",
4949
"husky": "^9.1.7",
50-
"prettier": "^3.5.3",
51-
"prettier-plugin-organize-imports": "^4.1.0",
52-
"prettier-plugin-tailwindcss": "^0.6.0",
50+
"prettier": "^3.7.4",
51+
"prettier-plugin-organize-imports": "^4.3.0",
52+
"prettier-plugin-tailwindcss": "^0.6.14",
5353
"react-dnd": "^16.0.1",
5454
"react-dnd-html5-backend": "^16.0.1",
55-
"react-email": "^4.0.15",
56-
"react-hook-form": "^7.61.1",
57-
"semantic-release": "^24.2.8",
55+
"react-email": "^4.3.2",
56+
"react-hook-form": "^7.68.0",
57+
"semantic-release": "^24.2.9",
5858
"semantic-release-discord": "^1.2.0",
59-
"semantic-release-discord-notifier": "^1.0.11",
60-
"sharp": "^0.34.2",
59+
"semantic-release-discord-notifier": "^1.1.1",
60+
"sharp": "^0.34.5",
6161
"syncpack": "^13.0.4",
62-
"tsup": "^8.5.0",
63-
"turbo": "^2.5.4",
64-
"typescript": "^5.8.3",
65-
"use-debounce": "^10.0.4",
62+
"tsup": "^8.5.1",
63+
"turbo": "^2.6.3",
64+
"typescript": "^5.9.3",
65+
"use-debounce": "^10.0.6",
6666
},
6767
},
6868
"apps/api": {

0 commit comments

Comments
 (0)