Files
twenty/packages/twenty-server/test/integration/oauth/suites/oauth.integration-spec.ts
T
martmullandGitHub fe1377f18b Provide applicatiion assets (#18973)
- improve backend
- improve frontend

<img width="1293" height="824" alt="image"
src="https://github.com/user-attachments/assets/7a4633f1-85cd-4126-b058-dbeae6ba2218"
/>
2026-03-30 10:53:31 +02:00

864 lines
26 KiB
TypeScript

import crypto from 'crypto';
import bcrypt from 'bcrypt';
import request from 'supertest';
import { base64UrlEncode } from 'twenty-shared/utils';
import { type DataSource } from 'typeorm';
import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity';
const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
const TEST_USER_ID = '20202020-e6b5-4680-8a32-b8209737156b';
type TestRegistration = {
id: string;
universalIdentifier: string;
name: string;
oAuthClientId: string;
oAuthRedirectUris: string[];
oAuthScopes: string[];
};
type TestApplication = {
id: string;
};
const insertRegistration = async (
ds: DataSource,
params: {
name: string;
clientSecretHash: string;
redirectUris: string[];
scopes: string[];
},
): Promise<TestRegistration> => {
const id = crypto.randomUUID();
const universalIdentifier = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
await ds.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId", "oAuthClientSecretHash", "oAuthRedirectUris", "oAuthScopes", "workspaceId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
universalIdentifier,
params.name,
oAuthClientId,
params.clientSecretHash,
params.redirectUris,
params.scopes,
TEST_WORKSPACE_ID,
],
);
return {
id,
universalIdentifier,
name: params.name,
oAuthClientId,
oAuthRedirectUris: params.redirectUris,
oAuthScopes: params.scopes,
};
};
const insertApplication = async (
ds: DataSource,
params: {
universalIdentifier: string;
name: string;
workspaceId: string;
applicationRegistrationId: string;
},
): Promise<TestApplication> => {
const id = crypto.randomUUID();
await ds.query(
`INSERT INTO core."application"
(id, "universalIdentifier", name, "workspaceId", "applicationRegistrationId", "sourceType", "sourcePath", "canBeUninstalled")
VALUES ($1, $2, $3, $4, $5, 'local', '', true)`,
[
id,
params.universalIdentifier,
params.name,
params.workspaceId,
params.applicationRegistrationId,
],
);
return { id };
};
const insertAppToken = async (
ds: DataSource,
params: {
value: string;
type: AppTokenType;
userId: string;
workspaceId: string;
expiresAt: Date;
context?: Record<string, string>;
},
): Promise<string> => {
const rows = await ds.query(
`INSERT INTO core."appToken"
(value, type, "userId", "workspaceId", "expiresAt", context)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
[
params.value,
params.type,
params.userId,
params.workspaceId,
params.expiresAt,
params.context ? JSON.stringify(params.context) : null,
],
);
return rows[0].id;
};
describe('OAuth (integration)', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
let ds: DataSource;
let testRegistration: TestRegistration;
let testClientSecret: string;
let testApplication: TestApplication;
let autoInstallRegistration: TestRegistration;
let autoInstallClientSecret: string;
const createdEntityIds: {
registrations: string[];
tokens: string[];
applications: string[];
} = { registrations: [], tokens: [], applications: [] };
beforeAll(async () => {
ds = global.testDataSource;
testClientSecret = crypto.randomBytes(32).toString('hex');
const clientSecretHash = await bcrypt.hash(testClientSecret, 10);
testRegistration = await insertRegistration(ds, {
name: 'OAuth Integration Test App',
clientSecretHash,
redirectUris: ['https://example.com/callback'],
scopes: ['read', 'write'],
});
createdEntityIds.registrations.push(testRegistration.id);
testApplication = await insertApplication(ds, {
universalIdentifier: testRegistration.universalIdentifier,
name: testRegistration.name,
workspaceId: TEST_WORKSPACE_ID,
applicationRegistrationId: testRegistration.id,
});
autoInstallClientSecret = crypto.randomBytes(32).toString('hex');
const autoInstallSecretHash = await bcrypt.hash(
autoInstallClientSecret,
10,
);
autoInstallRegistration = await insertRegistration(ds, {
name: 'OAuth Auto-Install Test App',
clientSecretHash: autoInstallSecretHash,
redirectUris: ['https://example.com/callback'],
scopes: ['api'],
});
createdEntityIds.registrations.push(autoInstallRegistration.id);
});
afterAll(async () => {
if (createdEntityIds.tokens.length > 0) {
const placeholders = createdEntityIds.tokens
.map((_, i) => `$${i + 1}`)
.join(', ');
await ds.query(
`DELETE FROM core."appToken" WHERE id IN (${placeholders})`,
createdEntityIds.tokens,
);
}
if (createdEntityIds.applications.length > 0) {
const placeholders = createdEntityIds.applications
.map((_, i) => `$${i + 1}`)
.join(', ');
await ds.query(
`DELETE FROM core."application" WHERE id IN (${placeholders})`,
createdEntityIds.applications,
);
}
if (testApplication) {
await ds.query(`DELETE FROM core."application" WHERE id = $1`, [
testApplication.id,
]);
}
if (createdEntityIds.registrations.length > 0) {
const placeholders = createdEntityIds.registrations
.map((_, i) => `$${i + 1}`)
.join(', ');
await ds.query(
`DELETE FROM core."applicationRegistration" WHERE id IN (${placeholders})`,
createdEntityIds.registrations,
);
}
});
const postToken = (body: Record<string, string>) =>
request(baseUrl).post('/oauth/token').send(body);
describe('Discovery endpoint', () => {
it('should return OAuth authorization server metadata', async () => {
const res = await request(baseUrl)
.get('/.well-known/oauth-authorization-server')
.expect(200);
expect(res.body.token_endpoint).toContain('/oauth/token');
expect(res.body.revocation_endpoint).toContain('/oauth/revoke');
expect(res.body.introspection_endpoint).toContain('/oauth/introspect');
expect(res.body.grant_types_supported).toEqual(
expect.arrayContaining([
'authorization_code',
'client_credentials',
'refresh_token',
]),
);
expect(res.body.code_challenge_methods_supported).toContain('S256');
expect(res.body.scopes_supported).toBeDefined();
expect(res.body.response_types_supported).toContain('code');
});
});
describe('Token endpoint validation', () => {
it('should return 400 for unsupported grant_type', async () => {
const res = await postToken({
grant_type: 'password',
client_id: testRegistration.oAuthClientId,
}).expect(400);
expect(res.body.error).toBe('unsupported_grant_type');
});
it('should return 401 for invalid client_id', async () => {
const res = await postToken({
grant_type: 'client_credentials',
client_id: 'non-existent-client',
client_secret: testClientSecret,
}).expect(401);
expect(res.body.error).toBe('invalid_client');
});
it('should return 401 for invalid client_secret', async () => {
const res = await postToken({
grant_type: 'client_credentials',
client_id: testRegistration.oAuthClientId,
client_secret: 'wrong-secret',
}).expect(401);
expect(res.body.error).toBe('invalid_client');
});
it('should include Cache-Control: no-store header on responses', async () => {
const res = await postToken({
grant_type: 'client_credentials',
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(200);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.headers['pragma']).toBe('no-cache');
});
it('should return 400 when grant_type is missing', async () => {
await postToken({
client_id: testRegistration.oAuthClientId,
}).expect(400);
});
});
describe('Client credentials grant', () => {
it('should issue an access token for valid credentials', async () => {
const res = await postToken({
grant_type: 'client_credentials',
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(res.body.expires_in).toBeGreaterThan(0);
expect(res.body.scope).toBe('read write');
expect(res.body.refresh_token).toBeUndefined();
});
});
describe('Authorization code grant', () => {
const createAuthorizationCode = async (
clientId: string,
redirectUri = 'https://example.com/callback',
): Promise<string> => {
const code = crypto.randomBytes(42).toString('hex');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const tokenId = await insertAppToken(ds, {
value: hashedCode,
type: AppTokenType.AuthorizationCode,
userId: TEST_USER_ID,
workspaceId: TEST_WORKSPACE_ID,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
context: { redirectUri, clientId },
});
createdEntityIds.tokens.push(tokenId);
return code;
};
it('should exchange a valid authorization code for tokens', async () => {
const code = await createAuthorizationCode(
testRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.refresh_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(res.body.expires_in).toBeGreaterThan(0);
});
it('should reject a reused authorization code', async () => {
const code = await createAuthorizationCode(
testRegistration.oAuthClientId,
);
await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
});
it('should reject an expired authorization code', async () => {
const code = crypto.randomBytes(42).toString('hex');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const tokenId = await insertAppToken(ds, {
value: hashedCode,
type: AppTokenType.AuthorizationCode,
userId: TEST_USER_ID,
workspaceId: TEST_WORKSPACE_ID,
expiresAt: new Date(Date.now() - 1000),
context: { clientId: testRegistration.oAuthClientId },
});
createdEntityIds.tokens.push(tokenId);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
});
it('should reject when redirect_uri does not match', async () => {
const code = await createAuthorizationCode(
testRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://evil.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
});
it('should reject when auth code was issued to a different client', async () => {
const code = await createAuthorizationCode(
autoInstallRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
expect(res.body.error_description).toContain('not issued to this client');
});
it('should require either client_secret or code_verifier', async () => {
const code = await createAuthorizationCode(
testRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
redirect_uri: 'https://example.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_request');
});
});
describe('Authorization code grant with PKCE', () => {
const createAuthCodeWithPkce = async (
clientId: string,
): Promise<{
code: string;
codeVerifier: string;
}> => {
const codeVerifier = crypto.randomBytes(32).toString('hex');
const codeChallenge = base64UrlEncode(
crypto.createHash('sha256').update(codeVerifier).digest(),
);
const code = crypto.randomBytes(42).toString('hex');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const codeTokenId = await insertAppToken(ds, {
value: hashedCode,
type: AppTokenType.AuthorizationCode,
userId: TEST_USER_ID,
workspaceId: TEST_WORKSPACE_ID,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
context: {
redirectUri: 'https://example.com/callback',
clientId,
codeChallenge,
},
});
createdEntityIds.tokens.push(codeTokenId);
return { code, codeVerifier };
};
it('should exchange code with valid PKCE verifier', async () => {
const { code, codeVerifier } = await createAuthCodeWithPkce(
testRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
code_verifier: codeVerifier,
redirect_uri: 'https://example.com/callback',
}).expect(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.refresh_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
});
it('should reject code with wrong PKCE verifier', async () => {
const { code } = await createAuthCodeWithPkce(
testRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
code_verifier: 'wrong-verifier',
redirect_uri: 'https://example.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
});
it('should require code_verifier when PKCE was used in authorization', async () => {
const { code } = await createAuthCodeWithPkce(
testRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_request');
expect(res.body.error_description).toContain('code_verifier is required');
});
});
describe('OAuth auto-install', () => {
const createAutoInstallAuthCode = async (): Promise<string> => {
const code = crypto.randomBytes(42).toString('hex');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const tokenId = await insertAppToken(ds, {
value: hashedCode,
type: AppTokenType.AuthorizationCode,
userId: TEST_USER_ID,
workspaceId: TEST_WORKSPACE_ID,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
context: {
redirectUri: 'https://example.com/callback',
clientId: autoInstallRegistration.oAuthClientId,
},
});
createdEntityIds.tokens.push(tokenId);
return code;
};
it('should auto-install application during authorization code exchange', async () => {
const code = await createAutoInstallAuthCode();
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: autoInstallRegistration.oAuthClientId,
client_secret: autoInstallClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.refresh_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(res.body.scope).toBe('api');
const rows = await ds.query(
`SELECT id, name, description, "sourcePath", "universalIdentifier"
FROM core."application"
WHERE "applicationRegistrationId" = $1
AND "workspaceId" = $2`,
[autoInstallRegistration.id, TEST_WORKSPACE_ID],
);
expect(rows).toHaveLength(1);
const autoCreatedApp = rows[0];
expect(autoCreatedApp.name).toBe('OAuth Auto-Install Test App');
expect(autoCreatedApp.sourcePath).toBe('oauth-install');
expect(autoCreatedApp.universalIdentifier).toBe(
autoInstallRegistration.universalIdentifier,
);
createdEntityIds.applications.push(autoCreatedApp.id);
});
it('should reuse existing application on subsequent authorization code exchanges', async () => {
const code = await createAutoInstallAuthCode();
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: autoInstallRegistration.oAuthClientId,
client_secret: autoInstallClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
expect(res.body.access_token).toBeDefined();
const rows = await ds.query(
`SELECT id FROM core."application"
WHERE "applicationRegistrationId" = $1
AND "workspaceId" = $2`,
[autoInstallRegistration.id, TEST_WORKSPACE_ID],
);
expect(rows).toHaveLength(1);
});
it('should fail client credentials when app is not installed in any workspace', async () => {
const noInstallSecret = crypto.randomBytes(32).toString('hex');
const noInstallHash = await bcrypt.hash(noInstallSecret, 10);
const noInstallRegistration = await insertRegistration(ds, {
name: 'No Install Test App',
clientSecretHash: noInstallHash,
redirectUris: [],
scopes: ['api'],
});
createdEntityIds.registrations.push(noInstallRegistration.id);
const res = await postToken({
grant_type: 'client_credentials',
client_id: noInstallRegistration.oAuthClientId,
client_secret: noInstallSecret,
}).expect(400);
expect(res.body.error).toBe('server_error');
expect(res.body.error_description).toContain(
'No workspace installation found',
);
});
});
describe('Refresh token grant', () => {
const createRefreshTokenAuthCode = async (
clientId: string,
): Promise<string> => {
const code = crypto.randomBytes(42).toString('hex');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const tokenId = await insertAppToken(ds, {
value: hashedCode,
type: AppTokenType.AuthorizationCode,
userId: TEST_USER_ID,
workspaceId: TEST_WORKSPACE_ID,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
context: {
redirectUri: 'https://example.com/callback',
clientId,
},
});
createdEntityIds.tokens.push(tokenId);
return code;
};
it('should issue new tokens from a valid refresh token', async () => {
const code = await createRefreshTokenAuthCode(
testRegistration.oAuthClientId,
);
const authCodeRes = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
const refreshToken = authCodeRes.body.refresh_token;
expect(refreshToken).toBeDefined();
const res = await postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.refresh_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(res.body.expires_in).toBeGreaterThan(0);
});
it('should reject refresh token presented by a different client', async () => {
const code = await createRefreshTokenAuthCode(
testRegistration.oAuthClientId,
);
const authCodeRes = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
const refreshToken = authCodeRes.body.refresh_token;
const res = await postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: autoInstallRegistration.oAuthClientId,
client_secret: autoInstallClientSecret,
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
expect(res.body.error_description).toContain('not issued to this client');
});
it('should reject an invalid refresh token', async () => {
const res = await postToken({
grant_type: 'refresh_token',
refresh_token: 'invalid-refresh-token',
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
});
});
describe('Authorization code replay detection', () => {
it('should return specific error when a used code is replayed', async () => {
const code = crypto.randomBytes(42).toString('hex');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const tokenId = await insertAppToken(ds, {
value: hashedCode,
type: AppTokenType.AuthorizationCode,
userId: TEST_USER_ID,
workspaceId: TEST_WORKSPACE_ID,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
context: {
redirectUri: 'https://example.com/callback',
clientId: testRegistration.oAuthClientId,
},
});
createdEntityIds.tokens.push(tokenId);
// First use succeeds
await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
// Second use detects replay
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
expect(res.body.error_description).toContain('already been used');
});
});
describe('Token revocation endpoint', () => {
it('should return 200 for valid token revocation', async () => {
const code = crypto.randomBytes(42).toString('hex');
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
const tokenId = await insertAppToken(ds, {
value: hashedCode,
type: AppTokenType.AuthorizationCode,
userId: TEST_USER_ID,
workspaceId: TEST_WORKSPACE_ID,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
context: {
redirectUri: 'https://example.com/callback',
clientId: testRegistration.oAuthClientId,
},
});
createdEntityIds.tokens.push(tokenId);
const tokenRes = await postToken({
grant_type: 'authorization_code',
code,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
redirect_uri: 'https://example.com/callback',
}).expect(200);
await request(baseUrl)
.post('/oauth/revoke')
.send({
token: tokenRes.body.refresh_token,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
})
.expect(200);
});
it('should return 200 for invalid token (per RFC 7009)', async () => {
await request(baseUrl)
.post('/oauth/revoke')
.send({
token: 'completely-invalid-token',
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
})
.expect(200);
});
});
describe('Token introspection endpoint', () => {
it('should return active=true for a valid access token', async () => {
const res = await postToken({
grant_type: 'client_credentials',
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(200);
const introspectRes = await request(baseUrl)
.post('/oauth/introspect')
.send({
token: res.body.access_token,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
})
.expect(200);
expect(introspectRes.body.active).toBe(true);
expect(introspectRes.body.client_id).toBe(testRegistration.oAuthClientId);
expect(introspectRes.body.token_type).toBe('Bearer');
});
it('should return active=false for an invalid token', async () => {
const res = await request(baseUrl)
.post('/oauth/introspect')
.send({
token: 'invalid-token',
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
})
.expect(200);
expect(res.body.active).toBe(false);
});
it('should require client_id', async () => {
await request(baseUrl)
.post('/oauth/introspect')
.send({ token: 'some-token' })
.expect(401);
});
});
});