mirror of
https://github.com/usetrmnl/trmnlp.git
synced 2026-08-13 14:27:33 -07:00
Add OAuth2 support for local plugin preview (#118)
* Add OAuth2 support for local plugin preview Preview private plugins that fetch data with a user's authorization. Configure a provider in the .trmnlp.yml oauth: block, set TRMNL_OAUTH_CLIENT_SECRET in the environment, and register http://localhost:4567/oauth/callback as the redirect URI. trmnlp serve shows a Connect banner that runs the authorization code flow in the browser, stores tokens in the cache dir, and refreshes them before they expire. The access token renders into polling templates as {{ oauth_access_token }}, matching the hosted service. * Mark OAuth2 support as beta and request bug reports Label the feature beta in the changelog, README, and the dev-server banner, and link the GitHub issue tracker so incorrect behaviour gets reported. * Read OAuth config from settings.yml so it round-trips with push and pull * Bump version to 0.9.0
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## 0.9.0
|
||||
|
||||
- Added OAuth2 support for local plugin preview (beta). Configure a provider with the flat `oauth_*` keys in `src/settings.yml` (authorize and token URLs, scopes, optional PKCE), which round-trip through `trmnlp push` and `pull`. Set `TRMNL_OAUTH_CLIENT_ID` and `TRMNL_OAUTH_CLIENT_SECRET` in your environment (credentials stay local and are never synced), and register `http://localhost:4567/oauth/callback` as the redirect URI in your OAuth app. `trmnlp serve` shows a Connect banner that runs the authorization code flow in the browser, stores the tokens in the cache directory (never in the project), and refreshes them before they expire. The access token is exposed to polling templates as `{{ oauth_access_token }}`, matching the hosted service. This is a beta feature; please report incorrect behaviour at https://github.com/usetrmnl/trmnlp/issues.
|
||||
|
||||
## 0.8.10
|
||||
|
||||
- Fixed Python serverless transforms failing on Windows. The local subprocess backend hardcoded `python3`, which the python.org Windows installer does not put on PATH (it installs `python` and the `py` launcher), so every Python transform raised "interpreter not available". The interpreter is now resolved from a per-language list of command candidates (`python3`, then `python`, then the `py` launcher) using a cross-platform PATH lookup, leaving POSIX behavior unchanged. (#116)
|
||||
|
||||
@@ -7,5 +7,6 @@ gem 'rake', '~> 13.0'
|
||||
gem 'rspec', '~> 3.13'
|
||||
gem 'rubocop', require: false
|
||||
gem 'simplecov', '~> 0.22', require: false
|
||||
gem 'webmock', '~> 3.0'
|
||||
|
||||
gemspec
|
||||
|
||||
+38
-1
@@ -1,13 +1,14 @@
|
||||
PATH
|
||||
remote: .
|
||||
specs:
|
||||
trmnl_preview (0.8.10)
|
||||
trmnl_preview (0.9.0)
|
||||
activesupport (~> 8.0)
|
||||
cgi (~> 0.5)
|
||||
faraday (~> 2.1)
|
||||
faraday-multipart (~> 1.1)
|
||||
filewatcher (~> 3.0)
|
||||
mini_magick (~> 5.3)
|
||||
oauth2 (~> 2.0)
|
||||
oj (~> 3.17)
|
||||
puma (~> 8.0)
|
||||
rackup (~> 2.2)
|
||||
@@ -36,13 +37,22 @@ GEM
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
anonymous_loader (0.1.2)
|
||||
version_gem (~> 1.1, >= 1.1.13)
|
||||
ast (2.4.3)
|
||||
auth-sanitizer (0.2.2)
|
||||
version_gem (~> 1.1, >= 1.1.10)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (4.1.2)
|
||||
cgi (0.5.1)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
diff-lcs (1.6.2)
|
||||
docile (1.4.1)
|
||||
drb (2.2.3)
|
||||
@@ -57,9 +67,14 @@ GEM
|
||||
filewatcher (3.0.1)
|
||||
logger (~> 1.7)
|
||||
module_methods (~> 1.0)
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.19.5)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
liquid (5.12.0)
|
||||
@@ -72,11 +87,23 @@ GEM
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
module_methods (1.0.0)
|
||||
multi_xml (0.9.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mustermann (3.1.1)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
nio4r (2.7.5)
|
||||
oauth2 (2.0.24)
|
||||
anonymous_loader (~> 0.1, >= 0.1.1)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.2)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
jwt (>= 1.0, < 4.0)
|
||||
logger (~> 1.2)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.6)
|
||||
version_gem (~> 1.1, >= 1.1.12)
|
||||
oj (3.17.1)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -86,6 +113,7 @@ GEM
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
prism (1.9.0)
|
||||
public_suffix (7.0.5)
|
||||
puma (8.0.1)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
@@ -159,6 +187,9 @@ GEM
|
||||
rack-protection (= 4.2.1)
|
||||
rack-session (>= 2.0.0, < 3)
|
||||
tilt (~> 2.0)
|
||||
snaky_hash (2.0.6)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
strscan (3.1.8)
|
||||
thor (1.5.0)
|
||||
tilt (2.7.0)
|
||||
@@ -175,6 +206,11 @@ GEM
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
version_gem (1.1.13)
|
||||
webmock (3.26.2)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
websocket (1.2.11)
|
||||
xdg (10.2.0)
|
||||
|
||||
@@ -190,6 +226,7 @@ DEPENDENCIES
|
||||
rubocop
|
||||
simplecov (~> 0.22)
|
||||
trmnl_preview!
|
||||
webmock (~> 3.0)
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.3
|
||||
|
||||
@@ -314,6 +314,63 @@ variables:
|
||||
|
||||
```
|
||||
|
||||
## OAuth2
|
||||
|
||||
This feature is in beta. Please report incorrect behaviour at https://github.com/usetrmnl/trmnlp/issues.
|
||||
|
||||
Some private plugins fetch data from a third-party API that requires the user to authorize access first (the OAuth2 authorization code flow). `trmnlp` can run that flow locally so you can preview the plugin with a real token. It injects the token into your polling request the same way the hosted service does, so a plugin that works locally behaves the same once deployed.
|
||||
|
||||
### 1. Configure the provider
|
||||
|
||||
Add the flat `oauth_*` keys to `src/settings.yml`. These are the provider definition, and they round-trip through `trmnlp push` and `pull` (the hosted service stores them on the plugin setting):
|
||||
|
||||
```yaml
|
||||
oauth_enabled: "true"
|
||||
oauth_authorize_url: https://github.com/login/oauth/authorize
|
||||
oauth_token_url: https://github.com/login/oauth/access_token
|
||||
oauth_scopes: "read:user user:email"
|
||||
oauth_pkce_enabled: "true" # optional, default false
|
||||
# oauth_scope_separator: " " # optional; some providers use ","
|
||||
# oauth_refresh_url: https://... # optional; defaults to oauth_token_url
|
||||
```
|
||||
|
||||
Your OAuth app credentials stay local and are never synced, so set them in your environment:
|
||||
|
||||
```sh
|
||||
export TRMNL_OAUTH_CLIENT_ID=your-oauth-app-client-id
|
||||
export TRMNL_OAUTH_CLIENT_SECRET=your-oauth-app-client-secret
|
||||
```
|
||||
|
||||
PKCE-only providers do not need a client secret.
|
||||
|
||||
### 2. Register the redirect URI
|
||||
|
||||
In your OAuth app on the provider's site, register this redirect URI:
|
||||
|
||||
```
|
||||
http://localhost:4567/oauth/callback
|
||||
```
|
||||
|
||||
Match the port if you run `trmnlp serve` on a different one.
|
||||
|
||||
### 3. Connect
|
||||
|
||||
Run `trmnlp serve` and open the preview. When OAuth is configured but not yet connected, a **Connect account** banner appears. Click it to authorize in your browser. `trmnlp` stores the tokens in its cache directory (never in your project) and refreshes them automatically before they expire. Use the **Disconnect** link to reconnect after changing scopes.
|
||||
|
||||
### 4. Use the token
|
||||
|
||||
Reference the token in your `src/settings.yml` polling configuration with the same variables the hosted service exposes:
|
||||
|
||||
- `{{ oauth_access_token }}`
|
||||
- `{{ oauth_token_type }}` (defaults to `Bearer`)
|
||||
- `{{ oauth_client_id }}`
|
||||
|
||||
For example, as a polling header:
|
||||
|
||||
```
|
||||
Authorization=Bearer {{ oauth_access_token }}
|
||||
```
|
||||
|
||||
## Serverless Transforms
|
||||
|
||||
`trmnlp` can run a transform script (`python`, `ruby`, `php`, or `node`) against the polled API response before handing data to your Liquid templates — matching the hosted plugin service's behavior.
|
||||
|
||||
@@ -6,6 +6,7 @@ require 'trmnl/liquid'
|
||||
Oj.mimic_JSON
|
||||
TRMNL::Liquid::RailsHelpers = Module.new unless defined?(TRMNL::Liquid::RailsHelpers)
|
||||
require_relative 'trmnlp/errors'
|
||||
require_relative 'trmnlp/oauth'
|
||||
require_relative 'trmnlp/config'
|
||||
require_relative 'trmnlp/context'
|
||||
require_relative 'trmnlp/screen'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'securerandom'
|
||||
require 'sinatra'
|
||||
require 'sinatra/base'
|
||||
|
||||
@@ -33,6 +34,10 @@ module TRMNLP
|
||||
def h(text)
|
||||
Rack::Utils.escape_html(text.to_s)
|
||||
end
|
||||
|
||||
# Derived from the live request so it matches at authorize and exchange
|
||||
# time; this is the single URI the developer registers with the provider.
|
||||
def oauth_callback_uri = "#{request.base_url}/oauth/callback"
|
||||
end
|
||||
|
||||
def initialize(*args)
|
||||
@@ -44,6 +49,10 @@ module TRMNLP
|
||||
@user_data_assembler = @context.user_data_assembler
|
||||
@transform_pipeline = @context.transform_pipeline
|
||||
@watcher = @context.watcher
|
||||
@oauth_session = @context.oauth_session
|
||||
# Keyed by state. A shared hash (built once) survives Sinatra's
|
||||
# per-request dup, like @live_reload_clients below.
|
||||
@oauth_state = {}
|
||||
@screenshot = Screenshot.new(pool: settings.browser_pool)
|
||||
|
||||
@poller.poll_data
|
||||
@@ -118,6 +127,35 @@ module TRMNLP
|
||||
redirect back
|
||||
end
|
||||
|
||||
get '/oauth/connect' do
|
||||
halt 400, 'OAuth is not configured. Add the oauth_* keys to src/settings.yml.' unless @oauth_session.configured?
|
||||
|
||||
state = SecureRandom.hex(16)
|
||||
if @oauth_session.pkce?
|
||||
verifier = OAuth::Pkce.verifier
|
||||
challenge = OAuth::Pkce.challenge(verifier)
|
||||
end
|
||||
@oauth_state[state] = verifier
|
||||
redirect @oauth_session.authorize_url(redirect_uri: oauth_callback_uri, state:, code_challenge: challenge)
|
||||
end
|
||||
|
||||
get '/oauth/callback' do
|
||||
halt 400, "OAuth provider returned an error: #{params[:error]}" if params[:error]
|
||||
halt 400, 'OAuth state mismatch. Restart at /oauth/connect.' unless @oauth_state.key?(params[:state])
|
||||
|
||||
verifier = @oauth_state.delete(params[:state])
|
||||
@oauth_session.complete(code: params[:code], redirect_uri: oauth_callback_uri, code_verifier: verifier)
|
||||
@poller.poll_data
|
||||
redirect '/'
|
||||
rescue StandardError => e
|
||||
halt 502, "OAuth token exchange failed: #{e.message}"
|
||||
end
|
||||
|
||||
get '/oauth/disconnect' do
|
||||
@oauth_session.disconnect
|
||||
redirect '/'
|
||||
end
|
||||
|
||||
Screen.all.each do |screen|
|
||||
view = screen.name
|
||||
get "/#{view}" do
|
||||
|
||||
@@ -30,14 +30,14 @@ module TRMNLP
|
||||
def webhook? = strategy == 'webhook'
|
||||
def static? = strategy == 'static'
|
||||
|
||||
def polling_urls
|
||||
def polling_urls(extra_variables: {})
|
||||
# allow project-level config to override
|
||||
urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings',
|
||||
'polling_url') || @config['polling_url']
|
||||
|
||||
return [] if urls.nil?
|
||||
|
||||
with_custom_fields(urls).strip.split("\n")
|
||||
with_custom_fields(urls, extra_variables:).strip.split("\n")
|
||||
end
|
||||
|
||||
# for {{ trmnl }}
|
||||
@@ -45,18 +45,18 @@ module TRMNLP
|
||||
|
||||
def polling_verb = @config['polling_verb'] || 'GET'
|
||||
|
||||
def polling_headers
|
||||
def polling_headers(extra_variables: {})
|
||||
# NOTE: render Liquid across the full headers string first so {% if %} blocks
|
||||
# spanning multiple key=value pairs are preserved. Splitting on
|
||||
# '&' or '=' before rendering would shatter tags into multiple values.
|
||||
rendered = with_custom_fields(@config['polling_headers'] || '')
|
||||
rendered = with_custom_fields(@config['polling_headers'] || '', extra_variables:)
|
||||
string_to_hash(rendered)
|
||||
end
|
||||
|
||||
# for {{ trmnl }}
|
||||
def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&')
|
||||
|
||||
def polling_body = with_custom_fields(@config['polling_body'] || '')
|
||||
def polling_body(extra_variables: {}) = with_custom_fields(@config['polling_body'] || '', extra_variables:)
|
||||
|
||||
def dark_mode = @config['dark_mode'] || 'no'
|
||||
|
||||
@@ -107,7 +107,9 @@ module TRMNLP
|
||||
|
||||
attr_reader :paths, :project_config
|
||||
|
||||
def with_custom_fields(value) = project_config.with_custom_fields(value)
|
||||
def with_custom_fields(value, extra_variables: {})
|
||||
project_config.with_custom_fields(value, extra_variables:)
|
||||
end
|
||||
|
||||
def string_to_hash(str, delimiter: '=')
|
||||
str.split('&').map do |k_v|
|
||||
|
||||
@@ -38,10 +38,11 @@ module TRMNLP
|
||||
|
||||
def user_data_overrides = @config['variables'] || {}
|
||||
|
||||
# for interpolating custom_fields into polling_* options
|
||||
def with_custom_fields(value)
|
||||
# extra_variables lets the poller inject live values (e.g.
|
||||
# oauth_access_token) into the same custom-field render.
|
||||
def with_custom_fields(value, extra_variables: {})
|
||||
custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
|
||||
parse_liquid(value).render(custom_fields_with_env)
|
||||
parse_liquid(value).render(custom_fields_with_env.merge(extra_variables))
|
||||
end
|
||||
|
||||
def time_zone = @config['time_zone'] || 'UTC'
|
||||
|
||||
+11
-1
@@ -22,7 +22,17 @@ module TRMNLP
|
||||
# Context is the composition root: it wires and memoizes the runtime
|
||||
# object graph. Callers take the collaborator they need and talk to it
|
||||
# directly — Context does not forward methods on their behalf.
|
||||
def poller = @poller ||= Poller.new(config:, paths:, reporter:)
|
||||
def poller = @poller ||= Poller.new(config:, paths:, oauth_session:, reporter:)
|
||||
|
||||
def oauth_session
|
||||
@oauth_session ||= begin
|
||||
provider = OAuth::Provider.new(config.plugin.settings)
|
||||
OAuth::Session.new(provider:,
|
||||
token_store: OAuth::TokenStore.new(paths.oauth_tokens),
|
||||
client: OAuth::Client.new(provider))
|
||||
end
|
||||
end
|
||||
|
||||
def transform_pipeline = @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:)
|
||||
def user_data_assembler = @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:)
|
||||
def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'oauth/pkce'
|
||||
require_relative 'oauth/provider'
|
||||
require_relative 'oauth/token_bundle'
|
||||
require_relative 'oauth/token_store'
|
||||
require_relative 'oauth/client'
|
||||
require_relative 'oauth/session'
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'oauth2'
|
||||
require 'uri'
|
||||
|
||||
module TRMNLP
|
||||
module OAuth
|
||||
class Client
|
||||
def initialize(provider)
|
||||
@provider = provider
|
||||
end
|
||||
|
||||
def authorize_url(redirect_uri:, state:, code_challenge: nil)
|
||||
params = { redirect_uri:, scope: provider.scopes, state: }
|
||||
params.merge!(code_challenge:, code_challenge_method: 'S256') if code_challenge
|
||||
oauth_client(provider.token_url).auth_code.authorize_url(**params)
|
||||
end
|
||||
|
||||
def exchange_code(code:, redirect_uri:, code_verifier: nil)
|
||||
params = { redirect_uri: }
|
||||
params[:code_verifier] = code_verifier if code_verifier
|
||||
bundle oauth_client(provider.token_url).auth_code.get_token(code, params)
|
||||
end
|
||||
|
||||
def refresh(refresh_token:)
|
||||
client = oauth_client(provider.refresh_url)
|
||||
bundle OAuth2::AccessToken.new(client, nil, refresh_token:).refresh!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :provider
|
||||
|
||||
def oauth_client(token_url)
|
||||
OAuth2::Client.new(
|
||||
provider.client_id, provider.client_secret,
|
||||
site: origin(token_url), authorize_url: provider.authorize_url,
|
||||
token_url:, auth_scheme:
|
||||
)
|
||||
end
|
||||
|
||||
# A PKCE public client has no secret, so its client_id must travel in the
|
||||
# request body; a confidential client authenticates with HTTP Basic.
|
||||
def auth_scheme = provider.client_secret ? :basic_auth : :request_body
|
||||
|
||||
# oauth2 posts tokens through a connection built from the site, so give it
|
||||
# the endpoint's origin even though the full URL is absolute.
|
||||
def origin(url)
|
||||
uri = URI(url)
|
||||
port = uri.port == uri.default_port ? nil : ":#{uri.port}"
|
||||
"#{uri.scheme}://#{uri.host}#{port}"
|
||||
end
|
||||
|
||||
def bundle(token)
|
||||
TokenBundle.new(
|
||||
access_token: token.token,
|
||||
refresh_token: token.refresh_token,
|
||||
expires_at: token.expires_at,
|
||||
token_type: token.params['token_type'] || 'Bearer'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'securerandom'
|
||||
require 'digest'
|
||||
require 'base64'
|
||||
|
||||
module TRMNLP
|
||||
module OAuth
|
||||
# Proof Key for Code Exchange (RFC 7636, S256).
|
||||
module Pkce
|
||||
module_function
|
||||
|
||||
def verifier = SecureRandom.urlsafe_base64(64)
|
||||
|
||||
def challenge(verifier)
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TRMNLP
|
||||
module OAuth
|
||||
# Provider definition from the flat oauth_* keys in src/settings.yml (which
|
||||
# round-trip through push/pull). Credentials stay local and env-first so
|
||||
# they are never synced or committed.
|
||||
class Provider
|
||||
ENV_CLIENT_ID = 'TRMNL_OAUTH_CLIENT_ID'
|
||||
ENV_CLIENT_SECRET = 'TRMNL_OAUTH_CLIENT_SECRET'
|
||||
|
||||
def initialize(settings, env: ENV)
|
||||
@settings = settings || {}
|
||||
@env = env
|
||||
end
|
||||
|
||||
def authorize_url = settings['oauth_authorize_url']
|
||||
def token_url = settings['oauth_token_url']
|
||||
def refresh_url = settings['oauth_refresh_url'] || token_url
|
||||
def scopes = settings['oauth_scopes']
|
||||
def scope_separator = settings['oauth_scope_separator'] || ' '
|
||||
def pkce? = truthy?(settings['oauth_pkce_enabled'])
|
||||
def enabled? = truthy?(settings['oauth_enabled'])
|
||||
def client_id = env_first(ENV_CLIENT_ID, settings['oauth_client_id'])
|
||||
def client_secret = env_first(ENV_CLIENT_SECRET, settings['oauth_client_secret'])
|
||||
|
||||
def configured?
|
||||
return false unless enabled? && authorize_url && token_url && client_id
|
||||
|
||||
pkce? || present?(client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :settings, :env
|
||||
|
||||
def truthy?(value) = [true, 'true'].include?(value)
|
||||
|
||||
def env_first(key, fallback)
|
||||
value = env[key]
|
||||
present?(value) ? value : fallback
|
||||
end
|
||||
|
||||
def present?(value) = !value.nil? && !value.empty?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TRMNLP
|
||||
module OAuth
|
||||
class RefreshError < StandardError; end
|
||||
|
||||
class Session
|
||||
def initialize(provider:, token_store:, client:)
|
||||
@provider = provider
|
||||
@token_store = token_store
|
||||
@client = client
|
||||
end
|
||||
|
||||
def configured? = provider.configured?
|
||||
|
||||
def pkce? = provider.pkce?
|
||||
|
||||
def connected?
|
||||
return false unless configured?
|
||||
|
||||
stored = token_store.read
|
||||
!stored.nil? && !stored.access_token.nil?
|
||||
end
|
||||
|
||||
def access_token = current_bundle&.access_token
|
||||
|
||||
def liquid_variables
|
||||
return {} unless connected?
|
||||
|
||||
bundle = current_bundle
|
||||
{
|
||||
'oauth_access_token' => bundle.access_token,
|
||||
'oauth_token_type' => bundle.token_type,
|
||||
'oauth_client_id' => provider.client_id
|
||||
}
|
||||
end
|
||||
|
||||
def authorize_url(redirect_uri:, state:, code_challenge: nil)
|
||||
client.authorize_url(redirect_uri:, state:, code_challenge:)
|
||||
end
|
||||
|
||||
def complete(code:, redirect_uri:, code_verifier: nil)
|
||||
token_store.write(client.exchange_code(code:, redirect_uri:, code_verifier:))
|
||||
end
|
||||
|
||||
def disconnect = token_store.clear
|
||||
|
||||
private
|
||||
|
||||
attr_reader :provider, :token_store, :client
|
||||
|
||||
# Returns the stored token, transparently refreshing and re-persisting it
|
||||
# when it is near expiry. nil when nothing is connected yet.
|
||||
def current_bundle
|
||||
stored = token_store.read
|
||||
return nil if stored.nil? || stored.access_token.nil?
|
||||
|
||||
stored.expired? ? refreshed(stored) : stored
|
||||
end
|
||||
|
||||
def refreshed(stale)
|
||||
token_store.write stale.merge_refresh(client.refresh(refresh_token: stale.refresh_token))
|
||||
rescue StandardError => e
|
||||
raise RefreshError, "OAuth token refresh failed: #{e.message}. Reconnect at /oauth/connect."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TRMNLP
|
||||
module OAuth
|
||||
TokenBundle = Data.define(:access_token, :refresh_token, :expires_at, :token_type) do
|
||||
def self.from_h(hash)
|
||||
new(
|
||||
access_token: hash['access_token'],
|
||||
refresh_token: hash['refresh_token'],
|
||||
expires_at: hash['expires_at'],
|
||||
token_type: hash['token_type'] || 'Bearer'
|
||||
)
|
||||
end
|
||||
|
||||
def expired?
|
||||
return false unless expires_at
|
||||
|
||||
# Treat as expired 5 minutes early so a token never dies mid-request.
|
||||
expires_at.to_i - 300 <= Time.now.to_i
|
||||
end
|
||||
|
||||
# A refresh response often omits the rotated refresh_token (and sometimes
|
||||
# other fields); the prior values remain valid, so carry them forward.
|
||||
def merge_refresh(fresh)
|
||||
with(
|
||||
access_token: fresh.access_token,
|
||||
refresh_token: fresh.refresh_token || refresh_token,
|
||||
expires_at: fresh.expires_at || expires_at,
|
||||
token_type: fresh.token_type || token_type
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
|
||||
module TRMNLP
|
||||
module OAuth
|
||||
class TokenStore
|
||||
def initialize(path)
|
||||
@path = path
|
||||
end
|
||||
|
||||
def read
|
||||
return nil unless path.exist?
|
||||
|
||||
TokenBundle.from_h(JSON.parse(path.read))
|
||||
end
|
||||
|
||||
def write(bundle)
|
||||
path.dirname.mkpath
|
||||
path.write(JSON.generate(bundle.to_h))
|
||||
path.chmod(0o600)
|
||||
bundle
|
||||
end
|
||||
|
||||
def clear
|
||||
path.delete if path.exist?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :path
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'digest'
|
||||
require 'xdg'
|
||||
|
||||
module TRMNLP
|
||||
@@ -45,6 +46,10 @@ module TRMNLP
|
||||
|
||||
def user_data = cache_dir.join('data.json')
|
||||
|
||||
# OAuth tokens are keyed per project root so working on two OAuth plugins
|
||||
# does not clobber a shared token file.
|
||||
def oauth_tokens = cache_dir.join('oauth', "#{Digest::SHA256.hexdigest(root_dir.to_s)[0, 16]}.json")
|
||||
|
||||
def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
|
||||
|
||||
def src_files = src_dir.glob('*').select(&:file?)
|
||||
|
||||
+17
-8
@@ -8,9 +8,10 @@ require_relative 'reporter'
|
||||
|
||||
module TRMNLP
|
||||
class Poller
|
||||
def initialize(config:, paths:, reporter: Reporter.new)
|
||||
def initialize(config:, paths:, oauth_session:, reporter: Reporter.new)
|
||||
@config = config
|
||||
@paths = paths
|
||||
@oauth_session = oauth_session
|
||||
@reporter = reporter
|
||||
end
|
||||
|
||||
@@ -39,25 +40,33 @@ module TRMNLP
|
||||
|
||||
private
|
||||
|
||||
attr_reader :config, :paths, :reporter
|
||||
attr_reader :config, :paths, :oauth_session, :reporter
|
||||
|
||||
def aggregate_responses
|
||||
responses = config.plugin.polling_urls.map { |url| fetch_one(url) }
|
||||
# Resolve once per poll (it refreshes as a side effect), then share across
|
||||
# every URL, header, and body render.
|
||||
oauth_variables = oauth_session.liquid_variables
|
||||
urls = config.plugin.polling_urls(extra_variables: oauth_variables)
|
||||
responses = urls.map { |url| fetch_one(url, oauth_variables) }
|
||||
return responses.first if responses.size == 1
|
||||
|
||||
responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
|
||||
end
|
||||
|
||||
def fetch_one(url)
|
||||
def fetch_one(url, oauth_variables)
|
||||
verb = config.plugin.polling_verb.upcase
|
||||
response = perform_request(url, verb)
|
||||
response = perform_request(url, verb, oauth_variables)
|
||||
reporter.info("#{verb} #{url} — received #{response.body.length} bytes (#{response.status} status)")
|
||||
parse_response(response)
|
||||
end
|
||||
|
||||
def perform_request(url, verb)
|
||||
conn = Faraday.new(url:, headers: config.plugin.polling_headers)
|
||||
verb == 'POST' ? conn.post { |req| req.body = config.plugin.polling_body } : conn.get
|
||||
def perform_request(url, verb, oauth_variables)
|
||||
conn = Faraday.new(url:, headers: config.plugin.polling_headers(extra_variables: oauth_variables))
|
||||
if verb == 'POST'
|
||||
conn.post { |req| req.body = config.plugin.polling_body(extra_variables: oauth_variables) }
|
||||
else
|
||||
conn.get
|
||||
end
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TRMNLP
|
||||
VERSION = '0.8.10'
|
||||
VERSION = '0.9.0'
|
||||
end
|
||||
|
||||
@@ -95,6 +95,40 @@ RSpec.describe TRMNLP::App do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /:view oauth banner' do
|
||||
it 'invites connection when configured but not connected' do
|
||||
allow(context.oauth_session).to receive_messages(configured?: true, connected?: false)
|
||||
|
||||
get '/full'
|
||||
|
||||
expect(last_response.body).to include('/oauth/connect')
|
||||
end
|
||||
|
||||
it 'offers disconnect when connected' do
|
||||
allow(context.oauth_session).to receive_messages(configured?: true, connected?: true)
|
||||
|
||||
get '/full'
|
||||
|
||||
expect(last_response.body).to include('/oauth/disconnect')
|
||||
end
|
||||
|
||||
it 'shows no oauth banner when unconfigured' do
|
||||
allow(context.oauth_session).to receive(:configured?).and_return(false)
|
||||
|
||||
get '/full'
|
||||
|
||||
expect(last_response.body).not_to include('/oauth/connect')
|
||||
end
|
||||
|
||||
it 'links to the issue tracker for bug reports' do
|
||||
allow(context.oauth_session).to receive_messages(configured?: true, connected?: false)
|
||||
|
||||
get '/full'
|
||||
|
||||
expect(last_response.body).to include('github.com/usetrmnl/trmnlp/issues')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /:view payload-size badge (#67)' do
|
||||
it 'marks a payload under 75 KB green' do
|
||||
allow(context.user_data_assembler).to receive(:call).and_return({ 'k' => 'small' })
|
||||
@@ -130,6 +164,99 @@ RSpec.describe TRMNLP::App do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /oauth/connect' do
|
||||
context 'when oauth is configured' do
|
||||
before do
|
||||
allow(context.oauth_session).to receive_messages(configured?: true, pkce?: false)
|
||||
allow(context.oauth_session).to receive(:authorize_url)
|
||||
.and_return('https://provider.test/authorize?state=x')
|
||||
end
|
||||
|
||||
it 'redirects to the provider authorize url' do
|
||||
get '/oauth/connect'
|
||||
|
||||
expect(last_response.headers['Location']).to eq('https://provider.test/authorize?state=x')
|
||||
end
|
||||
|
||||
it 'derives the callback redirect_uri from the request' do
|
||||
get '/oauth/connect'
|
||||
|
||||
expect(context.oauth_session).to have_received(:authorize_url)
|
||||
.with(hash_including(redirect_uri: 'http://example.org/oauth/callback'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a PKCE provider' do
|
||||
before do
|
||||
allow(context.oauth_session).to receive_messages(configured?: true, pkce?: true)
|
||||
allow(context.oauth_session).to receive(:authorize_url).and_return('https://provider.test/authorize')
|
||||
end
|
||||
|
||||
it 'sends a code challenge' do
|
||||
get '/oauth/connect'
|
||||
|
||||
expect(context.oauth_session).to have_received(:authorize_url)
|
||||
.with(hash_including(code_challenge: an_instance_of(String)))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when oauth is not configured' do
|
||||
before { allow(context.oauth_session).to receive(:configured?).and_return(false) }
|
||||
|
||||
it 'responds 400' do
|
||||
get '/oauth/connect'
|
||||
|
||||
expect(last_response.status).to eq(400)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /oauth/callback' do
|
||||
before do
|
||||
allow(context.oauth_session).to receive_messages(configured?: true, pkce?: false, complete: nil)
|
||||
allow(context.oauth_session).to receive(:authorize_url) do |state:, **|
|
||||
@state = state
|
||||
'https://provider.test/authorize'
|
||||
end
|
||||
end
|
||||
|
||||
it 'exchanges a valid code and redirects home' do
|
||||
get '/oauth/connect'
|
||||
get "/oauth/callback?code=abc&state=#{@state}"
|
||||
|
||||
expect(last_response.headers['Location']).to eq('http://example.org/')
|
||||
end
|
||||
|
||||
it 'passes the authorization code to the session' do
|
||||
get '/oauth/connect'
|
||||
get "/oauth/callback?code=abc&state=#{@state}"
|
||||
|
||||
expect(context.oauth_session).to have_received(:complete).with(hash_including(code: 'abc'))
|
||||
end
|
||||
|
||||
it 'rejects a mismatched state' do
|
||||
get '/oauth/callback?code=abc&state=forged'
|
||||
|
||||
expect(last_response.status).to eq(400)
|
||||
end
|
||||
|
||||
it 'surfaces a provider error' do
|
||||
get '/oauth/callback?error=access_denied'
|
||||
|
||||
expect(last_response.status).to eq(400)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /oauth/disconnect' do
|
||||
before { allow(context.oauth_session).to receive(:disconnect) }
|
||||
|
||||
it 'clears the connection and redirects home' do
|
||||
get '/oauth/disconnect'
|
||||
|
||||
expect(context.oauth_session).to have_received(:disconnect)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /webhook' do
|
||||
let(:payload) { '{"items":[1,2,3]}' }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user