Compare commits

...
Author SHA1 Message Date
raucao 30e45d3d34 Address second-round review nits in the storage dashboard
CI / Test (pull_request) Successful in 43s
Show the union of the app's authorizations' scopes in the modal's Access
section too, so it matches the card's summary, and cover the union
summary with a feature spec.

Part of #254
2026-11-03 12:01:56 +04:00
raucao 27168dde06 Address review nits in the storage dashboard
- Move the app display name and a root-access helper onto
  RemoteStorageAuthorization, used by both components and the controller,
  removing the duplicated fallback chain and the unused OauthHelper includes
- Summarise the card's categories from the union of the app's authorizations'
  scopes, so an older token with broader access is not understated

Part of #254
2026-11-03 12:00:40 +04:00
raucao f2248ea744 Rework storage dashboard and fix authorization revocation
- One card per app (grouped by client_id) showing distinct device and
  category counts; the whole card opens the details modal (with hover and
  focus highlight) instead of a separate Details button
- Details modal: access list styled like the OAuth consent dialog via a
  shared RsScopeListComponent, plus per-token device, connected date and
  expiry, each with its own Revoke
- "folder" -> "category" in the card, modal, consent dialog and the
  auth-created notification
- Make revoke a real DELETE: it was a GET link that Turbo 8's
  prefetch-on-hover fired on its own, silently deleting authorizations.
  Missing or foreign authorizations now return 404 instead of 500.
- Move "Launch app" to the app level
  (GET /services/storage/apps/:client_id/launch); no token id in the URL

Part of #254
2026-11-03 12:00:39 +04:00
raucao c4df1772e0 Determine device names with device_detector
Replace the hand-rolled user-agent regexes with the device_detector gem.
Its UA database recognises many more clients (and, e.g., GNOME Web when
its UA carries the Epiphany token). WebKit "Safari" user agents on Linux
(WebKitGTK, which GNOME Web sends) are labelled "WebKit on Linux", since
Safari never runs on Linux.

Part of #254
2026-11-03 12:00:39 +04:00
raucao c1548035ec Add storage dashboard card and per-app details modal
- Rework RsAuthComponent into a card that shows the app, its device, an
  access summary and the permission status
- Group connected apps by app and device, so repeated authorizations for
  the same app and device (e.g. after a browser update) are shown together,
  each with its own revoke action
- Add RsAuthDetailsModalComponent with the full scope list, device,
  connected tokens and permission expiry, plus Launch and Revoke actions
  (built on the existing ModalComponent)
- Add RemoteStorageAuthorization#device_name (parsed from the user agent)
  and #expired?

Part of #254
2026-11-03 12:00:38 +04:00
raucao 3dcd263d43 Store the remoteStorage user agent as text, not varchar(255)
CI / Test (pull_request) Failing after 45s
Real-world user agents (in-app browsers, Android WebView variants) commonly
exceed 255 characters. On PostgreSQL that would raise and 500 the consent
POST and token exchange; SQLite dev/test does not enforce the limit, so it
went unnoticed. Use text columns for user_agent on both tables.

Part of #254
2026-11-03 11:59:52 +04:00
raucao eeb399ff83 Document the storage-first launch flow
Add an explicit spec for the storage-first handoff (remoteStorage spec
section 11): launching an app passes only `#remotestorage=<address>`,
never an access token. The app then runs the application-first
(now PKCE-capable) flow itself.

Part of #254
2026-11-03 11:59:51 +04:00
raucao 7c4bad860a Store per-device user agent for remoteStorage authorizations
- Add user_agent to remote_storage_authorizations and rs_authorization_codes
- Capture the browser's user agent at consent, carry it on the authorization
  code, and store it on the authorization when the code is exchanged
- Store it directly for the implicit flow too

With the reuse shortcut gone, each authorization is already a distinct
per-device token; this adds the metadata needed to label and display them.

Part of #254
2026-11-03 11:59:51 +04:00
raucao cd2b4c9ba5 Address second-round review findings in the PKCE core
CI / Test (pull_request) Failing after 45s
- Enqueue the expiry job and the "app connected" notification on
  after_commit instead of after_create, so they only fire once the (now
  transactional) code exchange commits
- Enforce the PKCE code_challenge format (43-128 unreserved chars, S256)
  with model validations, not only at the controller
- Drop the now-unused RsAuthorizationCode#expired?
- Cover the client_id-vs-stored-client_id check

Part of #254
2026-11-03 11:59:12 +04:00
raucao 295261b332 Address review findings in the PKCE flow
CI / Test (pull_request) Failing after 46s
- Make authorization-code redemption atomic: claim the code with a guarded
  delete inside the transaction that creates the authorization, so concurrent
  exchanges can't both issue a token (RFC 6749 4.1.3)
- Compare the token request's client_id host to the code's stored client_id
  (defense-in-depth alongside the exact redirect_uri match)
- Reject unknown response_type values with unsupported_response_type
- Validate the PKCE code_challenge (43-128 unreserved chars, S256) so a client
  can't persist an over-long or malformed challenge
- Return invalid_request for missing token-endpoint parameters, and set
  Cache-Control: no-store on error responses too
- Use the right separator when the redirect_uri already has a query string
- Look codes up through the active scope; drop the unused consume!; serialize
  permissions as JSON
- Purge abandoned authorization codes via a recurring Solid Queue task

Part of #254
2026-11-03 11:40:18 +04:00
raucao 1e69957777 Shorten the authorization code lifetime to 60 seconds
CI / Test (pull_request) Failing after 45s
The code is redeemed by the client immediately in the OAuth callback, so
a 60-second window is plenty (and matches common practice; RFC 6749 caps
authorization codes at 10 minutes).

Part of #254
2026-11-03 09:17:44 +04:00
raucao 8dd0bf10ce Add PKCE support to remoteStorage OAuth
Implements the optional OAuth 2.0 Authorization Code grant with PKCE
(RFC 7636) for remoteStorage, as described in the remoteStorage spec
(section 10.1).

- Advertise the authorization and token endpoints plus the supported
  code_challenge method (S256) in WebFinger, and bump the advertised
  spec version to draft-dejong-remotestorage-27
- Add RsAuthorizationCode: a short-lived (5 min), single-use authorization
  code stored only as a SHA256 digest
- Add RsOauthManager services for issuing and exchanging codes, including
  S256 verification
- Add a cross-origin POST /rs/oauth/token endpoint that exchanges a code
  (and code_verifier) for an access token, with CORS and no CSRF token
- Consent dialog now carries response_type / code_challenge through the
  form and redirects with the code in the query string for the code flow
- Remove the existing-token reuse shortcut so every authorization results
  in a distinct authorization (and token)

Part of #254
2026-11-03 09:17:43 +04:00
36 changed files with 1516 additions and 152 deletions

No files matched your search

+1
View File
@@ -43,6 +43,7 @@ gem 'flipper-ui'
gem 'gpgme', '~> 2.0.24'
gem 'zbase32', '~> 0.1.1'
gem 'kramdown'
gem 'device_detector'
# HTTP requests
gem 'faraday'
+2
View File
@@ -136,6 +136,7 @@ GEM
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.5.1)
device_detector (1.1.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
@@ -543,6 +544,7 @@ DEPENDENCIES
capybara
cssbundling-rails
database_cleaner
device_detector
devise (~> 5.0)
devise_ldap_authenticatable
discourse_api
+27 -26
View File
@@ -1,27 +1,28 @@
<div class="flex items-center gap-4">
<div class="h-16 w-16 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</div>
<div class="flex-grow">
<h4 class="mb-1 text-lg font-bold">
<%= @web_app&.name || @auth.app_name %>
</h4>
<p class="text-sm text-gray-500">
<%= @auth.client_id %>
</p>
</div>
<%= render DropdownComponent.new do %>
<%= render DropdownLinkComponent.new(
href: launch_app_services_storage_rs_auth_url(@auth),
open_in_new_tab: true
) do %>
Launch app
<% end %>
<%= render DropdownLinkComponent.new(
href: revoke_services_storage_rs_auth_url(@auth),
separator: true, add_class: "text-red-700"
) do %>
Revoke access
<% end %>
<% end %>
<div class="h-full" data-controller="modal" data-action="keydown.esc->modal#close">
<button type="button"
class="rs-auth-card group flex h-full w-full items-center gap-4 rounded-lg border
border-gray-200 bg-white p-5 text-left transition
hover:border-gray-400 hover:bg-gray-50
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
data-action="click->modal#open">
<div class="h-14 w-14 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</div>
<div class="min-w-0 flex-grow">
<h4 class="text-lg font-bold truncate"><%= @auth.display_name %></h4>
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
<p class="mt-2 text-sm text-gray-600">
<%= device_summary %>
<span class="mx-1 text-gray-300">·</span>
<%= category_summary %>
</p>
</div>
<span class="flex-none text-gray-300 transition group-hover:text-gray-500" aria-hidden="true">
<%= render partial: "icons/chevron-right", locals: { custom_class: "h-5 w-5" } %>
</span>
</button>
<%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %>
</div>
+29 -3
View File
@@ -1,8 +1,34 @@
# frozen_string_literal: true
class RsAuthComponent < ViewComponent::Base
def initialize(auth:)
@auth = auth
@web_app = auth.web_app
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
# The card summarises the app, so reflect the union of its authorizations'
# scopes (an older token may have broader access than the newest one).
def permissions
@authorizations.flat_map(&:permissions).uniq
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def category_summary
return "All categories and files" if root_access?
count = permissions.size
"#{count} #{'category'.pluralize(count)}"
end
def device_count
@authorizations.map { |auth| auth.device_name.presence || "Unknown device" }.uniq.size
end
def device_summary
"#{device_count} #{'device'.pluralize(device_count)}"
end
end
@@ -0,0 +1,42 @@
<%= render ModalComponent.new do %>
<div class="flex items-center gap-4">
<div class="h-14 w-14 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</div>
<div class="min-w-0">
<h4 class="text-lg font-bold truncate"><%= @auth.display_name %></h4>
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
</div>
</div>
<h5 class="mt-6 mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Access
</h5>
<%= render RsScopeListComponent.new(scopes: permissions, root: root_access?) %>
<h5 class="mt-6 mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Connected devices
</h5>
<ul class="divide-y divide-gray-100 rounded border border-gray-200" data-turbo-prefetch="false">
<% @authorizations.each do |auth| %>
<li class="flex items-center justify-between gap-4 px-4 py-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-gray-900"><%= device_name(auth) %></p>
<p class="truncate text-xs text-gray-500">
Connected <%= auth.created_at.strftime('%-d %b %Y') %>
<span class="mx-1">&middot;</span>
<span class="<%= "text-red-700" if auth.expired? %>"><%= status_label(auth) %></span>
</p>
</div>
<%= button_to "Revoke access", services_storage_rs_auth_path(auth), method: :delete,
form: { class: "flex-none" },
class: "whitespace-nowrap text-sm text-red-700 hover:underline bg-transparent border-0 p-0 cursor-pointer" %>
</li>
<% end %>
</ul>
<div class="mt-6 flex flex-col sm:flex-row gap-2 sm:justify-end">
<%= link_to "Launch app", launch_app_services_storage_path(@auth.client_id),
class: "btn-md btn-blue", target: "_blank", rel: "noopener", data: { turbo: false } %>
</div>
<% end %>
@@ -0,0 +1,29 @@
# frozen_string_literal: true
class RsAuthDetailsModalComponent < ViewComponent::Base
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
# Reflect the union of the app's scopes, matching the card's summary.
def permissions
@authorizations.flat_map(&:permissions).uniq
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def device_name(auth)
auth.device_name.presence || "Unknown device"
end
def status_label(auth)
return "Never expires" if auth.expire_at.blank?
return "Expired on #{auth.expire_at.strftime('%-d %b %Y')}" if auth.expired?
"Expires #{auth.expire_at.strftime('%-d %b %Y')}"
end
end
@@ -0,0 +1,25 @@
<% if @root %>
<p class="scope text-lg">
<span class="text-red-700">
<%= render partial: "icons/alert-triangle",
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
All categories and files
</span>
<% if read_only? %>
<span class="text-sm text-gray-500">(read only)</span>
<% end %>
</p>
<% else %>
<% @scopes.each do |scope| %>
<p class="scope text-gray-600">
<span class="text-lg">
<%= render partial: "icons/folder",
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
<%= scope_name(scope) %>
</span>
<% if scope_permissions(scope) == "r" %>
<span>(read only)</span>
<% end %>
</p>
<% end %>
<% end %>
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class RsScopeListComponent < ViewComponent::Base
include OauthHelper
def initialize(scopes:, root: false)
@scopes = scopes
@root = root
end
def read_only?
@scopes.include?(":r")
end
end
+144 -42
View File
@@ -1,7 +1,10 @@
class Rs::OauthController < ApplicationController
skip_before_action :verify_authenticity_token, only: :token
prepend_before_action :assert_redirect_uri, only: [:new, :create]
before_action :require_signed_in_with_username, only: :new
before_action :authenticate_user!, only: :create
before_action :set_cors_headers, only: :token
def new
@user = User.where(cn: params[:username].downcase, ou: Setting.primary_domain).first
@@ -9,9 +12,12 @@ class Rs::OauthController < ApplicationController
@redirect_uri = params[:redirect_uri]
@client_id = params[:client_id]
@state = params[:state]
@response_type = params[:response_type].presence || "token"
@code_challenge = params[:code_challenge]
@code_challenge_method = params[:code_challenge_method].presence || "S256"
@root_access_requested = (@scopes & [":r",":rw"]).any?
@denial_url = url_with_state("#{@redirect_uri}#error=access_denied", @state)
@denial_url = error_url(@redirect_uri, "error=access_denied", @state)
@expire_at_dates = [["Never", nil],
["In 1 month", 1.month.from_now],
@@ -20,35 +26,45 @@ class Rs::OauthController < ApplicationController
unless current_user == @user
sign_out :user
redirect_to new_rs_oauth_url(@user.cn,
scope: params[:scope],
redirect_uri: params[:redirect_uri],
client_id: params[:client_id],
state: params[:state])
redirect_params = {
scope: params[:scope],
redirect_uri: params[:redirect_uri],
client_id: params[:client_id],
state: params[:state]
}
if @response_type == "code"
redirect_params[:response_type] = "code"
redirect_params[:code_challenge] = @code_challenge
redirect_params[:code_challenge_method] = @code_challenge_method
end
redirect_to new_rs_oauth_url(@user.cn, redirect_params)
return
end
unless @client_id.present?
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_request", @state),
allow_other_host: true) and return
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
end
if @scopes.empty?
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_scope", @state),
allow_other_host: true) and return
error_redirect(@redirect_uri, "error=invalid_scope", @state) and return
end
unless hostname_of(@client_id) == hostname_of(@redirect_uri)
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_client", @state),
allow_other_host: true) and return
error_redirect(@redirect_uri, "error=invalid_client", @state) and return
end
unless %w[token code].include?(@response_type)
error_redirect(@redirect_uri, "error=unsupported_response_type", @state) and return
end
if @response_type == "code" &&
!valid_code_challenge?(@code_challenge, @code_challenge_method)
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
end
@client_id.gsub!(/http(s)?:\/\//, "")
if auth = current_user.remote_storage_authorizations.valid.where(permissions: @scopes, client_id: @client_id).first
redirect_to(url_with_state("#{@redirect_uri}#access_token=#{auth.token}", @state),
allow_other_host: true) and return
end
end
def create
@@ -57,6 +73,7 @@ class Rs::OauthController < ApplicationController
http_status :forbidden and return
end
response_type = params[:response_type].presence || "token"
permissions = parse_scopes params[:scope]
redirect_uri = params[:redirect_uri].presence
client_id = params[:client_id].presence
@@ -64,32 +81,81 @@ class Rs::OauthController < ApplicationController
expire_at = params[:expire_at].presence
if permissions.empty?
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
allow_other_host: true) and return
error_redirect(redirect_uri, "error=invalid_scope", state, response_type) and return
end
unless client_id.present?
redirect_to(url_with_state("#{redirect_uri}#error=invalid_request", state),
allow_other_host: true) and return
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
end
unless hostname_of(client_id) == hostname_of(redirect_uri)
redirect_to(url_with_state("#{redirect_uri}#error=invalid_client", state),
allow_other_host: true) and return
error_redirect(redirect_uri, "error=invalid_client", state, response_type) and return
end
unless %w[token code].include?(response_type)
error_redirect(redirect_uri, "error=unsupported_response_type", state, response_type) and return
end
client_id.gsub!(/http(s)?:\/\//, "")
auth = current_user.remote_storage_authorizations.create!(
permissions: permissions,
client_id: client_id,
redirect_uri: redirect_uri,
app_name: client_id,
expire_at: expire_at
if response_type == "code"
challenge = params[:code_challenge].presence
method = params[:code_challenge_method].presence || "S256"
unless valid_code_challenge?(challenge, method)
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
end
code = RsOauthManager::IssueCode.call(
user: current_user,
client_id: client_id,
redirect_uri: redirect_uri,
permissions: permissions,
expire_at: expire_at,
user_agent: request.user_agent,
code_challenge: challenge,
code_challenge_method: method
)
redirect_to url_with_state("#{redirect_uri}#{query_separator(redirect_uri)}code=#{CGI.escape(code.code)}", state),
allow_other_host: true
else
auth = current_user.remote_storage_authorizations.create!(
permissions: permissions,
client_id: client_id,
redirect_uri: redirect_uri,
app_name: client_id,
expire_at: expire_at,
user_agent: request.user_agent
)
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
allow_other_host: true
end
end
def token
return head :ok if request.options?
unless params[:grant_type] == "authorization_code"
return render_token_error("unsupported_grant_type")
end
if params[:code].blank? || params[:code_verifier].blank? ||
params[:client_id].blank? || params[:redirect_uri].blank?
return render_token_error("invalid_request")
end
auth = RsOauthManager::ExchangeCode.call(
code: params[:code],
client_id: params[:client_id],
redirect_uri: params[:redirect_uri],
code_verifier: params[:code_verifier]
)
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
allow_other_host: true
render_token(token_response(auth))
rescue RsOauthManagerService::InvalidGrant
render_token_error("invalid_grant")
end
private
@@ -105,24 +171,60 @@ class Rs::OauthController < ApplicationController
end
end
def set_cors_headers
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
headers["Access-Control-Allow-Headers"] = "Content-Type"
end
def token_response(auth)
body = {
access_token: auth.token,
token_type: "bearer",
scope: auth.permissions.join(" ")
}
if auth.expire_at.present?
body[:expires_in] = (auth.expire_at - Time.current).to_i
end
body
end
def hostname_of(uri)
uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
RsOauthManagerService.hostname_of(uri)
end
def parse_scopes(scope_string)
return [] if scope_string.blank?
RsOauthManagerService.parse_scopes(scope_string)
end
scopes = scope_string.
gsub(/\[|\]/, "").
gsub(/\,/, " ").
gsub(/\/:/, ":").
split(/\s/).map(&:strip).
reject(&:empty?)
def error_redirect(redirect_uri, error, state, response_type = @response_type)
redirect_to error_url(redirect_uri, error, state, response_type),
allow_other_host: true
end
scopes = [":r"] if scopes.include?("*:r")
scopes = [":rw"] if scopes.include?("*:rw")
def error_url(redirect_uri, error, state, response_type = @response_type)
separator = response_type == "code" ? query_separator(redirect_uri) : "#"
url_with_state("#{redirect_uri}#{separator}#{error}", state)
end
scopes
def query_separator(uri)
uri.to_s.include?("?") ? "&" : "?"
end
def valid_code_challenge?(challenge, method)
method == "S256" && challenge.to_s.match?(/\A[A-Za-z0-9\-._~]{43,128}\z/)
end
def render_token(body)
response.headers["Cache-Control"] = "no-store"
render json: body
end
def render_token_error(error)
response.headers["Cache-Control"] = "no-store"
render json: { error: error }, status: :bad_request
end
def url_with_state(url, state)
@@ -2,11 +2,15 @@ class Services::RsAuthsController < Services::BaseController
before_action :authenticate_user!
before_action :require_service_enabled
before_action :require_service_available
before_action :find_rs_auth, only: [:destroy, :launch_app]
before_action :find_rs_auth, only: [:destroy]
def index
@rs_auths = current_user.remote_storage_authorizations
# TODO sort by app name?
@rs_apps = current_user.remote_storage_authorizations
.includes(:web_app)
.group_by(&:client_id)
.values
.map { |auths| auths.sort_by(&:created_at).reverse }
.sort_by { |auths| app_name_for(auths.first).downcase }
end
def destroy
@@ -22,6 +26,12 @@ class Services::RsAuthsController < Services::BaseController
end
def launch_app
auth = current_user.remote_storage_authorizations
.where(client_id: params[:client_id])
.order(created_at: :desc)
.first
http_status(:not_found) and return unless auth
user_address =
if Rails.env.development?
"#{current_user.cn}@#{request.host_with_port}"
@@ -29,13 +39,19 @@ class Services::RsAuthsController < Services::BaseController
current_user.address
end
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
# Storage-first bearer token issuance (remoteStorage spec §11): the app
# receives the address and runs the application-first flow itself.
launch_url = "#{auth.launch_url}#remotestorage=#{user_address}"
redirect_to launch_url, allow_other_host: true
end
private
def app_name_for(auth)
auth.display_name
end
def require_service_enabled
unless current_user.service_enabled?(:remotestorage)
http_status :forbidden
@@ -47,7 +63,7 @@ class Services::RsAuthsController < Services::BaseController
end
def find_rs_auth
@auth = current_user.remote_storage_authorizations.find(params[:id])
http_status :not_found unless @auth.present?
@auth = current_user.remote_storage_authorizations.find_by(id: params[:id])
http_status :not_found unless @auth
end
end
+10 -1
View File
@@ -101,8 +101,11 @@ class WebfingerController < WellKnownController
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
href: storage_url,
properties: {
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-27",
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
"http://tools.ietf.org/html/rfc6749#section-3.1" => auth_url,
"http://tools.ietf.org/html/rfc6749#section-3.2" => token_endpoint_url,
"http://tools.ietf.org/html/rfc7636" => "S256",
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter
"http://tools.ietf.org/html/rfc7233": "GET", # content range requests
"http://remotestorage.io/spec/web-authoring": nil
@@ -110,6 +113,12 @@ class WebfingerController < WellKnownController
}
end
def token_endpoint_url
return rs_oauth_token_url if Rails.env.development?
rs_oauth_token_url(host: Setting.rs_accounts_domain)
end
def remotestorage_storage_base_url
return Setting.rs_storage_url unless Rails.env.development?
+1 -1
View File
@@ -11,7 +11,7 @@ class NotificationMailer < ApplicationMailer
@auth = params[:auth]
@permissions = @auth.permissions.map do |p|
access = p.split(":")[1] == 'r' ? 'read' : 'read/write'
directory = p.split(':')[0] == '' ? 'all folders and files' : p.split(':')[0]
directory = p.split(':')[0] == '' ? 'all categories and files' : p.split(':')[0]
"#{access} #{directory}"
end
@subject = "New app connected to your storage"
+30 -2
View File
@@ -17,8 +17,8 @@ class RemoteStorageAuthorization < ApplicationRecord
before_create :generate_token
before_create :store_token_in_redis
before_create :find_or_create_web_app
after_create :schedule_token_expiry
after_create :notify_user
after_commit :schedule_token_expiry, on: :create
after_commit :notify_user, on: :create
before_destroy :delete_token_from_redis
after_destroy :remove_token_expiry_job
@@ -40,6 +40,34 @@ class RemoteStorageAuthorization < ApplicationRecord
end
end
def expired?
expire_at.present? && expire_at.past?
end
def device_name
return nil if user_agent.blank?
client = DeviceDetector.new(user_agent)
name = client.name.presence
os = client.os_name.presence
os = "Linux" if os == "GNU/Linux"
os = "macOS" if os == "Mac"
# Safari never runs on Linux. A WebKit "Safari" user agent on Linux is a
# WebKitGTK browser (e.g. GNOME Web), which sends a Safari-compatible UA.
name = "WebKit" if name == "Safari" && os == "Linux"
[name, os].compact.join(" on ").presence
end
def display_name
web_app&.name.presence || app_name.presence || client_id
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def delete_token_from_redis
key = "authorizations:#{user.cn}:#{token}"
redis.srem? key, redis.smembers(key)
+33
View File
@@ -0,0 +1,33 @@
class RsAuthorizationCode < ApplicationRecord
belongs_to :user
CODE_TTL = 60.seconds
serialize :permissions, coder: JSON
attr_accessor :code
before_validation :generate_code
validates :code_challenge, presence: true,
format: { with: /\A[A-Za-z0-9\-._~]{43,128}\z/ }
validates :code_challenge_method, inclusion: { in: %w[S256] }
scope :active, -> { where(expires_at: Time.current..) }
def self.find_by_code(raw_code)
return nil if raw_code.blank?
active.find_by(code_digest: Digest::SHA256.hexdigest(raw_code))
end
private
def generate_code
return if code_digest.present?
self.code = SecureRandom.urlsafe_base64(32)
self.code_digest = Digest::SHA256.hexdigest(code)
self.expires_at ||= CODE_TTL.from_now
end
end
@@ -0,0 +1,47 @@
module RsOauthManager
class ExchangeCode < RsOauthManagerService
def initialize(code:, client_id:, redirect_uri:, code_verifier:)
@code = code
@client_id = client_id
@redirect_uri = redirect_uri
@code_verifier = code_verifier
end
def call
auth_code = RsAuthorizationCode.find_by_code(@code)
raise InvalidGrant if auth_code.nil?
raise InvalidGrant unless hostname_of(@client_id) == hostname_of(@redirect_uri)
raise InvalidGrant unless hostname_of(@client_id) == hostname_of(auth_code.client_id)
raise InvalidGrant unless @redirect_uri == auth_code.redirect_uri
raise InvalidGrant unless VerifyPkce.call(
code_challenge: auth_code.code_challenge,
code_challenge_method: auth_code.code_challenge_method,
code_verifier: @code_verifier
)
user = auth_code.user
auth = nil
# Claim the code atomically so a concurrent redemption can't issue a
# second token. The whole exchange is one transaction: if creating the
# authorization fails, the code is not consumed and can be retried.
RsAuthorizationCode.transaction do
claimed = RsAuthorizationCode.where(id: auth_code.id).delete_all
raise InvalidGrant if claimed != 1
auth = user.remote_storage_authorizations.create!(
permissions: auth_code.permissions,
client_id: auth_code.client_id,
redirect_uri: auth_code.redirect_uri,
app_name: auth_code.client_id,
expire_at: auth_code.permission_expire_at,
user_agent: auth_code.user_agent
)
end
auth
end
end
end
@@ -0,0 +1,29 @@
module RsOauthManager
class IssueCode < RsOauthManagerService
def initialize(user:, client_id:, redirect_uri:, permissions:,
code_challenge:, code_challenge_method: "S256",
expire_at: nil, user_agent: nil)
@user = user
@client_id = client_id
@redirect_uri = redirect_uri
@permissions = permissions
@code_challenge = code_challenge
@code_challenge_method = code_challenge_method
@expire_at = expire_at
@user_agent = user_agent
end
def call
RsAuthorizationCode.create!(
user: @user,
client_id: @client_id,
redirect_uri: @redirect_uri,
permissions: @permissions,
permission_expire_at: @expire_at,
user_agent: @user_agent,
code_challenge: @code_challenge,
code_challenge_method: @code_challenge_method
)
end
end
end
@@ -0,0 +1,21 @@
module RsOauthManager
class VerifyPkce < RsOauthManagerService
def initialize(code_challenge:, code_challenge_method:, code_verifier:)
@code_challenge = code_challenge
@code_challenge_method = code_challenge_method
@code_verifier = code_verifier.to_s
end
def call
return false unless @code_challenge_method == "S256"
expected = Base64.urlsafe_encode64(
Digest::SHA256.digest(@code_verifier), padding: false
)
ActiveSupport::SecurityUtils.secure_compare(
expected, @code_challenge.to_s
)
end
end
end
+27
View File
@@ -0,0 +1,27 @@
class RsOauthManagerService < ApplicationService
InvalidGrant = Class.new(StandardError)
def self.parse_scopes(scope_string)
return [] if scope_string.blank?
scopes = scope_string.
gsub(/\[|\]/, "").
gsub(/\,/, " ").
gsub(/\/:/, ":").
split(/\s/).map(&:strip).
reject(&:empty?)
scopes = [":r"] if scopes.include?("*:r")
scopes = [":rw"] if scopes.include?("*:rw")
scopes
end
def self.hostname_of(uri)
uri.to_s.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
end
def hostname_of(uri)
self.class.hostname_of(uri)
end
end
+5 -26
View File
@@ -5,34 +5,10 @@
<p class="mb-8">
The app on
<%= link_to @client_id, "https://#{@client_id}" %>
is asking for access to these folders:
is asking for access to these categories:
</p>
<% if @root_access_requested %>
<p class="scope text-lg">
<span class="text-red-700">
<%= render partial: "icons/alert-triangle",
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
All files and directories
</span>
<% if (@scopes & [":r"]).any? %>
<span class="text-sm text-gray-500">(read only)</span>
<% end %>
</p>
<% else %>
<% @scopes.each do |scope| %>
<p class="scope text-gray-600">
<span class="text-lg">
<%= render partial: "icons/folder",
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
<%= scope_name(scope) %>
</span>
<% if scope_permissions(scope) == "r" %>
<span>(read only)</span>
<% end %>
</p>
<% end %>
<% end %>
<%= render RsScopeListComponent.new(scopes: @scopes, root: @root_access_requested) %>
<%= form_with(url: rs_oauth_path, method: :post, data: { turbo: false }) do |f| %>
<%= f.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -40,6 +16,9 @@
<%= f.hidden_field :user_id, value: @user.id %>
<%= f.hidden_field :client_id, value: @client_id %>
<%= f.hidden_field :state, value: @state %>
<%= f.hidden_field :response_type, value: @response_type %>
<%= f.hidden_field :code_challenge, value: @code_challenge %>
<%= f.hidden_field :code_challenge_method, value: @code_challenge_method %>
<p class="mt-8 mb-6">
<%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %>
<%= f.select :expire_at, options_for_select(@expire_at_dates) %>
+4 -4
View File
@@ -10,10 +10,10 @@
<%= render partial: "shared/tabnav_remotestorage" %>
<section>
<% if @rs_auths.any? %>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-10 gap-x-12 mt-4">
<% @rs_auths.each do |auth| %>
<%= render RsAuthComponent.new(auth: auth) %>
<% if @rs_apps.any? %>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-4">
<% @rs_apps.each do |authorizations| %>
<%= render RsAuthComponent.new(authorizations: authorizations) %>
<% end %>
</div>
<% else %>
+5
View File
@@ -8,3 +8,8 @@
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
production:
purge_rs_authorization_codes:
command: "RsAuthorizationCode.where(expires_at: ..1.hour.ago).delete_all"
schedule: every hour
+5 -6
View File
@@ -52,12 +52,9 @@ Rails.application.routes.draw do
resource :storage, controller: 'remotestorage', only: [:show] do
get :apps, to: "rs_auths#index"
resources :rs_auths, only: [:index, :destroy] do
member do
get :revoke, to: 'rs_auths#destroy'
get :launch_app
end
end
get "apps/:client_id/launch", to: "rs_auths#launch_app", as: :launch_app,
constraints: { client_id: /[^\/]+/ }
resources :rs_auths, only: [:index, :destroy]
end
end
@@ -115,6 +112,8 @@ Rails.application.routes.draw do
end
namespace :rs do
post 'oauth/token', to: 'oauth#token', as: :oauth_token
match 'oauth/token', to: 'oauth#token', via: :options
resource :oauth, only: [:new, :create], path_names: {
new: ':username', create: ':username'
}, controller: 'oauth'
@@ -0,0 +1,19 @@
class CreateRsAuthorizationCodes < ActiveRecord::Migration[8.1]
def change
create_table :rs_authorization_codes do |t|
t.references :user, null: false, foreign_key: true
t.string :client_id, null: false
t.string :redirect_uri, null: false
t.text :permissions, default: [].to_json
t.datetime :permission_expire_at
t.string :code_digest, null: false
t.string :code_challenge, null: false
t.string :code_challenge_method, null: false, default: "S256"
t.datetime :expires_at, null: false
t.timestamps
end
add_index :rs_authorization_codes, :code_digest, unique: true
end
end
@@ -0,0 +1,6 @@
class AddUserAgentToRsAuthorizations < ActiveRecord::Migration[8.1]
def change
add_column :remote_storage_authorizations, :user_agent, :text
add_column :rs_authorization_codes, :user_agent, :text
end
end
+20 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -110,6 +110,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
t.string "redirect_uri"
t.string "token"
t.datetime "updated_at", null: false
t.text "user_agent"
t.integer "user_id", null: false
t.integer "web_app_id"
t.index ["permissions"], name: "index_remote_storage_authorizations_on_permissions"
@@ -117,6 +118,23 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
t.index ["web_app_id"], name: "index_remote_storage_authorizations_on_web_app_id"
end
create_table "rs_authorization_codes", force: :cascade do |t|
t.string "client_id", null: false
t.string "code_challenge", null: false
t.string "code_challenge_method", default: "S256", null: false
t.string "code_digest", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.datetime "permission_expire_at"
t.text "permissions", default: "[]"
t.string "redirect_uri", null: false
t.datetime "updated_at", null: false
t.text "user_agent"
t.integer "user_id", null: false
t.index ["code_digest"], name: "index_rs_authorization_codes_on_code_digest", unique: true
t.index ["user_id"], name: "index_rs_authorization_codes_on_user_id"
end
create_table "settings", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -164,5 +182,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "remote_storage_authorizations", "app_catalog_web_apps", column: "web_app_id"
add_foreign_key "remote_storage_authorizations", "users"
add_foreign_key "rs_authorization_codes", "users"
add_foreign_key "zaps", "users"
end
+289 -28
View File
@@ -128,8 +128,10 @@ RSpec.describe Rs::OauthController, type: :controller do
}
end
it "redirects to the redirect_uri with the existing token" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}")
it "does not reuse the existing token (renders the consent dialog)" do
expect(response.response_code).to eq(200)
expect(response.location).to be_nil
expect(user.remote_storage_authorizations.count).to eq(1)
end
end
@@ -147,36 +149,100 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
end
end
end
context "with different redirect_uri" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com/a_new_route",
client_id: "https://example.com"
}
end
it "redirects to the new redirect_uri" do
expect(response).to redirect_to("https://example.com/a_new_route#access_token=#{@auth.token}")
end
context "when response_type is code" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
state: "foobar123"
}
end
context "with state param given" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
end
it "renders the consent dialog and exposes the code challenge" do
expect(response.response_code).to eq(200)
expect(assigns["response_type"]).to eq("code")
expect(assigns["code_challenge"]).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(assigns["code_challenge_method"]).to eq("S256")
end
it "redirects to the redirect_uri with token and state" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
it "uses a query-based denial url" do
expect(assigns["denial_url"]).to eq("https://example.com?error=access_denied&state=foobar123")
end
end
context "when response_type is code without a code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
state: "foobar123"
}
end
it "redirects with invalid_request using a query param" do
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
end
end
context "when response_type is code with the plain method" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
context "with an unsupported response_type" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "id_token"
}
end
it "redirects with unsupported_response_type" do
expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
end
end
context "with a malformed code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "too-short",
code_challenge_method: "S256"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
@@ -280,10 +346,12 @@ RSpec.describe Rs::OauthController, type: :controller do
context "when user is signed in" do
before do
sign_in user
request.user_agent = "RSpec Device"
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
context "when no valid token exists" do
@@ -303,6 +371,10 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r))
end
it "stores the device's user agent" do
expect(@auth.user_agent).to eq("RSpec Device")
end
it "redirects to the redirect_uri" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
@@ -412,6 +484,101 @@ RSpec.describe Rs::OauthController, type: :controller do
end
end
context "with response_type=code" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now
}
@code = RsAuthorizationCode.last
end
it "creates an authorization code" do
expect(@code.permissions).to eq(%w(documents photos contacts:rw))
expect(@code.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(@code.code_challenge_method).to eq("S256")
expect(@code.user_agent).to eq("RSpec Device")
end
it "does not create a remote storage authorization" do
expect(user.reload.remote_storage_authorizations.count).to eq(0)
end
it "redirects with the code in the query string" do
location = response.location
expect(location).to match(%r{\Ahttps://example\.com\?code=.+&state=foobar123\z})
raw_code = CGI.parse(URI.parse(location).query)["code"].first
expect(RsAuthorizationCode.find_by_code(raw_code)).to eq(@code)
end
end
context "with response_type=code but no code_challenge" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
response_type: "code",
expire_at: 1.month.from_now
}
end
it "redirects with invalid_request using a query param" do
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
end
it "does not create an authorization code" do
expect(RsAuthorizationCode.count).to eq(0)
end
end
context "with an unsupported response_type" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
response_type: "id_token",
expire_at: 1.month.from_now
}
end
it "redirects with unsupported_response_type" do
expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
end
end
context "with response_type=code and a redirect_uri that already has a query" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com/?foo=bar",
client_id: "example.com",
state: "foobar123",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now
}
end
it "appends the code with an ampersand" do
expect(response.location).to match(%r{\Ahttps://example\.com/\?foo=bar&code=.+&state=foobar123\z})
end
end
context "when the user_id is different from the signed in user" do
before do
post :create, params: {
@@ -451,4 +618,98 @@ RSpec.describe Rs::OauthController, type: :controller do
end
end
end
describe "POST /rs/oauth/token" do
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
def issue_code
RsOauthManager::IssueCode.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256"
)
end
def token_params(code, **overrides)
{
grant_type: "authorization_code",
code: code.code,
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier
}.merge(overrides)
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
context "with a valid code" do
before do
@code = issue_code
post :token, params: token_params(@code)
end
it "returns an access token" do
expect(response.response_code).to eq(200)
json = JSON.parse(response.body)
expect(json["access_token"]).to be_present
expect(json["token_type"]).to eq("bearer")
expect(json["scope"]).to eq("documents:rw")
end
it "does not cache the response" do
expect(response.headers["Cache-Control"]).to eq("no-store")
end
it "sets CORS headers" do
expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
end
end
context "with a bad code_verifier" do
before do
@code = issue_code
post :token, params: token_params(@code, code_verifier: "wrong-verifier")
end
it "returns invalid_grant" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("invalid_grant")
end
end
context "with an unsupported grant_type" do
before { post :token, params: { grant_type: "password" } }
it "returns unsupported_grant_type" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("unsupported_grant_type")
end
end
context "with missing required parameters" do
before { post :token, params: { grant_type: "authorization_code", code: "x" } }
it "returns invalid_request" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("invalid_request")
end
end
context "on error" do
before { post :token, params: { grant_type: "password" } }
it "does not cache the response" do
expect(response.headers["Cache-Control"]).to eq("no-store")
end
end
end
end
@@ -11,13 +11,13 @@ RSpec.describe Services::RsAuthsController, type: :controller do
])
end
describe "GET /services/storage/rs_auths/:id/launch_app" do
describe "GET /services/storage/apps/:client_id/launch (storage-first)" do
context "when user is signed in" do
before do
sign_in user
end
context "token exists" do
context "authorization exists" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
@@ -25,17 +25,21 @@ RSpec.describe Services::RsAuthsController, type: :controller do
expire_at: 2.days.from_now
)
get :launch_app, params: { id: @auth.id }
get :launch_app, params: { client_id: @auth.client_id }
end
after do
@auth.destroy
end
it "redirects to the given URL with the correct RS URL fragment params" do
it "hands the app the user address in the URL fragment" do
launch_url = "https://app.example.com#remotestorage=#{user.address}"
expect(response).to redirect_to(launch_url)
end
it "does not hand over an access token" do
expect(response.location).not_to include("access_token")
end
end
context "when remoteStorage is not enabled for the user" do
@@ -44,7 +48,7 @@ RSpec.describe Services::RsAuthsController, type: :controller do
{ services_enabled: [] }
])
get :launch_app, params: { id: 1 }
get :launch_app, params: { client_id: "app.example.com" }
end
it "responds with forbidden" do
@@ -53,4 +57,52 @@ RSpec.describe Services::RsAuthsController, type: :controller do
end
end
end
describe "GET /services/storage/apps/:client_id/launch for an unknown app" do
before { sign_in user }
it "returns 404 instead of raising" do
get :launch_app, params: { client_id: "nonexistent.example.com" }
expect(response).to have_http_status(:not_found)
end
end
describe "DELETE /services/storage/rs_auths/:id" do
context "when user is signed in" do
before { sign_in user }
it "revokes the authorization" do
auth = user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
)
expect {
delete :destroy, params: { id: auth.id }
}.to change(RemoteStorageAuthorization, :count).by(-1)
expect(response).to redirect_to(apps_services_storage_url)
end
it "returns 404 for an unknown authorization" do
delete :destroy, params: { id: 0 }
expect(response).to have_http_status(:not_found)
end
it "returns 404 for another user's authorization" do
other = create :user, id: 99, cn: "otheruser", email: "other@example.com"
auth = other.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
redirect_uri: "https://app.example.com"
)
delete :destroy, params: { id: auth.id }
expect(response).to have_http_status(:not_found)
expect(RemoteStorageAuthorization.exists?(auth.id)).to be(true)
end
end
end
end
+19 -1
View File
@@ -44,11 +44,29 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
it "shows a special permission for all files and dirs" do
within ".scope" do
expect(page).to have_content("All files and directories")
expect(page).to have_content("All categories and files")
end
end
end
end
context "with PKCE parameters" do
before do
visit new_rs_oauth_path(username: user.cn,
redirect_uri: "http://example.com",
client_id: "http://example.com",
scope: "documents:rw",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256")
end
it "includes the PKCE parameters in the consent form" do
expect(page).to have_selector("input[name='response_type'][value='code']", visible: false)
expect(page).to have_selector("input[name='code_challenge'][value='E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM']", visible: false)
expect(page).to have_selector("input[name='code_challenge_method'][value='S256']", visible: false)
end
end
end
context "when signed out" do
+150
View File
@@ -0,0 +1,150 @@
require 'rails_helper'
RSpec.describe "Storage connected apps", type: :feature do
let(:user) { create :user }
let(:firefox_ua) do
"Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
end
let(:chrome_ua) do
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
"(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
end
before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
allow_any_instance_of(User).to receive(:ldap_entry).and_return(
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["remotestorage"]
)
login_as user, scope: :user
end
def create_auth(client:, device:, permissions: %w(documents:rw))
user.remote_storage_authorizations.create!(
permissions: permissions,
client_id: client,
redirect_uri: "https://#{client}",
expire_at: 1.month.from_now,
user_agent: device
)
end
context "with a connected app" do
before do
AppCatalog::WebApp.create!(url: "https://notes.example.com", name: "Notes", metadata: {})
end
let!(:auth) do
create_auth(client: "notes.example.com", device: firefox_ua,
permissions: %w(documents:rw photos:r))
end
after { auth.destroy }
it "shows the app with device and category counts" do
visit apps_services_storage_path
within ".rs-auth-card" do
expect(page).to have_content("Notes")
expect(page).to have_content("notes.example.com")
expect(page).to have_content("1 device")
expect(page).to have_content("2 categories")
end
end
it "does not show the permission expiry on the card" do
visit apps_services_storage_path
within ".rs-auth-card" do
expect(page).not_to have_content("Expires")
end
end
it "renders access and per-token details in the modal" do
visit apps_services_storage_path
expect(page).to have_selector("p.scope", text: "documents", visible: false)
expect(page).to have_selector("p.scope", text: "photos", visible: false)
expect(page).to have_selector("h5", text: "Connected devices", visible: false)
expect(page).to have_selector("p", text: "Firefox on Linux", visible: false)
expect(page).to have_selector("p", text: /Expires/, visible: false)
expect(page).to have_button("Revoke access", visible: false)
expect(page).to have_link("Launch app", visible: false)
end
end
context "with multiple apps" do
let!(:notes) { create_auth(client: "notes.example.com", device: firefox_ua) }
let!(:photos) { create_auth(client: "photos.example.com", device: firefox_ua) }
after do
notes.destroy
photos.destroy
end
it "shows one card per app" do
visit apps_services_storage_path
expect(page).to have_selector(".rs-auth-card", count: 2)
end
end
context "with one app on multiple devices" do
let!(:laptop) { create_auth(client: "notes.example.com", device: firefox_ua) }
let!(:phone) { create_auth(client: "notes.example.com", device: chrome_ua) }
after do
laptop.destroy
phone.destroy
end
it "shows a single card with the device count" do
visit apps_services_storage_path
expect(page).to have_selector(".rs-auth-card", count: 1)
within ".rs-auth-card" do
expect(page).to have_content("2 devices")
end
end
end
context "with one app on multiple devices and differing scopes" do
let!(:laptop) do
create_auth(client: "notes.example.com", device: firefox_ua, permissions: %w(documents:rw))
end
let!(:phone) do
create_auth(client: "notes.example.com", device: chrome_ua,
permissions: %w(documents:rw photos:r))
end
after do
laptop.destroy
phone.destroy
end
it "summarises the union of the app's scopes in the card and the modal" do
visit apps_services_storage_path
within ".rs-auth-card" do
expect(page).to have_content("2 devices")
expect(page).to have_content("2 categories")
end
expect(page).to have_selector("p.scope", text: "documents", visible: false)
expect(page).to have_selector("p.scope", text: "photos", visible: false)
end
end
context "without connected apps" do
it "shows the empty state" do
visit apps_services_storage_path
expect(page).to have_content("No apps connected")
end
end
end
@@ -303,4 +303,73 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end
end
end
describe "#expired?" do
def auth_with_expiry(expire_at)
RemoteStorageAuthorization.new(
user: user, permissions: %w(documents:rw), client_id: "example.com",
redirect_uri: "https://example.com", expire_at: expire_at
)
end
it "is false without an expiry" do
expect(auth_with_expiry(nil)).not_to be_expired
end
it "is false before the expiry" do
expect(auth_with_expiry(1.day.from_now)).not_to be_expired
end
it "is true after the expiry" do
expect(auth_with_expiry(1.day.ago)).to be_expired
end
end
describe "#device_name" do
def auth_with_user_agent(user_agent)
RemoteStorageAuthorization.new(user: user, user_agent: user_agent)
end
it "returns nil without a user agent" do
expect(auth_with_user_agent(nil).device_name).to be_nil
end
it "recognizes Firefox on Linux" do
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
expect(auth_with_user_agent(ua).device_name).to eq("Firefox on Linux")
end
it "recognizes Chrome on macOS" do
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
"(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
expect(auth_with_user_agent(ua).device_name).to eq("Chrome on macOS")
end
it "recognizes Mobile Safari on iOS" do
ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) " \
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
expect(auth_with_user_agent(ua).device_name).to eq("Mobile Safari on iOS")
end
it "recognizes GNOME Web on Linux (Epiphany's Safari-like user agent)" do
ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
"Version/16.0 Safari/605.1.15 Epiphany/45.0"
expect(auth_with_user_agent(ua).device_name).to eq("GNOME Web on Linux")
end
it "treats a WebKit Safari user agent on Linux as WebKit (WebKitGTK)" do
ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
"Version/60.5 Safari/605.1.15"
expect(auth_with_user_agent(ua).device_name).to eq("WebKit on Linux")
end
it "keeps Safari on macOS as Safari" do
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " \
"(KHTML, like Gecko) Version/17.1 Safari/605.1.15"
expect(auth_with_user_agent(ua).device_name).to eq("Safari on macOS")
end
it "falls back to the browser when the OS is unknown" do
expect(auth_with_user_agent("Firefox/120.0").device_name).to eq("Firefox")
end
end
end
+79
View File
@@ -0,0 +1,79 @@
require 'rails_helper'
RSpec.describe RsAuthorizationCode, type: :model do
let(:user) { create :user }
# Example value from RFC 7636, Appendix B
let(:challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
def new_code(**attrs)
RsAuthorizationCode.new(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: challenge,
**attrs
)
end
def build_code(**attrs)
new_code(**attrs).tap(&:save!)
end
describe "validations" do
it "is valid with a well-formed S256 challenge" do
expect(new_code).to be_valid
end
it "rejects a challenge shorter than 43 characters" do
expect(new_code(code_challenge: "short")).not_to be_valid
end
it "rejects a challenge with invalid characters" do
expect(new_code(code_challenge: "#{'a' * 42}!")).not_to be_valid
end
it "rejects an unsupported challenge method" do
code = new_code(code_challenge_method: "plain")
expect(code).not_to be_valid
expect(code.errors[:code_challenge_method]).to be_present
end
end
describe "#create" do
it "generates a raw code and stores only its digest" do
code = build_code
expect(code.code).to be_present
expect(code.code_digest).to eq(Digest::SHA256.hexdigest(code.code))
end
it "sets a short expiry" do
code = build_code
expect(code.expires_at).to be_within(5.seconds).of(60.seconds.from_now)
end
end
describe ".find_by_code" do
it "finds a code by its raw value" do
code = build_code
expect(RsAuthorizationCode.find_by_code(code.code)).to eq(code)
end
it "returns nil for an unknown code" do
expect(RsAuthorizationCode.find_by_code("does-not-exist")).to be_nil
end
it "returns nil for a blank code" do
expect(RsAuthorizationCode.find_by_code(nil)).to be_nil
end
it "returns nil for an expired code" do
code = build_code(expires_at: 1.minute.ago)
expect(RsAuthorizationCode.find_by_code(code.code)).to be_nil
end
end
end
+57
View File
@@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe "remoteStorage OAuth token endpoint", type: :request do
let(:user) { create :user }
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
def issue_code
RsOauthManager::IssueCode.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256"
)
end
it "exchanges a code for an access token without a session" do
code = issue_code
post "/rs/oauth/token", params: {
grant_type: "authorization_code",
code: code.code,
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier
}
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("application/json")
json = JSON.parse(response.body)
expect(json["access_token"]).to be_present
expect(json["token_type"]).to eq("bearer")
expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
end
it "answers CORS preflight requests" do
options "/rs/oauth/token"
expect(response).to have_http_status(:ok)
expect(response.headers["Access-Control-Allow-Methods"]).to include("POST")
end
end
+9
View File
@@ -133,6 +133,15 @@ RSpec.describe "WebFinger", type: :request do
oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"]
expect(oauth_url).to eql("http://localhost/rs/oauth/tony")
authorization_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-3.1"]
expect(authorization_url).to eql("http://localhost/rs/oauth/tony")
token_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-3.2"]
expect(token_url).to eql("http://localhost/rs/oauth/token")
expect(rs_link["properties"]["http://tools.ietf.org/html/rfc7636"]).to eql("S256")
expect(rs_link["properties"]["http://remotestorage.io/spec/version"]).to eql("draft-dejong-remotestorage-27")
end
it "returns CORS headers" do
@@ -0,0 +1,118 @@
require 'rails_helper'
RSpec.describe RsOauthManager::ExchangeCode do
let(:user) { create :user }
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
end
def issue_code(**overrides)
RsOauthManager::IssueCode.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256",
user_agent: "RSpec Device",
**overrides
)
end
def exchange(code, **overrides)
described_class.call(
code: code.code,
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier,
**overrides
)
end
it "creates an authorization and consumes the code" do
code = issue_code
auth = nil
expect { auth = exchange(code) }.to change(RemoteStorageAuthorization, :count).by(1)
expect(auth.permissions).to eq(%w(documents:rw))
expect(auth.client_id).to eq("example.com")
expect(auth.token).to be_present
expect(auth.user_agent).to eq("RSpec Device")
expect(RsAuthorizationCode.exists?(code.id)).to be(false)
end
it "carries over the permission expiry" do
expire_at = 1.month.from_now
code = issue_code(expire_at: expire_at)
auth = exchange(code)
expect(auth.expire_at).to be_within(1.second).of(expire_at)
end
it "rejects an unknown code" do
expect do
described_class.call(
code: "unknown",
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier
)
end.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a reused code" do
code = issue_code
exchange(code)
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects an expired code" do
code = issue_code
code.update_columns(expires_at: 1.minute.ago)
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a mismatching redirect_uri" do
code = issue_code
expect { exchange(code, redirect_uri: "https://example.com/other") }
.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a mismatching client_id host" do
code = issue_code
expect { exchange(code, client_id: "https://evil.example.org") }
.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a code whose stored client_id host differs from the presented one" do
code = RsAuthorizationCode.create!(
user: user,
client_id: "evil.example.org",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256"
)
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a bad code_verifier" do
code = issue_code
expect { exchange(code, code_verifier: "wrong-verifier") }
.to raise_error(RsOauthManagerService::InvalidGrant)
end
end
@@ -0,0 +1,31 @@
require 'rails_helper'
RSpec.describe RsOauthManager::IssueCode do
let(:user) { create :user }
subject do
described_class.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now,
user_agent: "RSpec Device"
)
end
it "creates a short-lived authorization code" do
expect { subject }.to change(RsAuthorizationCode, :count).by(1)
expect(subject.code).to be_present
expect(subject.client_id).to eq("example.com")
expect(subject.permissions).to eq(%w(documents:rw))
expect(subject.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(subject.user_agent).to eq("RSpec Device")
end
it "does not create a remote storage authorization" do
expect { subject }.not_to change(RemoteStorageAuthorization, :count)
end
end
@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe RsOauthManager::VerifyPkce do
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
subject do
described_class.call(
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
code_verifier: code_verifier
)
end
context "with the S256 method" do
let(:code_challenge_method) { "S256" }
it "accepts a matching verifier" do
expect(subject).to be(true)
end
it "rejects a non-matching verifier" do
expect(
described_class.call(
code_challenge: code_challenge,
code_challenge_method: "S256",
code_verifier: "wrong-verifier"
)
).to be(false)
end
end
context "with an unsupported method" do
let(:code_challenge_method) { "plain" }
it "rejects the verifier" do
expect(subject).to be(false)
end
end
end