Class: CollavreLinear::OAuthTokenService

Inherits:
Object
  • Object
show all
Defined in:
app/services/collavre_linear/o_auth_token_service.rb

Overview

Handles Linear OAuth 2.0 flows:

- authorize_url  : build the URL that sends the user to Linear's OAuth screen
- exchange       : trade an authorization code for tokens (authorization_code grant)
- refresh        : silently refresh tokens when they are expiring soon (refresh_token grant)

Secret resolution order: DB Resolver > ENV > Rails.application.credentials (consistent with CollavreLinear::Client and other engine services in this codebase)

Defined Under Namespace

Classes: Error

Constant Summary collapse

LINEAR_AUTH_ENDPOINT =
"https://linear.app/oauth/authorize"
LINEAR_TOKEN_ENDPOINT =
"https://api.linear.app/oauth/token"
OAUTH_SCOPES =

NOTE: admin is intentionally NOT requested. Linear rejects it for app actors ("App users can't request admin scopes"), which is how we authorize (actor: "app"). admin is only needed to auto-create webhooks via the API, so inbound sync is wired up MANUALLY instead — the integration modal shows a one-time setup guide (URL + events) after linking, and the admin pastes back the signing secret Linear generates for the webhook.

"read,write,issues:create,comments:create"

Class Method Summary collapse

Class Method Details

.authorize_url(state:, creative_id:) ⇒ String

Build the URL that redirects the user to Linear for authorization.

Parameters:

  • state (String)

    CSRF token / opaque value passed through the OAuth flow

  • creative_id (Integer)

    stored in session before redirect; not sent to Linear

Returns:

  • (String)

    full URL including query params



48
49
50
51
52
53
54
55
56
57
58
# File 'app/services/collavre_linear/o_auth_token_service.rb', line 48

def authorize_url(state:, creative_id:)
  params = {
    response_type: "code",
    client_id:     client_id,
    redirect_uri:  redirect_uri,
    scope:         OAUTH_SCOPES,
    actor:         "app",
    state:         state
  }
  "#{LINEAR_AUTH_ENDPOINT}?#{URI.encode_www_form(params)}"
end

.exchange(code) ⇒ Hash

Exchange an authorization code for access + refresh tokens.

Parameters:

  • code (String)

    authorization code from Linear callback

Returns:

  • (Hash)

    with symbolized keys :access_token, :refresh_token, :expires_in



64
65
66
67
68
69
70
71
72
73
# File 'app/services/collavre_linear/o_auth_token_service.rb', line 64

def exchange(code)
  params = {
    grant_type:    "authorization_code",
    code:          code,
    redirect_uri:  redirect_uri,
    client_id:     client_id,
    client_secret: client_secret
  }
  post_token!(params)
end

.missing_configObject

Names of any OAuth secrets that are unset/blank. When non-empty we must NOT start the flow: a blank redirect_uri produces an authorize URL like ...&redirect_uri&scope=... that Linear accepts but can never redirect back from, so the callback never fires and the account is never created (Linear just shows the app as "already installed"). client_secret is checked too: it isn't in the authorize URL, but a blank one would let the user complete Linear authorization only to fail at token exchange in the callback — surface the misconfiguration before leaving the app instead.



37
38
39
40
41
# File 'app/services/collavre_linear/o_auth_token_service.rb', line 37

def missing_config
  { LINEAR_CLIENT_ID: client_id,
    LINEAR_CLIENT_SECRET: client_secret,
    LINEAR_OAUTH_REDIRECT_URI: redirect_uri }.select { |_, v| v.blank? }.keys
end

.refresh(account) ⇒ CollavreLinear::Account

Refresh tokens if the account is expiring soon. Persists new token values to the account and returns it. No-ops (returns account unchanged) if not expiring soon.

Parameters:

Returns:



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'app/services/collavre_linear/o_auth_token_service.rb', line 81

def refresh()
  return  unless .token_expiring_soon?

  params = {
    grant_type:    "refresh_token",
    refresh_token: .refresh_token,
    client_id:     client_id,
    client_secret: client_secret
  }

  tokens = post_token!(params)

  attrs = {
    access_token:     tokens[:access_token],
    token_expires_at: Time.current + tokens[:expires_in].to_i.seconds
  }
  attrs[:refresh_token] = tokens[:refresh_token] if tokens[:refresh_token].present?
  .update!(attrs)
  
end