Files
upstream-mirror e7da97194a
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

1250 lines
38 KiB
TypeScript

import crypto from 'crypto';
import bcrypt from 'bcrypt';
import gql from 'graphql-tag';
import request from 'supertest';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { assertIsDefinedOrThrow, base64UrlEncode } from 'twenty-shared/utils';
import { type DataSource } from 'typeorm';
import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const TEST_USER_ID = USER_DATA_SEED_IDS.JANE;
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 | null;
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 publicRegistration: TestRegistration;
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,
});
publicRegistration = await insertRegistration(ds, {
name: 'OAuth Public PKCE Test App',
clientSecretHash: null,
redirectUris: ['https://example.com/callback'],
scopes: ['read', 'write'],
});
createdEntityIds.registrations.push(publicRegistration.id);
const publicApplication = await insertApplication(ds, {
universalIdentifier: publicRegistration.universalIdentifier,
name: publicRegistration.name,
workspaceId: TEST_WORKSPACE_ID,
applicationRegistrationId: publicRegistration.id,
});
createdEntityIds.applications.push(publicApplication.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');
});
it('should promise `iss` when it serves its own authorization endpoint', async () => {
const res = await request(baseUrl)
.get('/.well-known/oauth-authorization-server')
.expect(200);
expect(res.body.authorization_endpoint).toBe(
`${res.body.issuer}/authorize`,
);
expect(res.body.authorization_response_iss_parameter_supported).toBe(
true,
);
});
});
describe('Authorization endpoint (authorizeApp)', () => {
const authorizeAppMutation = gql`
mutation AuthorizeApp(
$clientId: String!
$redirectUrl: String!
$state: String
) {
authorizeApp(
clientId: $clientId
redirectUrl: $redirectUrl
state: $state
) {
redirectUrl
}
}
`;
it('should return an `iss` matching the advertised issuer alongside code and state', async () => {
const state = 'integration-state-123';
const response = await makeMetadataAPIRequest({
query: authorizeAppMutation,
variables: {
clientId: testRegistration.oAuthClientId,
redirectUrl: testRegistration.oAuthRedirectUris[0],
state,
},
});
expect(response.body.errors).toBeUndefined();
const callbackUrl = new URL(response.body.data.authorizeApp.redirectUrl);
const code = callbackUrl.searchParams.get('code');
const metadata = await request(baseUrl)
.get('/.well-known/oauth-authorization-server')
.expect(200);
expect(`${callbackUrl.origin}${callbackUrl.pathname}`).toBe(
testRegistration.oAuthRedirectUris[0],
);
expect(code).toMatch(/^[0-9a-f]+$/);
expect(callbackUrl.searchParams.get('iss')).toBe(metadata.body.issuer);
expect(callbackUrl.searchParams.get('state')).toBe(state);
assertIsDefinedOrThrow(code);
const [issuedToken] = await ds.query(
`SELECT id FROM core."appToken" WHERE value = $1`,
[crypto.createHash('sha256').update(code).digest('hex')],
);
createdEntityIds.tokens.push(issuedToken.id);
});
});
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(
publicRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: publicRegistration.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(
publicRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: publicRegistration.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(
publicRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: publicRegistration.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(
publicRegistration.oAuthClientId,
);
const res = await postToken({
grant_type: 'authorization_code',
code,
client_id: publicRegistration.oAuthClientId,
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');
});
it('should reject a confidential client that presents only PKCE and no client_secret', 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(401);
expect(res.body.error).toBe('invalid_client');
});
});
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);
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');
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);
});
});
describe('Per-user authorizations', () => {
const exchangeForTokens = async (
scope = 'read write',
): Promise<{
accessToken: string;
refreshToken: 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: testRegistration.oAuthClientId,
scope,
},
});
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(200);
return {
accessToken: res.body.access_token,
refreshToken: res.body.refresh_token,
};
};
const findAuthorization = async () => {
const [authorization] = await ds.query(
`SELECT "scopes", "lastAuthorizedAt", "revokedAt"
FROM core."applicationAuthorization"
WHERE "userId" = $1 AND "applicationId" = $2`,
[TEST_USER_ID, testApplication.id],
);
return authorization;
};
const deleteAuthorization = () =>
ds.query(
`DELETE FROM core."applicationAuthorization"
WHERE "userId" = $1 AND "applicationId" = $2`,
[TEST_USER_ID, testApplication.id],
);
const revokeRefreshToken = (refreshToken: string) =>
request(baseUrl)
.post('/oauth/revoke')
.send({
token: refreshToken,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
})
.expect(200);
// 'read' alone, not the registration's full scope list, so the assertion
// fails if the granted scope is ignored in favour of the declared one.
it('should record the authorization with the scopes the user granted', async () => {
await exchangeForTokens('read');
const authorization = await findAuthorization();
expect(authorization).toBeDefined();
expect(authorization.scopes).toEqual(['read']);
expect(authorization.revokedAt).toBeNull();
});
it('should stop the refresh token being redeemed once the authorization is revoked', async () => {
const { refreshToken } = await exchangeForTokens();
await revokeRefreshToken(refreshToken);
expect((await findAuthorization()).revokedAt).not.toBeNull();
const res = await postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
});
it('should let the user authorize again after revoking', async () => {
const { refreshToken: revokedRefreshToken } = await exchangeForTokens();
await revokeRefreshToken(revokedRefreshToken);
const { refreshToken } = await exchangeForTokens();
expect((await findAuthorization()).revokedAt).toBeNull();
await postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(200);
});
// Deleting the row leaves a refresh token in the state every token minted
// before this table existed is in.
it('should backfill a refresh token that predates the authorization record without inventing a consent', async () => {
const { refreshToken } = await exchangeForTokens('read write');
await deleteAuthorization();
await postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(200);
const authorization = await findAuthorization();
expect(authorization).toBeDefined();
expect(authorization.scopes).toBeNull();
expect(authorization.lastAuthorizedAt).toBeNull();
expect(authorization.revokedAt).toBeNull();
});
it('should keep a refresh token predating the authorization record revoked', async () => {
const { refreshToken } = await exchangeForTokens();
await deleteAuthorization();
await revokeRefreshToken(refreshToken);
const res = await postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
});
const LIST_AUTHORIZATIONS_OPERATION = {
query: gql`
query CurrentUserApplicationAuthorizations {
currentUserApplicationAuthorizations {
id
applicationId
applicationName
scopes
}
}
`,
};
const revokeAuthorizationOperation = (
applicationAuthorizationId: string,
) => ({
query: gql`
mutation RevokeApplicationAuthorization(
$applicationAuthorizationId: UUID!
) {
revokeApplicationAuthorization(
applicationAuthorizationId: $applicationAuthorizationId
)
}
`,
variables: { applicationAuthorizationId },
});
const findListedAuthorization = async (
token = APPLE_JANE_ADMIN_ACCESS_TOKEN,
) => {
const res = await makeMetadataAPIRequest(
LIST_AUTHORIZATIONS_OPERATION,
token,
);
expect(res.body.errors).toBeUndefined();
return res.body.data.currentUserApplicationAuthorizations.find(
(authorization: { applicationId: string }) =>
authorization.applicationId === testApplication.id,
);
};
const revokeListedAuthorization = (
applicationAuthorizationId: string,
token: string,
) =>
makeMetadataAPIRequest(
revokeAuthorizationOperation(applicationAuthorizationId),
token,
);
it('should list the authorization to the user who granted it', async () => {
await exchangeForTokens('read');
const authorization = await findListedAuthorization();
expect(authorization).toBeDefined();
expect(authorization.applicationName).toBe(testRegistration.name);
expect(authorization.scopes).toEqual(['read']);
});
it('should stop the refresh token being redeemed when revoked from the list', async () => {
const { refreshToken } = await exchangeForTokens();
const { id } = await findListedAuthorization();
const revokeResponse = await revokeListedAuthorization(
id,
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(revokeResponse.body.errors).toBeUndefined();
expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe(
true,
);
const res = await postToken({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: testRegistration.oAuthClientId,
client_secret: testClientSecret,
}).expect(400);
expect(res.body.error).toBe('invalid_grant');
expect(await findListedAuthorization()).toBeUndefined();
});
it('should report a repeated revocation as a no-op', async () => {
await exchangeForTokens();
const { id } = await findListedAuthorization();
const firstRevoke = await revokeListedAuthorization(
id,
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(firstRevoke.body.data.revokeApplicationAuthorization).toBe(true);
const secondRevoke = await revokeListedAuthorization(
id,
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(secondRevoke.body.data.revokeApplicationAuthorization).toBe(false);
});
it('should not let another user see or revoke the authorization', async () => {
await exchangeForTokens();
const { id } = await findListedAuthorization();
expect(
await findListedAuthorization(APPLE_JONY_MEMBER_ACCESS_TOKEN),
).toBeUndefined();
const revokeResponse = await revokeListedAuthorization(
id,
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe(
false,
);
expect((await findAuthorization()).revokedAt).toBeNull();
});
});
});