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
1250 lines
38 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|