From ca5c1ba0d37b9bb047ef731b35bac68e746e1148 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 19 Oct 2026 23:00:29 -0700 Subject: [PATCH 01/16] Design the consent screen and Connected apps: who's asking, what it may do, where it goes The consent screen names a self-registered app by where its access goes, its own name quoted beside it, warns that Fizzy hasn't checked it, explains each access level in plain words, says who is signed in and where the browser goes next. An account picker partial is ready for account binding. Errors shown on Fizzy say what to do next. Connected apps groups apps by kind (web, on your devices, Fizzy's own), with access, when connected and last active, and an empty state. --- app/assets/stylesheets/oauth.css | 108 +++++++++++++++++ app/helpers/oauth_helper.rb | 72 +++++++++++ app/models/oauth/client.rb | 21 ++++ .../my/connected_apps/_connected_app.html.erb | 112 +++++++++++------- app/views/my/connected_apps/index.html.erb | 30 +++-- .../oauth/authorizations/_access.html.erb | 24 ++++ .../authorizations/_account_picker.html.erb | 22 ++++ .../oauth/authorizations/_client.html.erb | 28 +++++ app/views/oauth/authorizations/error.html.erb | 18 +-- app/views/oauth/authorizations/new.html.erb | 45 +++---- .../my/connected_apps_controller_test.rb | 29 +++++ test/helpers/oauth_helper_test.rb | 56 +++++++++ test/integration/oauth_consent_screen_test.rb | 92 ++++++++++++++ test/integration/oauth_flow_test.rb | 12 +- test/models/oauth/client_test.rb | 8 ++ 15 files changed, 584 insertions(+), 93 deletions(-) create mode 100644 app/assets/stylesheets/oauth.css create mode 100644 app/helpers/oauth_helper.rb create mode 100644 app/views/oauth/authorizations/_access.html.erb create mode 100644 app/views/oauth/authorizations/_account_picker.html.erb create mode 100644 app/views/oauth/authorizations/_client.html.erb create mode 100644 test/helpers/oauth_helper_test.rb create mode 100644 test/integration/oauth_consent_screen_test.rb diff --git a/app/assets/stylesheets/oauth.css b/app/assets/stylesheets/oauth.css new file mode 100644 index 0000000000..5a9e966c01 --- /dev/null +++ b/app/assets/stylesheets/oauth.css @@ -0,0 +1,108 @@ +/* Authorizing an app (oauth/authorizations) and the apps you've connected (my/connected_apps) */ + +@layer components { + .oauth-consent { + display: flex; + flex-direction: column; + gap: var(--block-space); + } + + .oauth-client__name { + overflow-wrap: anywhere; + } + + .oauth-client__self-name { + overflow-wrap: anywhere; + } + + .oauth-client__warning { + background-color: var(--color-highlight); + border-radius: 0.5em; + padding: var(--block-space-half) var(--inline-space); + } + + .oauth-access { + border: 0; + margin: 0; + padding: 0; + } + + .oauth-access__legend { + margin-block-end: var(--block-space-half); + padding: 0; + } + + .oauth-access__choice { + align-items: start; + border: var(--border); + border-radius: 0.5em; + cursor: pointer; + display: flex; + gap: var(--inline-space); + padding: var(--block-space-half) var(--inline-space); + + &:has(:checked) { + border-color: var(--color-ink); + } + + &:has(:focus-visible) { + outline: 2px solid var(--color-ink); + outline-offset: 2px; + } + } + + .oauth-access__radio { + accent-color: var(--color-ink); + flex-shrink: 0; + margin-block-start: 0.2em; + } + + .oauth-consent__actions { + flex-wrap: wrap; + } + + .connected-apps { + display: flex; + flex-direction: column; + gap: var(--block-space); + } + + .connected-apps__group { + display: flex; + flex-direction: column; + } + + .connected-apps__empty { + border: 2px dashed var(--color-ink-lighter); + border-radius: 0.5em; + padding: var(--block-space) var(--inline-space-double); + } + + .connected-app { + align-items: start; + border-block-end: var(--border); + display: flex; + gap: var(--inline-space); + justify-content: space-between; + padding-block: var(--block-space-half); + + &:last-child { + border-block-end: 0; + } + + /* Narrow screens: the disconnect button drops under the details */ + @media (max-width: 480px) { + flex-direction: column; + } + } + + .connected-app__disconnect { + flex-shrink: 0; + } + + .connected-app__details { + gap: 0.15em; + min-inline-size: 0; + overflow-wrap: anywhere; + } +} diff --git a/app/helpers/oauth_helper.rb b/app/helpers/oauth_helper.rb new file mode 100644 index 0000000000..d01ce3063d --- /dev/null +++ b/app/helpers/oauth_helper.rb @@ -0,0 +1,72 @@ +module OauthHelper + # Bidirectional controls can make a name read as something else, or push the + # host beside it out of place. A self-registered name is the app's own claim, + # so they go before it is shown anywhere. + BIDI_CONTROLS = /[؜‎‏‪-‮⁦-⁩]/ + + # Grouped by what the app is (Oauth::Client#connection_kind), in this order; + # a group with nothing in it isn't shown. + CONNECTED_APP_GROUPS = { + web: "Apps and assistants on the web", + local: "Apps on your devices", + fizzy: "Fizzy apps" + } + + ACCESS_LEVELS = { + "read" => { + label: "Read only", + description: "See your boards, cards, comments and the people in your accounts. It can't change anything." + }, + "read write" => { + label: "Read and write", + description: "See everything you can, and act as you: create, edit, move, close and delete cards, comments and boards." + } + } + + def oauth_client_self_name(client) + client.name.to_s.gsub(BIDI_CONTROLS, "") + end + + # Who the person is dealing with, as far as we can vouch for it. A trusted or + # operator-registered app is known by its name. A self-registered app's name + # is only its claim, so it goes by where its access goes: the callback's + # host, port included when it isn't the default, or this device. + def oauth_client_display_identity(client, redirect_uri) + if client.dynamically_registered? + oauth_redirect_host(redirect_uri) || "an app on your device" + else + oauth_client_self_name(client) + end + end + + # nil for a callback that stays on this device: loopback, or an app's own + # URL scheme. + def oauth_redirect_host(redirect_uri) + uri = URI.parse(redirect_uri.to_s) + + if uri.host.present? && !Oauth.loopback_host?(uri.host) && uri.scheme.to_s.downcase.in?(%w[ http https ]) + uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}" + end + rescue URI::InvalidURIError + nil + end + + # The access levels a client may be granted, weakest first, each with what + # it lets the app do in plain words. + def oauth_access_levels(client) + levels = client.scopes.include?("write") ? ACCESS_LEVELS.keys : %w[ read ] + levels.map { |scope| [ scope, ACCESS_LEVELS.fetch(scope) ] } + end + + def oauth_access_label(permission) + ACCESS_LEVELS.fetch(Oauth.canonical_scope(permission))[:label] + end + + def connected_app_groups(connected_apps) + by_kind = connected_apps.group_by { |client, _| client.connection_kind } + + CONNECTED_APP_GROUPS.filter_map do |kind, heading| + [ kind, heading, by_kind[kind] ] if by_kind[kind].present? + end + end +end diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb index 28d103eb52..07d17078d8 100644 --- a/app/models/oauth/client.rb +++ b/app/models/oauth/client.rb @@ -73,6 +73,21 @@ def redeem(authorization_code, identity:, permission:) end end + # What kind of app this is, as this server sees it rather than as it names + # itself: Connected Apps groups by it. Our own apps are the trusted ones. An + # app whose every callback stays on this device, a loopback address or an + # app's own URL scheme, is a tool running here, such as a CLI or a desktop + # assistant. Anything else receives its access somewhere on the web. + def connection_kind + if trusted? + :fizzy + elsif redirect_uris.all? { |uri| on_device_uri?(uri) } + :local + else + :web + end + end + def confidential? token_endpoint_auth_method.in?(SECRET_AUTH_METHODS) end @@ -133,6 +148,12 @@ def validate_redirect_uri(uri) errors.add :redirect_uris, "includes an invalid URI" end + def on_device_uri?(uri) + loopback_uri?(uri) || !URI.parse(uri).scheme.to_s.downcase.in?(%w[ http https ]) + rescue URI::InvalidURIError + false + end + def loopback_uri?(uri) Oauth.loopback_host?(URI.parse(uri).host) rescue URI::InvalidURIError diff --git a/app/views/my/connected_apps/_connected_app.html.erb b/app/views/my/connected_apps/_connected_app.html.erb index 3c8b2a2f35..46c4e57ec0 100644 --- a/app/views/my/connected_apps/_connected_app.html.erb +++ b/app/views/my/connected_apps/_connected_app.html.erb @@ -1,44 +1,72 @@ - - <%= client.name %> - <%= tokens.map { |t| t.permission.humanize }.uniq.sort.join(", ") %> - <%= local_datetime_tag tokens.map(&:created_at).min, style: :datetime %> - -
- - - -

Disconnect <%= client.name %>?

-

This will revoke all of its access.

- - <%= form_with url: my_connected_app_path(client), method: :delete, class: "flex flex-column gap" do |form| %> - <% if webhooks.any? %> -
-

It set up <%= webhooks.one? ? "this webhook" : "these webhooks" %>, which will keep running unless you remove <%= webhooks.one? ? "it" : "them" %>:

-
    - <% webhooks.each do |webhook| %> -
  • - <%= webhook.name %> on <%= webhook.board.name %><%= " in #{webhook.account.name}" unless webhook.account == Current.account %> - <%= hidden_field_tag "webhook_ids[]", webhook.id, id: nil %> -
  • - <% end %> -
- - -
- <% end %> - -
- - <%= form.button "Disconnect", class: "btn txt-negative" %> +<% redirect_host = client.dynamically_registered? ? oauth_redirect_host(client.redirect_uris.first) : nil %> +<% permission = tokens.any?(&:write?) ? "write" : "read" %> + +
+
+ <%# A self-registered app goes by the name it gave itself, since that's the + name people know it by, but never without where its access goes %> +

<%= oauth_client_self_name(client) %>

+ + <% if client.dynamically_registered? %> +

+ <% if redirect_host %> + Name set by the app at <%= redirect_host %> + <% else %> + Name set by an app on your device + <% end %> +

+ <% elsif client.trusted? %> +

Made by 37signals

+ <% end %> + +

+ <%= oauth_access_label(permission) %>, in every account you belong to +

+ + <%# updated_at moves each time the app renews its access, so it's the last + time the app was active, to within an access token's lifetime %> +

+ Connected <%= local_datetime_tag tokens.map(&:created_at).min, style: :date %> + · last active <%= local_datetime_tag tokens.map(&:updated_at).max, style: :daysago %> + <% if tokens.many? %>· <%= pluralize(tokens.size, "connection") %><% end %> +

+
+ +
+ + + +

Disconnect <%= oauth_client_self_name(client) %>?

+

It loses its access to Fizzy right away. To use it again, you'd connect it again.

+ + <%= form_with url: my_connected_app_path(client), method: :delete, class: "flex flex-column gap" do |form| %> + <% if webhooks.any? %> +
+

It set up <%= webhooks.one? ? "this webhook" : "these webhooks" %>, which will keep running unless you remove <%= webhooks.one? ? "it" : "them" %>:

+
    + <% webhooks.each do |webhook| %> +
  • + <%= webhook.name %> on <%= webhook.board.name %><%= " in #{webhook.account.name}" unless webhook.account == Current.account %> + <%= hidden_field_tag "webhook_ids[]", webhook.id, id: nil %> +
  • + <% end %> +
+ +
<% end %> -
-
- - + +
+ + <%= form.button "Disconnect", class: "btn txt-negative" %> +
+ <% end %> +
+
+ diff --git a/app/views/my/connected_apps/index.html.erb b/app/views/my/connected_apps/index.html.erb index 49cb77e80b..83eca19f0a 100644 --- a/app/views/my/connected_apps/index.html.erb +++ b/app/views/my/connected_apps/index.html.erb @@ -12,25 +12,23 @@

<%= @page_title %>

<% end %> -
+
<% if @connected_apps.any? %> -

Apps you've authorized to access Fizzy on your behalf.

- - - - - - - - - - - <% @connected_apps.each do |client, tokens| %> +

Apps you've authorized to use Fizzy as you. Each one can reach what you can, with the access shown. Don't recognize one? Disconnect it, and its access ends right away.

+ + <% connected_app_groups(@connected_apps).each do |kind, heading, apps| %> +
"> +

" class="divider txt-medium margin-none"><%= heading %>

+ + <% apps.each do |client, tokens| %> <%= render "my/connected_apps/connected_app", client: client, tokens: tokens, webhooks: @webhooks_by_client.fetch(client.id, []) %> <% end %> -
-
AppPermissionsConnected
+
+ <% end %> <% else %> -

No apps are connected to your account. When you authorize an app to access Fizzy, it will appear here.

+
+

Nothing connected yet.

+

AI assistants and other apps you connect to Fizzy show up here, so you can see what each one can do and disconnect it whenever you like.

+
<% end %>
diff --git a/app/views/oauth/authorizations/_access.html.erb b/app/views/oauth/authorizations/_access.html.erb new file mode 100644 index 0000000000..9cd372c0c9 --- /dev/null +++ b/app/views/oauth/authorizations/_access.html.erb @@ -0,0 +1,24 @@ +<%# Fizzy's two access levels, each in plain words. Write includes read, so + the choice is one or the other; a client registered for read alone gets + the single option, shown so the person still sees what they're granting. %> +
+ Access + + <% oauth_access_levels(client).each do |value, level| %> + + <% end %> + +

+ <% if local_assigns[:one_account] %> + It works as you, so it can only reach what you can, in the account above. + <% else %> + It works as you, so it can only reach what you can, in every account you belong to. + <% end %> +

+
diff --git a/app/views/oauth/authorizations/_account_picker.html.erb b/app/views/oauth/authorizations/_account_picker.html.erb new file mode 100644 index 0000000000..f1372a0f5a --- /dev/null +++ b/app/views/oauth/authorizations/_account_picker.html.erb @@ -0,0 +1,22 @@ +<%# Which account this connection reaches. The controller sets accounts (the + identity's accounts) and the preselected one; the choice posts as + account_id. With one account there is nothing to choose, so it's named, + and carried in a hidden field. %> +
+ Account + + <% if accounts.one? %> + <% account = accounts.first %> + <%= form.hidden_field :account_id, value: account.id %> +

<%= account.name %>

+ <% else %> + <% accounts.each_with_index do |account, index| %> + + <% end %> + <% end %> + +

The app reaches this account only. Connect it again to add another.

+
diff --git a/app/views/oauth/authorizations/_client.html.erb b/app/views/oauth/authorizations/_client.html.erb new file mode 100644 index 0000000000..672455d0b2 --- /dev/null +++ b/app/views/oauth/authorizations/_client.html.erb @@ -0,0 +1,28 @@ +<%# Who is asking, as far as Fizzy can vouch for it. A self-registered app's + name is only its claim, so it never stands alone: it's shown in quotes, + subdued, beside where the access goes (bc3's "name set by" line). bdi keeps + any right-to-left letters in the name from moving the quotes and host. %> +
+

<%= client.dynamically_registered? && redirect_host.nil? ? identity.upcase_first : identity %>

+ + <% if client.dynamically_registered? %> +

+ “<%= oauth_client_self_name(client) %>” · + <% if redirect_host %> + name set by the app at <%= redirect_host %> + <% else %> + name set by an app running on your device + <% end %> +

+ +

+ <% if redirect_host %> + Fizzy hasn't checked this app. Anyone can run an app at any address, so only authorize it if you trust <%= redirect_host %>. + <% else %> + Fizzy hasn't checked this app. An app running on your device will get this access, so only authorize it if you started it. + <% end %> +

+ <% elsif client.trusted? %> +

Made by 37signals

+ <% end %> +
diff --git a/app/views/oauth/authorizations/error.html.erb b/app/views/oauth/authorizations/error.html.erb index 3493d9a7c2..40b7bb0413 100644 --- a/app/views/oauth/authorizations/error.html.erb +++ b/app/views/oauth/authorizations/error.html.erb @@ -1,15 +1,15 @@ -
+<%# Shown on Fizzy, never redirected: the request couldn't be trusted enough + to send the browser back to the app (see redirect_with_error) %> +<% @page_title = "Can't authorize this app" %> + + diff --git a/app/views/oauth/authorizations/new.html.erb b/app/views/oauth/authorizations/new.html.erb index 40bd97e190..d6f3d2b05b 100644 --- a/app/views/oauth/authorizations/new.html.erb +++ b/app/views/oauth/authorizations/new.html.erb @@ -1,10 +1,18 @@ -<% @page_title = "Authorize #{@client.name}" %> +<% identity = oauth_client_display_identity(@client, @redirect_uri) %> +<% redirect_host = oauth_redirect_host(@redirect_uri) %> +<% @page_title = "Authorize #{identity}" %> <% content_for :header do %>

<%= @page_title %>

<% end %> -
+