feat: Linear OAuth 2.0 (#10851)

Fixes https://linear.app/chatwoot/issue/CW-3417/oauth-20-authentication
We are planning to publish the Chatwoot app in the Linear [integration
list](https://linear.app/docs/integration-directory). While we currently
use token-based authentication, Linear recommends OAuth2 authentication.
This PR implements OAuth2 support.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Muhsin Keloth
2025-02-27 18:15:53 +05:30
committed by GitHub
parent 30996140a3
commit 12134f9391
18 changed files with 432 additions and 29 deletions

View File

@@ -155,6 +155,10 @@ TWITTER_ENVIRONMENT=
SLACK_CLIENT_ID= SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET= SLACK_CLIENT_SECRET=
#Linear Integration
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# Google OAuth # Google OAuth
GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET= GOOGLE_OAUTH_CLIENT_SECRET=

View File

@@ -1,5 +1,11 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues] before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def teams def teams
teams = linear_processor_service.teams teams = linear_processor_service.teams
@@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def permitted_params def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end end
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end
end end

View File

@@ -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

View File

@@ -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

View File

@@ -297,6 +297,12 @@
"TITLE": "Unlink", "TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully", "SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again" "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"
} }
} }
}, },

View File

@@ -0,0 +1,66 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'linear');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return integration.value.action;
});
const initializeLinearIntegration = async () => {
await store.dispatch('integrations/get', 'linear');
integrationLoaded.value = true;
};
onMounted(() => {
initializeLinearIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto">
<div class="flex flex-col">
<div class="flex flex-col">
<div>
<div
v-if="integrationLoaded && !uiFlags.isCreatingLinear"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -7,7 +7,7 @@ import Webhook from './Webhooks/Index.vue';
import DashboardApps from './DashboardApps/Index.vue'; import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue'; import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue'; import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
export default { export default {
routes: [ routes: [
{ {
@@ -71,6 +71,15 @@ export default {
}, },
props: route => ({ code: route.query.code }), 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', path: ':integration_id',
name: 'settings_applications_integration', name: 'settings_applications_integration',

View File

@@ -1,4 +1,5 @@
class Integrations::App class Integrations::App
include Linear::IntegrationHelper
attr_accessor :params attr_accessor :params
def initialize(params) def initialize(params)
@@ -25,10 +26,18 @@ class Integrations::App
params[:fields] params[:fields]
end 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 def action
case params[:id] case params[:id]
when 'slack' when 'slack'
"#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}" "#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
else else
params[:action] params[:action]
end end
@@ -45,6 +54,17 @@ class Integrations::App
end end
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) def enabled?(account)
case params[:id] case params[:id]
when 'webhook' when 'webhook'
@@ -64,6 +84,10 @@ class Integrations::App
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack" "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack"
end end
def self.linear_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end
class << self class << self
def apps def apps
Hashie::Mash.new(APPS_CONFIG) Hashie::Mash.new(APPS_CONFIG)

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/app', formats: [:json], resource: @hook.app

View File

@@ -59,26 +59,9 @@ linear:
id: linear id: linear
logo: linear.png logo: linear.png
i18n_key: linear i18n_key: linear
action: /linear action: https://linear.app/oauth/authorize
hook_type: account hook_type: account
allow_multiple_hooks: false 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: slack:
id: slack id: slack
logo: slack.png logo: slack.png

View File

@@ -233,6 +233,7 @@ Rails.application.routes.draw do
end end
resource :linear, controller: 'linear', only: [] do resource :linear, controller: 'linear', only: [] do
collection do collection do
delete :destroy
get :teams get :teams
get :team_entities get :team_entities
post :create_issue post :create_issue
@@ -444,6 +445,10 @@ Rails.application.routes.draw do
resource :callback, only: [:show] resource :callback, only: [:show]
end end
namespace :linear do
resource :callback, only: [:show]
end
namespace :twilio do namespace :twilio do
resources :callback, only: [:create] resources :callback, only: [:create]
resources :delivery_status, only: [:create] resources :delivery_status, only: [:create]

View File

@@ -76,7 +76,6 @@ class Integrations::Linear::ProcessorService
end end
def linear_client def linear_client
credentials = linear_hook.settings @linear_client ||= Linear.new(linear_hook.access_token)
@linear_client ||= Linear.new(credentials['api_key'])
end end
end end

View File

@@ -2,9 +2,9 @@ class Linear
BASE_URL = 'https://api.linear.app/graphql'.freeze BASE_URL = 'https://api.linear.app/graphql'.freeze
PRIORITY_LEVELS = (0..4).to_a PRIORITY_LEVELS = (0..4).to_a
def initialize(api_key) def initialize(access_token)
@api_key = api_key @access_token = access_token
raise ArgumentError, 'Missing Credentials' if api_key.blank? raise ArgumentError, 'Missing Credentials' if access_token.blank?
end end
def teams def teams
@@ -108,7 +108,7 @@ class Linear
def post(payload) def post(payload)
HTTParty.post( HTTParty.post(
BASE_URL, BASE_URL,
headers: { 'Authorization' => @api_key, 'Content-Type' => 'application/json' }, headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' },
body: payload.to_json body: payload.to_json
) )
end end

View File

@@ -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) allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor_service)
end 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 describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
context 'when data is retrieved successfully' do context 'when data is retrieved successfully' do

View File

@@ -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

View File

@@ -29,7 +29,7 @@ FactoryBot.define do
trait :linear do trait :linear do
app_id { 'linear' } app_id { 'linear' }
settings { { api_key: 'api_key' } } access_token { SecureRandom.hex }
end end
end end
end end

View File

@@ -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

View File

@@ -1,10 +1,10 @@
require 'rails_helper' require 'rails_helper'
describe Linear do describe Linear do
let(:api_key) { 'valid_api_key' } let(:access_token) { 'valid_access_token' }
let(:url) { 'https://api.linear.app/graphql' } let(:url) { 'https://api.linear.app/graphql' }
let(:linear_client) { described_class.new(api_key) } let(:linear_client) { described_class.new(access_token) }
let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => api_key } } let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{access_token}" } }
it 'raises an exception if the API key is absent' do it 'raises an exception if the API key is absent' do
expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials') expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials')