mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 02:32:29 +00:00
feat: add support for long lived tokens
This commit is contained in:
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
return unless @hook&.access_token
|
||||
|
||||
begin
|
||||
linear_client = Linear.new(@hook.access_token)
|
||||
linear_client = Linear.new(@hook)
|
||||
linear_client.revoke_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
||||
|
||||
@@ -38,10 +38,11 @@ class Linear::CallbacksController < ApplicationController
|
||||
settings: {
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
scope: parsed_body['scope'],
|
||||
refresh_token: parsed_body['refresh_token'],
|
||||
expires_at: calculate_expires_at(parsed_body['expires_in'])
|
||||
}.compact
|
||||
)
|
||||
# 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
|
||||
@@ -70,4 +71,10 @@ class Linear::CallbacksController < ApplicationController
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
def calculate_expires_at(expires_in)
|
||||
return nil unless expires_in
|
||||
|
||||
(Time.current + expires_in.to_i.seconds).iso8601
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,6 +77,6 @@ class Integrations::Linear::ProcessorService
|
||||
end
|
||||
|
||||
def linear_client
|
||||
@linear_client ||= Linear.new(linear_hook.access_token)
|
||||
@linear_client ||= Linear.new(linear_hook)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,9 +3,10 @@ class Linear
|
||||
REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze
|
||||
PRIORITY_LEVELS = (0..4).to_a
|
||||
|
||||
def initialize(access_token)
|
||||
@access_token = access_token
|
||||
raise ArgumentError, 'Missing Credentials' if access_token.blank?
|
||||
def initialize(hook)
|
||||
@hook = hook
|
||||
@token_refresh_service = Linear::TokenRefreshService.new(hook)
|
||||
raise ArgumentError, 'Missing hook or access token' if hook.blank? || hook.access_token.blank?
|
||||
end
|
||||
|
||||
def teams
|
||||
@@ -81,7 +82,7 @@ class Linear
|
||||
def revoke_token
|
||||
response = HTTParty.post(
|
||||
REVOKE_URL,
|
||||
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
|
||||
headers: { 'Authorization' => "Bearer #{@hook.access_token}", 'Content-Type' => 'application/json' }
|
||||
)
|
||||
response.success?
|
||||
end
|
||||
@@ -145,11 +146,15 @@ class Linear
|
||||
def post(payload)
|
||||
HTTParty.post(
|
||||
BASE_URL,
|
||||
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' },
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: payload.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def access_token
|
||||
@token_refresh_service&.token || @hook.access_token
|
||||
end
|
||||
|
||||
def process_response(response)
|
||||
return response.parsed_response['data'].with_indifferent_access if response.success? && !response.parsed_response['data'].nil?
|
||||
|
||||
|
||||
121
lib/linear/token_refresh_service.rb
Normal file
121
lib/linear/token_refresh_service.rb
Normal file
@@ -0,0 +1,121 @@
|
||||
# Manages OAuth2 token lifecycle for Linear integration
|
||||
# Handles automatic token refresh and migration from long-lived to short-lived tokens
|
||||
class Linear::TokenRefreshService
|
||||
TOKEN_URL = 'https://api.linear.app/oauth/token'.freeze
|
||||
MIGRATE_TOKEN_URL = 'https://api.linear.app/oauth/migrate_old_token'.freeze
|
||||
|
||||
def initialize(hook)
|
||||
@hook = hook
|
||||
end
|
||||
|
||||
# Returns a valid access token, handling refresh/migration automatically
|
||||
# This is the main entry point - call this whenever you need a valid token
|
||||
# @return [String] Valid OAuth access token
|
||||
def token
|
||||
return @hook.access_token unless @hook
|
||||
|
||||
# For existing accounts without refresh token, attempt migration first
|
||||
# This migrates long-lived tokens to the new refresh token system, https://linear.app/developers/oauth-2-0-authentication#migrate-to-using-refresh-tokens
|
||||
migrate_old_token unless refresh_token?
|
||||
|
||||
refresh_access_token if token_eligible_for_refresh?
|
||||
|
||||
@hook.access_token
|
||||
end
|
||||
|
||||
def refresh_access_token
|
||||
return false unless @hook&.settings&.dig('refresh_token')
|
||||
|
||||
response = HTTParty.post(
|
||||
TOKEN_URL,
|
||||
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: @hook.settings['refresh_token'],
|
||||
client_id: GlobalConfigService.load('LINEAR_CLIENT_ID', nil),
|
||||
client_secret: GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
|
||||
}
|
||||
)
|
||||
|
||||
if response.success?
|
||||
update_tokens(response.parsed_response)
|
||||
true
|
||||
else
|
||||
Rails.logger.error("Linear token refresh failed: #{response.parsed_response}")
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def migrate_old_token
|
||||
return false unless @hook
|
||||
|
||||
response = HTTParty.post(
|
||||
MIGRATE_TOKEN_URL,
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{@hook.access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
)
|
||||
|
||||
if response.success?
|
||||
update_tokens(response.parsed_response)
|
||||
true
|
||||
else
|
||||
Rails.logger.error("Linear token migration failed: #{response.parsed_response}")
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def token_expired?
|
||||
return false unless @hook&.settings&.dig('expires_at')
|
||||
|
||||
Time.zone.parse(@hook.settings['expires_at']) <= Time.current
|
||||
end
|
||||
|
||||
def token_eligible_for_refresh?
|
||||
return false unless @hook&.settings&.dig('expires_at')
|
||||
return false unless @hook&.settings&.dig('refresh_token')
|
||||
|
||||
expires_at = Time.zone.parse(@hook.settings['expires_at'])
|
||||
|
||||
# Three conditions must be met for proactive refresh:
|
||||
# 1. Token is still valid (not expired yet)
|
||||
token_is_valid = Time.current < expires_at
|
||||
|
||||
# 2. Token is at least 24 hours old (prevents excessive refresh attempts)
|
||||
token_is_old_enough = @hook.updated_at.present? && Time.current - @hook.updated_at >= 24.hours
|
||||
|
||||
# 3. Token is approaching expiry (within 10 days)
|
||||
# This gives enough buffer to handle refresh failures
|
||||
approaching_expiry = expires_at < 10.days.from_now
|
||||
|
||||
token_is_valid && token_is_old_enough && approaching_expiry
|
||||
end
|
||||
|
||||
def refresh_token?
|
||||
@hook&.settings&.dig('refresh_token').present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_tokens(response_data)
|
||||
return unless @hook
|
||||
|
||||
@hook.update!(
|
||||
access_token: response_data['access_token'],
|
||||
settings: @hook.settings.merge(
|
||||
token_type: response_data['token_type'],
|
||||
expires_in: response_data['expires_in'],
|
||||
scope: response_data['scope'],
|
||||
refresh_token: response_data['refresh_token'] || @hook.settings['refresh_token'],
|
||||
expires_at: calculate_expires_at(response_data['expires_in'])
|
||||
).compact
|
||||
)
|
||||
end
|
||||
|
||||
def calculate_expires_at(expires_in)
|
||||
return nil unless expires_in
|
||||
|
||||
(Time.current + expires_in.to_i.seconds).iso8601
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user