From a10e9f6c2d89598cade57d01d6cba6bc431d2a25 Mon Sep 17 00:00:00 2001 From: Muhsin Date: Tue, 7 Oct 2025 12:51:01 +0530 Subject: [PATCH] feat: add support for long lived tokens --- .../integrations/linear_controller.rb | 2 +- .../linear/callbacks_controller.rb | 13 +- lib/integrations/linear/processor_service.rb | 2 +- lib/linear.rb | 15 ++- lib/linear/token_refresh_service.rb | 121 ++++++++++++++++++ 5 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 lib/linear/token_refresh_service.rb diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index eb6525bb1..b8ba6e57c 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -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}" diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb index 2eea49333..955fbb9a7 100644 --- a/app/controllers/linear/callbacks_controller.rb +++ b/app/controllers/linear/callbacks_controller.rb @@ -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 diff --git a/lib/integrations/linear/processor_service.rb b/lib/integrations/linear/processor_service.rb index a3447b79e..36f5cee92 100644 --- a/lib/integrations/linear/processor_service.rb +++ b/lib/integrations/linear/processor_service.rb @@ -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 diff --git a/lib/linear.rb b/lib/linear.rb index 2ffcf8cca..d230c6bac 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -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? diff --git a/lib/linear/token_refresh_service.rb b/lib/linear/token_refresh_service.rb new file mode 100644 index 000000000..511c8869c --- /dev/null +++ b/lib/linear/token_refresh_service.rb @@ -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