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:
Ikraam Ghoor
2026-07-05 22:45:49 +01:00
committed by GitHub
parent a9dce223a9
commit 84d95e69ed
34 changed files with 1270 additions and 21 deletions
+4
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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
+57
View File
@@ -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.
+1
View File
@@ -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'
+38
View File
@@ -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
+8 -6
View File
@@ -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|
+4 -3
View File
@@ -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
View File
@@ -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:)
+8
View File
@@ -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'
+64
View File
@@ -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
+20
View File
@@ -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
+47
View File
@@ -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
+68
View File
@@ -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
+34
View File
@@ -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
+34
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module TRMNLP
VERSION = '0.8.10'
VERSION = '0.9.0'
end
+127
View File
@@ -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