diff --git a/.env.example b/.env.example index b7ba0920d..6e2b7fe56 100644 --- a/.env.example +++ b/.env.example @@ -155,6 +155,10 @@ TWITTER_ENVIRONMENT= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +#Linear Integration +LINEAR_CLIENT_ID= +LINEAR_CLIENT_SECRET= + # Google OAuth GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 814373c7e..4e5348e88 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,11 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end def teams teams = linear_processor_service.teams @@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def permitted_params params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) end + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') + end end diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb new file mode 100644 index 000000000..c0688cefc --- /dev/null +++ b/app/controllers/linear/callbacks_controller.rb @@ -0,0 +1,70 @@ +class Linear::CallbacksController < ApplicationController + include Linear::IntegrationHelper + + def show + @response = oauth_client.auth_code.get_token( + params[:code], + redirect_uri: "#{base_url}/linear/callback" + ) + + handle_response + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + private + + def oauth_client + OAuth2::Client.new( + ENV.fetch('LINEAR_CLIENT_ID', nil), + ENV.fetch('LINEAR_CLIENT_SECRET', nil), + { + site: 'https://api.linear.app', + token_url: '/oauth/token', + authorize_url: '/oauth/authorize' + } + ) + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'linear', + settings: { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + scope: parsed_body['scope'] + } + ) + # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 + hook.save! + redirect_to linear_redirect_uri + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + def account + @account ||= Account.find(account_id) + end + + def account_id + return unless params[:state] + + verify_linear_token(params[:state]) + end + + def linear_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/helpers/linear/integration_helper.rb b/app/helpers/linear/integration_helper.rb new file mode 100644 index 000000000..19f16832d --- /dev/null +++ b/app/helpers/linear/integration_helper.rb @@ -0,0 +1,47 @@ +module Linear::IntegrationHelper + # Generates a signed JWT token for Linear integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_linear_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Linear token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Linear JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_linear_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= ENV.fetch('LINEAR_CLIENT_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Linear token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 56f6e9776..58e70db8b 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -297,6 +297,12 @@ "TITLE": "Unlink", "SUCCESS": "Issue unlinked successfully", "ERROR": "There was an error unlinking the issue, please try again" + }, + "DELETE": { + "TITLE": "Are you sure you want to delete the integration?", + "MESSAGE": "Are you sure you want to delete the integration?", + "CONFIRM": "Yes, delete", + "CANCEL": "Cancel" } } }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Linear.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Linear.vue new file mode 100644 index 000000000..5cb4d2b07 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Linear.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js index a2ec06ae0..dca2a19ed 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js @@ -7,7 +7,7 @@ import Webhook from './Webhooks/Index.vue'; import DashboardApps from './DashboardApps/Index.vue'; import Slack from './Slack.vue'; import SettingsContent from '../Wrapper.vue'; - +import Linear from './Linear.vue'; export default { routes: [ { @@ -71,6 +71,15 @@ export default { }, props: route => ({ code: route.query.code }), }, + { + path: 'linear', + name: 'settings_integrations_linear', + component: Linear, + meta: { + permissions: ['administrator'], + }, + props: route => ({ code: route.query.code }), + }, { path: ':integration_id', name: 'settings_applications_integration', diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index 0afb083dc..b57a109db 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -1,4 +1,5 @@ class Integrations::App + include Linear::IntegrationHelper attr_accessor :params def initialize(params) @@ -25,10 +26,18 @@ class Integrations::App params[:fields] end + # There is no way to get the account_id from the linear callback + # so we are using the generate_linear_token method to generate a token and encode it in the state parameter + def encode_state + generate_linear_token(Current.account.id) + end + def action case params[:id] when 'slack' "#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}" + when 'linear' + build_linear_action else params[:action] end @@ -45,6 +54,17 @@ class Integrations::App end end + def build_linear_action + [ + "#{params[:action]}?response_type=code", + "client_id=#{ENV.fetch('LINEAR_CLIENT_ID', nil)}", + "redirect_uri=#{self.class.linear_integration_url}", + "state=#{encode_state}", + 'scope=read,write', + 'prompt=consent' + ].join('&') + end + def enabled?(account) case params[:id] when 'webhook' @@ -64,6 +84,10 @@ class Integrations::App "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack" end + def self.linear_integration_url + "#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback" + end + class << self def apps Hashie::Mash.new(APPS_CONFIG) diff --git a/app/views/api/v1/accounts/integrations/linear/create.json.jbuilder b/app/views/api/v1/accounts/integrations/linear/create.json.jbuilder new file mode 100644 index 000000000..c208c81db --- /dev/null +++ b/app/views/api/v1/accounts/integrations/linear/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/app', formats: [:json], resource: @hook.app diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 4fc65af11..b625e83b5 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -59,26 +59,9 @@ linear: id: linear logo: linear.png i18n_key: linear - action: /linear + action: https://linear.app/oauth/authorize hook_type: account allow_multiple_hooks: false - settings_json_schema: - { - 'type': 'object', - 'properties': { 'api_key': { 'type': 'string' } }, - 'required': ['api_key'], - 'additionalProperties': false, - } - settings_form_schema: - [ - { - 'label': 'API Key', - 'type': 'text', - 'name': 'api_key', - 'validation': 'required', - }, - ] - visible_properties: [] slack: id: slack logo: slack.png diff --git a/config/routes.rb b/config/routes.rb index 712aa150d..5340ec9cd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -233,6 +233,7 @@ Rails.application.routes.draw do end resource :linear, controller: 'linear', only: [] do collection do + delete :destroy get :teams get :team_entities post :create_issue @@ -444,6 +445,10 @@ Rails.application.routes.draw do resource :callback, only: [:show] end + namespace :linear do + resource :callback, only: [:show] + end + namespace :twilio do resources :callback, only: [:create] resources :delivery_status, only: [:create] diff --git a/lib/integrations/linear/processor_service.rb b/lib/integrations/linear/processor_service.rb index 20b04a301..2dfae28dc 100644 --- a/lib/integrations/linear/processor_service.rb +++ b/lib/integrations/linear/processor_service.rb @@ -76,7 +76,6 @@ class Integrations::Linear::ProcessorService end def linear_client - credentials = linear_hook.settings - @linear_client ||= Linear.new(credentials['api_key']) + @linear_client ||= Linear.new(linear_hook.access_token) end end diff --git a/lib/linear.rb b/lib/linear.rb index 2e801c673..9a998c34a 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -2,9 +2,9 @@ class Linear BASE_URL = 'https://api.linear.app/graphql'.freeze PRIORITY_LEVELS = (0..4).to_a - def initialize(api_key) - @api_key = api_key - raise ArgumentError, 'Missing Credentials' if api_key.blank? + def initialize(access_token) + @access_token = access_token + raise ArgumentError, 'Missing Credentials' if access_token.blank? end def teams @@ -108,7 +108,7 @@ class Linear def post(payload) HTTParty.post( BASE_URL, - headers: { 'Authorization' => @api_key, 'Content-Type' => 'application/json' }, + headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }, body: payload.to_json ) end diff --git a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb index 8b5de48dd..b1341e65e 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -12,6 +12,16 @@ RSpec.describe 'Linear Integration API', type: :request do allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor_service) end + describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do + it 'deletes the linear integration' do + delete "/api/v1/accounts/#{account.id}/integrations/linear", + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:ok) + expect(account.hooks.count).to eq(0) + end + end + describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do context 'when it is an authenticated user' do context 'when data is retrieved successfully' do diff --git a/spec/controllers/linear/callbacks_controller_spec.rb b/spec/controllers/linear/callbacks_controller_spec.rb new file mode 100644 index 000000000..b3245fbc5 --- /dev/null +++ b/spec/controllers/linear/callbacks_controller_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe Linear::CallbacksController, type: :request do + let(:account) { create(:account) } + let(:code) { SecureRandom.hex(10) } + let(:state) { SecureRandom.hex(10) } + let(:linear_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', '')}/app/accounts/#{account.id}/settings/integrations/linear" } + + describe 'GET /linear/callback' do + let(:access_token) { SecureRandom.hex(10) } + let(:response_body) do + { + 'access_token' => access_token, + 'token_type' => 'Bearer', + 'expires_in' => 7200, + 'scope' => 'read,write' + } + end + + before do + stub_const('ENV', ENV.to_hash.merge('FRONTEND_URL' => 'http://www.example.com')) + + controller = described_class.new + allow(controller).to receive(:verify_linear_token).with(state).and_return(account.id) + allow(described_class).to receive(:new).and_return(controller) + end + + context 'when successful' do + before do + stub_request(:post, 'https://api.linear.app/oauth/token') + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'creates a new integration hook' do + expect do + get linear_callback_path, params: { code: code, state: state } + end.to change(Integrations::Hook, :count).by(1) + + hook = Integrations::Hook.last + expect(hook.access_token).to eq(access_token) + expect(hook.app_id).to eq('linear') + expect(hook.status).to eq('enabled') + expect(hook.settings).to eq( + 'token_type' => 'Bearer', + 'expires_in' => 7200, + 'scope' => 'read,write' + ) + expect(response).to redirect_to(linear_redirect_uri) + end + end + + context 'when the code is missing' do + before do + stub_request(:post, 'https://api.linear.app/oauth/token') + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'redirects to the linear_redirect_uri' do + get linear_callback_path, params: { state: state } + expect(response).to redirect_to(linear_redirect_uri) + end + end + + context 'when the token is invalid' do + before do + stub_request(:post, 'https://api.linear.app/oauth/token') + .to_return( + status: 400, + body: { error: 'invalid_grant' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'redirects to the linear_redirect_uri' do + get linear_callback_path, params: { code: code, state: state } + expect(response).to redirect_to(linear_redirect_uri) + end + end + end +end diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index 12958c1ac..498fb9f9a 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -29,7 +29,7 @@ FactoryBot.define do trait :linear do app_id { 'linear' } - settings { { api_key: 'api_key' } } + access_token { SecureRandom.hex } end end end diff --git a/spec/helpers/linear/integration_helper_spec.rb b/spec/helpers/linear/integration_helper_spec.rb new file mode 100644 index 000000000..4f0f65c31 --- /dev/null +++ b/spec/helpers/linear/integration_helper_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe Linear::IntegrationHelper do + include described_class + + describe '#generate_linear_token' do + let(:account_id) { 1 } + let(:client_secret) { 'test_secret' } + let(:current_time) { Time.current } + + before do + allow(ENV).to receive(:fetch).with('LINEAR_CLIENT_SECRET', nil).and_return(client_secret) + allow(Time).to receive(:current).and_return(current_time) + end + + it 'generates a valid JWT token with correct payload' do + token = generate_linear_token(account_id) + decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first + + expect(decoded_token['sub']).to eq(account_id) + expect(decoded_token['iat']).to eq(current_time.to_i) + end + + context 'when client secret is not configured' do + let(:client_secret) { nil } + + it 'returns nil' do + expect(generate_linear_token(account_id)).to be_nil + end + end + + context 'when an error occurs' do + before do + allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error')) + end + + it 'logs the error and returns nil' do + expect(Rails.logger).to receive(:error).with('Failed to generate Linear token: Test error') + expect(generate_linear_token(account_id)).to be_nil + end + end + end + + describe '#verify_linear_token' do + let(:account_id) { 1 } + let(:client_secret) { 'test_secret' } + let(:valid_token) do + JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256') + end + + before do + allow(ENV).to receive(:fetch).with('LINEAR_CLIENT_SECRET', nil).and_return(client_secret) + end + + it 'successfully verifies and returns account_id from valid token' do + expect(verify_linear_token(valid_token)).to eq(account_id) + end + + context 'when token is blank' do + it 'returns nil' do + expect(verify_linear_token('')).to be_nil + expect(verify_linear_token(nil)).to be_nil + end + end + + context 'when client secret is not configured' do + let(:client_secret) { nil } + + it 'returns nil' do + expect(verify_linear_token(valid_token)).to be_nil + end + end + + context 'when token is invalid' do + it 'logs the error and returns nil' do + expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Linear token:/) + expect(verify_linear_token('invalid_token')).to be_nil + end + end + end +end diff --git a/spec/lib/linear_spec.rb b/spec/lib/linear_spec.rb index e8a866cc5..2ebaec1a8 100644 --- a/spec/lib/linear_spec.rb +++ b/spec/lib/linear_spec.rb @@ -1,10 +1,10 @@ require 'rails_helper' describe Linear do - let(:api_key) { 'valid_api_key' } + let(:access_token) { 'valid_access_token' } let(:url) { 'https://api.linear.app/graphql' } - let(:linear_client) { described_class.new(api_key) } - let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => api_key } } + let(:linear_client) { described_class.new(access_token) } + let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{access_token}" } } it 'raises an exception if the API key is absent' do expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials')