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 => { 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 => { 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; }, ): Promise => { 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) => 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 => { 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 => { 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 => { 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(); }); }); });