From 84d95e69edce02a11bcf7fa81fec8d07660aaabb Mon Sep 17 00:00:00 2001 From: Ikraam Ghoor Date: Sun, 5 Jul 2026 23:45:49 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + Gemfile | 1 + Gemfile.lock | 39 ++++- README.md | 57 +++++++ lib/trmnlp.rb | 1 + lib/trmnlp/app.rb | 38 +++++ lib/trmnlp/config/plugin.rb | 14 +- lib/trmnlp/config/project.rb | 7 +- lib/trmnlp/context.rb | 12 +- lib/trmnlp/oauth.rb | 8 + lib/trmnlp/oauth/client.rb | 64 ++++++++ lib/trmnlp/oauth/pkce.rb | 20 +++ lib/trmnlp/oauth/provider.rb | 47 ++++++ lib/trmnlp/oauth/session.rb | 68 +++++++++ lib/trmnlp/oauth/token_bundle.rb | 34 +++++ lib/trmnlp/oauth/token_store.rb | 34 +++++ lib/trmnlp/paths.rb | 5 + lib/trmnlp/poller.rb | 25 +++- lib/trmnlp/version.rb | 2 +- spec/lib/trmnlp/app_spec.rb | 127 ++++++++++++++++ spec/lib/trmnlp/config/plugin_spec.rb | 19 +++ spec/lib/trmnlp/config/project_spec.rb | 14 ++ spec/lib/trmnlp/context_spec.rb | 12 ++ spec/lib/trmnlp/oauth/client_spec.rb | 115 +++++++++++++++ spec/lib/trmnlp/oauth/pkce_spec.rb | 19 +++ spec/lib/trmnlp/oauth/provider_spec.rb | 161 ++++++++++++++++++++ spec/lib/trmnlp/oauth/session_spec.rb | 163 +++++++++++++++++++++ spec/lib/trmnlp/oauth/token_bundle_spec.rb | 70 +++++++++ spec/lib/trmnlp/oauth/token_store_spec.rb | 54 +++++++ spec/lib/trmnlp/paths_spec.rb | 15 ++ spec/lib/trmnlp/poller_spec.rb | 28 +++- spec/spec_helper.rb | 5 + trmnl_preview.gemspec | 3 + web/views/index.erb | 6 + 34 files changed, 1270 insertions(+), 21 deletions(-) create mode 100644 lib/trmnlp/oauth.rb create mode 100644 lib/trmnlp/oauth/client.rb create mode 100644 lib/trmnlp/oauth/pkce.rb create mode 100644 lib/trmnlp/oauth/provider.rb create mode 100644 lib/trmnlp/oauth/session.rb create mode 100644 lib/trmnlp/oauth/token_bundle.rb create mode 100644 lib/trmnlp/oauth/token_store.rb create mode 100644 spec/lib/trmnlp/oauth/client_spec.rb create mode 100644 spec/lib/trmnlp/oauth/pkce_spec.rb create mode 100644 spec/lib/trmnlp/oauth/provider_spec.rb create mode 100644 spec/lib/trmnlp/oauth/session_spec.rb create mode 100644 spec/lib/trmnlp/oauth/token_bundle_spec.rb create mode 100644 spec/lib/trmnlp/oauth/token_store_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index fa15fbe..0d59d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Gemfile b/Gemfile index 01ea494..15c8369 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 2ae9460..23cd1b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/README.md b/README.md index 3d2ce1c..8037a8a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/trmnlp.rb b/lib/trmnlp.rb index ccb3656..1391649 100644 --- a/lib/trmnlp.rb +++ b/lib/trmnlp.rb @@ -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' diff --git a/lib/trmnlp/app.rb b/lib/trmnlp/app.rb index 68d0a3d..3a5f188 100644 --- a/lib/trmnlp/app.rb +++ b/lib/trmnlp/app.rb @@ -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 diff --git a/lib/trmnlp/config/plugin.rb b/lib/trmnlp/config/plugin.rb index 97e7299..75df60c 100644 --- a/lib/trmnlp/config/plugin.rb +++ b/lib/trmnlp/config/plugin.rb @@ -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| diff --git a/lib/trmnlp/config/project.rb b/lib/trmnlp/config/project.rb index 3b9459a..f4d95fa 100644 --- a/lib/trmnlp/config/project.rb +++ b/lib/trmnlp/config/project.rb @@ -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' diff --git a/lib/trmnlp/context.rb b/lib/trmnlp/context.rb index b40a74a..0869e01 100644 --- a/lib/trmnlp/context.rb +++ b/lib/trmnlp/context.rb @@ -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:) diff --git a/lib/trmnlp/oauth.rb b/lib/trmnlp/oauth.rb new file mode 100644 index 0000000..6d9d1fd --- /dev/null +++ b/lib/trmnlp/oauth.rb @@ -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' diff --git a/lib/trmnlp/oauth/client.rb b/lib/trmnlp/oauth/client.rb new file mode 100644 index 0000000..2574560 --- /dev/null +++ b/lib/trmnlp/oauth/client.rb @@ -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 diff --git a/lib/trmnlp/oauth/pkce.rb b/lib/trmnlp/oauth/pkce.rb new file mode 100644 index 0000000..9cf995c --- /dev/null +++ b/lib/trmnlp/oauth/pkce.rb @@ -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 diff --git a/lib/trmnlp/oauth/provider.rb b/lib/trmnlp/oauth/provider.rb new file mode 100644 index 0000000..80f4293 --- /dev/null +++ b/lib/trmnlp/oauth/provider.rb @@ -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 diff --git a/lib/trmnlp/oauth/session.rb b/lib/trmnlp/oauth/session.rb new file mode 100644 index 0000000..443a6ab --- /dev/null +++ b/lib/trmnlp/oauth/session.rb @@ -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 diff --git a/lib/trmnlp/oauth/token_bundle.rb b/lib/trmnlp/oauth/token_bundle.rb new file mode 100644 index 0000000..70a8aa7 --- /dev/null +++ b/lib/trmnlp/oauth/token_bundle.rb @@ -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 diff --git a/lib/trmnlp/oauth/token_store.rb b/lib/trmnlp/oauth/token_store.rb new file mode 100644 index 0000000..9f3e8e3 --- /dev/null +++ b/lib/trmnlp/oauth/token_store.rb @@ -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 diff --git a/lib/trmnlp/paths.rb b/lib/trmnlp/paths.rb index 81e3e4d..0f74e65 100644 --- a/lib/trmnlp/paths.rb +++ b/lib/trmnlp/paths.rb @@ -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?) diff --git a/lib/trmnlp/poller.rb b/lib/trmnlp/poller.rb index 844d74b..a27ea6b 100644 --- a/lib/trmnlp/poller.rb +++ b/lib/trmnlp/poller.rb @@ -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) diff --git a/lib/trmnlp/version.rb b/lib/trmnlp/version.rb index 90afeb0..b0e7bcd 100644 --- a/lib/trmnlp/version.rb +++ b/lib/trmnlp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module TRMNLP - VERSION = '0.8.10' + VERSION = '0.9.0' end diff --git a/spec/lib/trmnlp/app_spec.rb b/spec/lib/trmnlp/app_spec.rb index 50d9950..f8b1bcf 100644 --- a/spec/lib/trmnlp/app_spec.rb +++ b/spec/lib/trmnlp/app_spec.rb @@ -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]}' } diff --git a/spec/lib/trmnlp/config/plugin_spec.rb b/spec/lib/trmnlp/config/plugin_spec.rb index 55d0491..f3dd1d2 100644 --- a/spec/lib/trmnlp/config/plugin_spec.rb +++ b/spec/lib/trmnlp/config/plugin_spec.rb @@ -42,6 +42,25 @@ RSpec.describe TRMNLP::Config::Plugin do end end + describe 'oauth variable injection' do + it 'injects extra variables into polling_headers' do + plugin.instance_variable_set(:@config, { 'polling_headers' => 'Authorization=Bearer {{ oauth_access_token }}' }) + expect(plugin.polling_headers(extra_variables: { 'oauth_access_token' => 'AT' })) + .to eq({ 'Authorization' => 'Bearer AT' }) + end + + it 'injects extra variables into polling_urls' do + plugin.instance_variable_set(:@config, { 'polling_url' => 'https://api.test/?t={{ oauth_access_token }}' }) + expect(plugin.polling_urls(extra_variables: { 'oauth_access_token' => 'AT' })) + .to eq(['https://api.test/?t=AT']) + end + + it 'injects extra variables into polling_body' do + plugin.instance_variable_set(:@config, { 'polling_body' => 'token={{ oauth_access_token }}' }) + expect(plugin.polling_body(extra_variables: { 'oauth_access_token' => 'AT' })).to eq('token=AT') + end + end + describe '#framework_version' do context 'when settings.yml pins a version' do let(:pinned) { TRMNLP::FrameworkVersion.version_numbers.first } diff --git a/spec/lib/trmnlp/config/project_spec.rb b/spec/lib/trmnlp/config/project_spec.rb index 00281c6..233275e 100644 --- a/spec/lib/trmnlp/config/project_spec.rb +++ b/spec/lib/trmnlp/config/project_spec.rb @@ -31,6 +31,20 @@ RSpec.describe TRMNLP::Config::Project do end end + describe '#with_custom_fields' do + before { project.instance_variable_set(:@config, { 'custom_fields' => { 'city' => 'Perth' } }) } + + it 'renders custom field values' do + expect(project.with_custom_fields('in {{ city }}')).to eq('in Perth') + end + + it 'merges extra variables into the render context' do + rendered = project.with_custom_fields('Bearer {{ oauth_access_token }}', + extra_variables: { 'oauth_access_token' => 'AT' }) + expect(rendered).to eq('Bearer AT') + end + end + describe '#serverless_daemon_api_key' do let(:root_dir) { 'not-a-valid-path' } diff --git a/spec/lib/trmnlp/context_spec.rb b/spec/lib/trmnlp/context_spec.rb index 251a9f1..999020d 100644 --- a/spec/lib/trmnlp/context_spec.rb +++ b/spec/lib/trmnlp/context_spec.rb @@ -3,6 +3,18 @@ require 'spec_helper' RSpec.describe TRMNLP::Context do + describe '#oauth_session' do + subject(:context) { described_class.new(File.join(__dir__, '../../fixtures')) } + + it 'builds an OAuth session' do + expect(context.oauth_session).to be_a(TRMNLP::OAuth::Session) + end + + it 'memoizes the session' do + expect(context.oauth_session).to be(context.oauth_session) + end + end + describe '#validate!' do context 'when the directory is not a plugin' do subject(:context) { described_class.new('/tmp') } diff --git a/spec/lib/trmnlp/oauth/client_spec.rb b/spec/lib/trmnlp/oauth/client_spec.rb new file mode 100644 index 0000000..11637e7 --- /dev/null +++ b/spec/lib/trmnlp/oauth/client_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'cgi' + +RSpec.describe TRMNLP::OAuth::Client do + subject(:client) { described_class.new(provider) } + + let(:provider) { TRMNLP::OAuth::Provider.new(config, env: {}) } + let(:config) do + { + 'oauth_enabled' => 'true', + 'oauth_authorize_url' => 'https://provider.test/authorize', + 'oauth_token_url' => 'https://provider.test/token', + 'oauth_scopes' => 'read write', + 'oauth_client_id' => 'cid', + 'oauth_client_secret' => 'secret' + } + end + let(:redirect_uri) { 'http://localhost:4567/oauth/callback' } + let(:token_response) do + { access_token: 'AT', refresh_token: 'RT', expires_in: 3600, token_type: 'bearer' } + end + + describe '#authorize_url' do + it 'requests the authorization code grant with the configured params' do + url = client.authorize_url(redirect_uri:, state: 'xyz') + + expect(CGI.unescape(url)).to include( + 'response_type=code', 'client_id=cid', 'state=xyz', + 'scope=read write', "redirect_uri=#{redirect_uri}" + ) + end + + context 'with a PKCE challenge' do + it 'includes the S256 code challenge' do + url = client.authorize_url(redirect_uri:, state: 'xyz', code_challenge: 'chal') + + expect(url).to include('code_challenge=chal', 'code_challenge_method=S256') + end + end + end + + describe '#exchange_code' do + before do + stub_request(:post, 'https://provider.test/token').to_return( + status: 200, body: token_response.to_json, headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the token bundle' do + expect(client.exchange_code(code: 'thecode', redirect_uri:)) + .to have_attributes(access_token: 'AT', refresh_token: 'RT', token_type: 'bearer') + end + + it 'resolves expires_in into an absolute expires_at' do + expect(client.exchange_code(code: 'thecode', redirect_uri:).expires_at).to be > Time.now.to_i + end + + context 'with a PKCE code verifier' do + it 'sends the verifier in the token request' do + client.exchange_code(code: 'thecode', redirect_uri:, code_verifier: 'the-verifier') + + expect(a_request(:post, 'https://provider.test/token') + .with(body: hash_including('code_verifier' => 'the-verifier'))).to have_been_made + end + end + + context 'as a public client without a secret' do + let(:config) { super().except('oauth_client_secret').merge('oauth_pkce_enabled' => 'true') } + + it 'sends the client_id in the request body' do + client.exchange_code(code: 'thecode', redirect_uri:) + + expect(a_request(:post, 'https://provider.test/token') + .with(body: hash_including('client_id' => 'cid'))).to have_been_made + end + end + end + + describe '#refresh' do + before do + stub_request(:post, 'https://provider.test/token').to_return( + status: 200, body: token_response.merge(access_token: 'AT2').to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the refreshed bundle' do + expect(client.refresh(refresh_token: 'RT').access_token).to eq('AT2') + end + + it 'sends the refresh_token grant' do + client.refresh(refresh_token: 'RT') + + expect(a_request(:post, 'https://provider.test/token') + .with(body: hash_including('grant_type' => 'refresh_token'))).to have_been_made + end + + context 'with a distinct refresh_url' do + let(:config) { super().merge('oauth_refresh_url' => 'https://provider.test/refresh') } + + before do + stub_request(:post, 'https://provider.test/refresh').to_return( + status: 200, body: token_response.merge(access_token: 'AT3').to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'refreshes against the refresh_url' do + expect(client.refresh(refresh_token: 'RT').access_token).to eq('AT3') + end + end + end +end diff --git a/spec/lib/trmnlp/oauth/pkce_spec.rb b/spec/lib/trmnlp/oauth/pkce_spec.rb new file mode 100644 index 0000000..ea550f7 --- /dev/null +++ b/spec/lib/trmnlp/oauth/pkce_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TRMNLP::OAuth::Pkce do + describe '.challenge' do + # RFC 7636 Appendix B test vector. + it 'is the padless base64url S256 digest of the verifier' do + verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + expect(described_class.challenge(verifier)).to eq('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + end + end + + describe '.verifier' do + it 'generates a url-safe string' do + expect(described_class.verifier).to match(/\A[A-Za-z0-9\-_]+\z/) + end + end +end diff --git a/spec/lib/trmnlp/oauth/provider_spec.rb b/spec/lib/trmnlp/oauth/provider_spec.rb new file mode 100644 index 0000000..744e7a2 --- /dev/null +++ b/spec/lib/trmnlp/oauth/provider_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TRMNLP::OAuth::Provider do + subject(:provider) { described_class.new(settings, env:) } + + let(:settings) { {} } + let(:env) { {} } + let(:full_settings) do + { + 'oauth_enabled' => 'true', + 'oauth_authorize_url' => 'https://provider.test/authorize', + 'oauth_token_url' => 'https://provider.test/token', + 'oauth_scopes' => 'read:user user:email', + 'oauth_pkce_enabled' => 'true' + } + end + + describe '#authorize_url, #token_url, #scopes' do + let(:settings) { full_settings } + + it 'read the flat oauth_* keys from settings.yml' do + expect(provider).to have_attributes( + authorize_url: 'https://provider.test/authorize', + token_url: 'https://provider.test/token', + scopes: 'read:user user:email' + ) + end + end + + describe '#refresh_url' do + let(:settings) { { 'oauth_token_url' => 'https://provider.test/token' } } + + it 'falls back to the token_url' do + expect(provider.refresh_url).to eq('https://provider.test/token') + end + + context 'when set explicitly' do + let(:settings) { super().merge('oauth_refresh_url' => 'https://provider.test/refresh') } + + it 'uses the configured refresh_url' do + expect(provider.refresh_url).to eq('https://provider.test/refresh') + end + end + end + + describe '#scope_separator' do + it 'defaults to a single space' do + expect(provider.scope_separator).to eq(' ') + end + + context 'when configured' do + let(:settings) { { 'oauth_scope_separator' => ',' } } + + it 'uses the configured separator' do + expect(provider.scope_separator).to eq(',') + end + end + end + + describe '#pkce?' do + it 'defaults to false' do + expect(provider).not_to be_pkce + end + + context "when enabled with the string 'true'" do + let(:settings) { { 'oauth_pkce_enabled' => 'true' } } + + it 'is true' do + expect(provider).to be_pkce + end + end + + context 'when enabled with a boolean' do + let(:settings) { { 'oauth_pkce_enabled' => true } } + + it 'is true' do + expect(provider).to be_pkce + end + end + end + + describe '#client_id' do + let(:settings) { { 'oauth_client_id' => 'from-settings' } } + + it 'falls back to the settings value when the env var is absent' do + expect(provider.client_id).to eq('from-settings') + end + + context 'when the env var is set' do + let(:env) { { 'TRMNL_OAUTH_CLIENT_ID' => 'from-env' } } + + it 'prefers the env var' do + expect(provider.client_id).to eq('from-env') + end + end + end + + describe '#client_secret' do + let(:settings) { { 'oauth_client_secret' => 'from-settings' } } + + it 'falls back to the settings value when the env var is absent' do + expect(provider.client_secret).to eq('from-settings') + end + + context 'when the env var is set' do + let(:env) { { 'TRMNL_OAUTH_CLIENT_SECRET' => 'from-env' } } + + it 'prefers the env var' do + expect(provider.client_secret).to eq('from-env') + end + end + end + + describe '#configured?' do + let(:env) { { 'TRMNL_OAUTH_CLIENT_ID' => 'cid' } } + + context 'with a PKCE provider and no secret' do + let(:settings) { full_settings } + + it 'is configured' do + expect(provider).to be_configured + end + end + + context 'with a secret and no PKCE' do + let(:settings) { full_settings.merge('oauth_pkce_enabled' => 'false') } + let(:env) { super().merge('TRMNL_OAUTH_CLIENT_SECRET' => 'shh') } + + it 'is configured' do + expect(provider).to be_configured + end + end + + context 'when oauth is disabled' do + let(:settings) { full_settings.merge('oauth_enabled' => 'false') } + + it 'is not configured' do + expect(provider).not_to be_configured + end + end + + context 'without a client_id' do + let(:settings) { full_settings } + let(:env) { {} } + + it 'is not configured' do + expect(provider).not_to be_configured + end + end + + context 'without PKCE or a secret' do + let(:settings) { full_settings.merge('oauth_pkce_enabled' => 'false') } + + it 'is not configured' do + expect(provider).not_to be_configured + end + end + end +end diff --git a/spec/lib/trmnlp/oauth/session_spec.rb b/spec/lib/trmnlp/oauth/session_spec.rb new file mode 100644 index 0000000..5bf5fd9 --- /dev/null +++ b/spec/lib/trmnlp/oauth/session_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +RSpec.describe TRMNLP::OAuth::Session do + subject(:session) { described_class.new(provider:, token_store:, client:) } + + let(:provider) { TRMNLP::OAuth::Provider.new(config, env: {}) } + let(:config) do + { + 'oauth_enabled' => 'true', + 'oauth_authorize_url' => 'https://provider.test/authorize', + 'oauth_token_url' => 'https://provider.test/token', + 'oauth_scopes' => 'read', + 'oauth_client_id' => 'cid', + 'oauth_client_secret' => 'secret' + } + end + let(:tmpdir) { Pathname(Dir.mktmpdir) } + let(:token_store) { TRMNLP::OAuth::TokenStore.new(tmpdir.join('tokens.json')) } + let(:client) { instance_double(TRMNLP::OAuth::Client) } + + let(:valid_bundle) do + TRMNLP::OAuth::TokenBundle.new(access_token: 'AT', refresh_token: 'RT', + expires_at: Time.now.to_i + 3600, token_type: 'Bearer') + end + let(:expired_bundle) { valid_bundle.with(expires_at: Time.now.to_i - 10) } + let(:refreshed_bundle) do + TRMNLP::OAuth::TokenBundle.new(access_token: 'AT2', refresh_token: 'RT2', + expires_at: Time.now.to_i + 3600, token_type: 'Bearer') + end + + after { FileUtils.remove_entry(tmpdir) if tmpdir.exist? } + + describe '#configured?' do + it 'delegates to the provider' do + expect(session).to be_configured + end + end + + describe '#pkce?' do + it 'delegates to the provider' do + expect(session).not_to be_pkce + end + end + + describe '#connected?' do + it 'is not connected without a stored token' do + expect(session).not_to be_connected + end + + context 'with a stored token' do + before { token_store.write(valid_bundle) } + + it 'is connected' do + expect(session).to be_connected + end + end + end + + describe '#access_token' do + it 'returns nil without a stored token' do + expect(session.access_token).to be_nil + end + + context 'with a valid token' do + before { token_store.write(valid_bundle) } + + it 'returns the stored token without refreshing' do + expect(session.access_token).to eq('AT') + end + end + + context 'with an expired token' do + before do + token_store.write(expired_bundle) + allow(client).to receive(:refresh).with(refresh_token: 'RT').and_return(refreshed_bundle) + end + + it 'refreshes and returns the new token' do + expect(session.access_token).to eq('AT2') + end + + it 'persists the refreshed bundle' do + session.access_token + + expect(token_store.read.access_token).to eq('AT2') + end + end + + context 'when the refresh omits a new refresh_token' do + before do + token_store.write(expired_bundle) + allow(client).to receive(:refresh).and_return(refreshed_bundle.with(refresh_token: nil)) + end + + it 'keeps the previous refresh_token' do + session.access_token + + expect(token_store.read.refresh_token).to eq('RT') + end + end + + context 'when the refresh fails' do + before do + token_store.write(expired_bundle) + allow(client).to receive(:refresh).and_raise(StandardError, 'boom') + end + + it 'raises a RefreshError naming the reconnect path' do + expect { session.access_token }.to raise_error(TRMNLP::OAuth::RefreshError, %r{/oauth/connect}) + end + end + end + + describe '#liquid_variables' do + it 'returns no variables when not connected' do + expect(session.liquid_variables).to eq({}) + end + + context 'when connected' do + before { token_store.write(valid_bundle) } + + it 'exposes the oauth variables' do + expect(session.liquid_variables).to eq( + 'oauth_access_token' => 'AT', 'oauth_token_type' => 'Bearer', 'oauth_client_id' => 'cid' + ) + end + end + end + + describe '#complete' do + before { allow(client).to receive(:exchange_code).and_return(valid_bundle) } + + it 'stores the exchanged bundle' do + session.complete(code: 'thecode', redirect_uri: 'http://localhost:4567/oauth/callback') + + expect(token_store.read).to eq(valid_bundle) + end + end + + describe '#authorize_url' do + before { allow(client).to receive(:authorize_url).and_return('https://provider.test/authorize?x=1') } + + it 'delegates to the client' do + expect(session.authorize_url(redirect_uri: 'http://localhost:4567/oauth/callback', state: 's')) + .to eq('https://provider.test/authorize?x=1') + end + end + + describe '#disconnect' do + before do + token_store.write(valid_bundle) + session.disconnect + end + + it 'clears the connection' do + expect(session).not_to be_connected + end + end +end diff --git a/spec/lib/trmnlp/oauth/token_bundle_spec.rb b/spec/lib/trmnlp/oauth/token_bundle_spec.rb new file mode 100644 index 0000000..d196cb4 --- /dev/null +++ b/spec/lib/trmnlp/oauth/token_bundle_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +RSpec.describe TRMNLP::OAuth::TokenBundle do + subject(:bundle) do + described_class.new(access_token: 'AT', refresh_token: 'RT', expires_at:, token_type: 'Bearer') + end + + let(:expires_at) { Time.now.to_i + 3600 } + + describe '#expired?' do + it 'is not expired when the expiry is comfortably ahead' do + expect(bundle).not_to be_expired + end + + context 'when the expiry falls inside the refresh buffer' do + let(:expires_at) { Time.now.to_i + 60 } + + it 'is expired' do + expect(bundle).to be_expired + end + end + + context 'when the expiry is in the past' do + let(:expires_at) { Time.now.to_i - 10 } + + it 'is expired' do + expect(bundle).to be_expired + end + end + + context 'when there is no expiry' do + let(:expires_at) { nil } + + it 'never expires' do + expect(bundle).not_to be_expired + end + end + end + + describe '#merge_refresh' do + let(:fresh) do + described_class.new(access_token: 'AT2', refresh_token: 'RT2', expires_at: 999, token_type: 'Bearer') + end + + it 'takes the refreshed values' do + expect(bundle.merge_refresh(fresh)).to have_attributes(access_token: 'AT2', refresh_token: 'RT2', expires_at: 999) + end + + context 'when the refresh omits the refresh_token' do + let(:fresh) { super().with(refresh_token: nil) } + + it 'keeps the previous refresh_token' do + expect(bundle.merge_refresh(fresh).refresh_token).to eq('RT') + end + end + end + + describe '.from_h' do + it 'restores the bundle from its serialized hash' do + expect(described_class.from_h(JSON.parse(JSON.generate(bundle.to_h)))).to eq(bundle) + end + + it 'defaults token_type to Bearer when absent' do + expect(described_class.from_h('access_token' => 'AT').token_type).to eq('Bearer') + end + end +end diff --git a/spec/lib/trmnlp/oauth/token_store_spec.rb b/spec/lib/trmnlp/oauth/token_store_spec.rb new file mode 100644 index 0000000..972db2b --- /dev/null +++ b/spec/lib/trmnlp/oauth/token_store_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +RSpec.describe TRMNLP::OAuth::TokenStore do + subject(:store) { described_class.new(path) } + + let(:tmpdir) { Pathname(Dir.mktmpdir) } + let(:path) { tmpdir.join('oauth', 'tokens.json') } + let(:bundle) do + TRMNLP::OAuth::TokenBundle.new(access_token: 'at', refresh_token: 'rt', expires_at: 123, token_type: 'Bearer') + end + + after { FileUtils.remove_entry(tmpdir) if tmpdir.exist? } + + describe '#read' do + it 'returns nil when no file has been written' do + expect(store.read).to be_nil + end + + context 'after writing a bundle' do + before { store.write(bundle) } + + it 'returns the stored bundle' do + expect(store.read).to eq(bundle) + end + end + end + + describe '#write' do + before { store.write(bundle) } + + it 'creates the parent directory' do + expect(path.dirname).to be_directory + end + + it 'writes the file readable only by the owner' do + expect(path.stat.mode & 0o777).to eq(0o600) + end + end + + describe '#clear' do + before do + store.write(bundle) + store.clear + end + + it 'removes the stored bundle' do + expect(store.read).to be_nil + end + end +end diff --git a/spec/lib/trmnlp/paths_spec.rb b/spec/lib/trmnlp/paths_spec.rb index d4e1c67..dabb684 100644 --- a/spec/lib/trmnlp/paths_spec.rb +++ b/spec/lib/trmnlp/paths_spec.rb @@ -64,6 +64,21 @@ RSpec.describe TRMNLP::Paths do end end + describe '#oauth_tokens' do + it 'lives under the oauth cache directory' do + expect(paths.oauth_tokens.to_s).to start_with(paths.cache_dir.join('oauth').to_s) + end + + it 'is a json file' do + expect(paths.oauth_tokens.extname).to eq('.json') + end + + it 'is keyed per project root' do + other = described_class.new(Dir.mktmpdir('trmnlp-other-')) + expect(paths.oauth_tokens).not_to eq(other.oauth_tokens) + end + end + describe '#expand' do it 'resolves a relative path against the project root' do expect(paths.expand('src').to_s).to eq(File.join(tmp_root, 'src')) diff --git a/spec/lib/trmnlp/poller_spec.rb b/spec/lib/trmnlp/poller_spec.rb index 9c62773..d636377 100644 --- a/spec/lib/trmnlp/poller_spec.rb +++ b/spec/lib/trmnlp/poller_spec.rb @@ -3,11 +3,12 @@ require 'spec_helper' RSpec.describe TRMNLP::Poller do - subject(:poller) { described_class.new(config:, paths:) } + subject(:poller) { described_class.new(config:, paths:, oauth_session:) } let(:root_dir) { File.join(__dir__, '../../fixtures') } let(:paths) { TRMNLP::Paths.new(root_dir) } let(:config) { TRMNLP::Config.new(paths) } + let(:oauth_session) { instance_double(TRMNLP::OAuth::Session, liquid_variables: {}) } let(:content_type_cases) do [ { name: 'json', @@ -134,6 +135,31 @@ RSpec.describe TRMNLP::Poller do end end + context 'when an oauth session is connected' do + before do + allow(config.plugin).to receive_messages( + polling?: true, polling_verb: 'GET', + polling_urls: ['https://example.com/api'], polling_headers: {} + ) + allow(oauth_session).to receive(:liquid_variables).and_return('oauth_access_token' => 'AT') + allow(faraday_connection).to receive(:get).and_return(headerless_response) + end + + it 'threads the oauth variables into the polling headers render' do + poller.poll_data + + expect(config.plugin).to have_received(:polling_headers) + .with(extra_variables: { 'oauth_access_token' => 'AT' }) + end + + it 'threads the oauth variables into the polling urls render' do + poller.poll_data + + expect(config.plugin).to have_received(:polling_urls) + .with(extra_variables: { 'oauth_access_token' => 'AT' }) + end + end + context 'when the plugin is not configured for polling' do before { allow(config.plugin).to receive(:polling?).and_return(false) } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d659782..8444fb7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -13,6 +13,11 @@ end require File.join(__dir__, '../lib/trmnlp') +require 'webmock/rspec' +# Block real outbound HTTP in specs, but let rack-test drive the local +# Sinatra app (OAuth route specs) and any other localhost traffic. +WebMock.disable_net_connect!(allow_localhost: true) + # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| config.color = true diff --git a/trmnl_preview.gemspec b/trmnl_preview.gemspec index 1fced4a..3fa032d 100644 --- a/trmnl_preview.gemspec +++ b/trmnl_preview.gemspec @@ -54,6 +54,9 @@ Gem::Specification.new do |spec| spec.add_dependency 'mini_magick', '~> 5.3' spec.add_dependency 'selenium-webdriver', '~> 4.44' + # OAuth2 (local plugin preview auth flow) + spec.add_dependency 'oauth2', '~> 2.0' + # Utilities spec.add_dependency 'cgi', '~> 0.5' spec.add_dependency 'faraday', '~> 2.1' diff --git a/web/views/index.erb b/web/views/index.erb index 01c4749..5ecf30e 100644 --- a/web/views/index.erb +++ b/web/views/index.erb @@ -60,6 +60,12 @@ + <% if @oauth_session&.connected? %> +
OAuth account connected (beta). Disconnect ยท Report an issue
+ <% elsif @oauth_session&.configured? %> +
Connect account to authorize this plugin's OAuth requests. OAuth is in beta. Report an issue.
+ <% end %> + <% if @transform_error %>
Transform error: <%= h @transform_error %>
<% end %>