Merge branch 'master' into live

This commit is contained in:
raucao committed 2026-11-04 16:09:01 +04:00
commit 290ceaa7fa
70 files changed
+2420 -249

No files matched your search

+7 -4
View File
@@ -7,13 +7,16 @@ ejabberd, Discourse, Mastodon, remoteStorage, Nostr, LNDHub, and BTCPay.
## Development environment
Development runs in Docker Compose — run all commands against the `web` container.
Start services: `docker compose up` (web, ldap, redis, minio, liquor-cabinet, strfry).
Start services: `docker compose up` (web, ldap, redis, garage, liquor-cabinet, strfry).
The `web` service runs `bin/dev` (foreman: Puma + Tailwind CSS watcher) and embeds
Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`).
First-time LDAP setup (after creating the 389ds backend once):
`docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be_name="dev"`
then `docker compose run web bin/rails ldap:setup`.
First-time LDAP and database setup is automated: the `ldap-init` service
creates the 389ds back-end, then the `web` entrypoint seeds LDAP and the
databases on first start and runs `db:prepare` on every boot. Manual
equivalents: `docker compose exec ldap dsconf localhost backend create
--suffix="dc=kosmos,dc=org" --be-name="dev"` and `docker compose run --rm web
bin/rails ldap:setup`.
## Common commands (prefix with `docker compose exec web`)
+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
+44 -24
View File
@@ -14,13 +14,14 @@ so:
1. Make sure [Docker Compose is installed][1] and Docker is running (included in
Docker Desktop)
3. Run `docker compose up --build` and wait until all services have started
2. Run `docker compose up --build` and wait until all services have started
(389ds might take an extra minute to be ready). This will take a while when
running for the first time, so you might want to do something else in the
meantime.
4. `docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"`
5. `docker compose run web rails ldap:setup`
6. `docker compose run web rails db:setup`
On the first start, the `ldap-init` service creates the 389ds back-end, and the
`web` container then seeds the LDAP directory and the databases automatically.
On every start, `web` also applies any pending database migrations.
After these steps, you should have a working Rails app with a handful of test
users running on [http://localhost:3000](http://localhost:3000).
@@ -71,15 +72,12 @@ containers you want to run to the `up` command, like so:
#### LDAP server
After creating the Docker container for the first time (or after deleting it),
you need to run the following command once, in order to create the dirsrv
back-end:
On first start, the `ldap-init` service creates the dirsrv back-end
automatically, and the `web` container then seeds it with development entries.
To do either step manually (for example, after changing the setup), run:
docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"
Now you can seed the back-end with data using this Rails task:
bundle exec rails ldap:setup
docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"
docker compose run --rm web bin/rails ldap:setup
The setup task will first delete any existing entries in the directory tree
("dc=kosmos,dc=org"), and then create our development entries.
@@ -88,20 +86,42 @@ Note that all 389ds data is stored in the `389ds-data` volume. So if you want
to start over with a fresh installation, delete both that volume as well as the
container.
#### Minio / remoteStorage
To reset the development environment completely, remove all volumes plus the
generated database files and the first-run marker, then start over:
If you want to run remoteStorage accounts locally, you will have to create the
respective bucket first. With the `minio` container running (run by default
when using Docker Compose), follow these steps:
docker compose down -v
rm -f db/*.sqlite3 tmp/.setup-complete
docker compose up --build
* `docker compose up web redis minio liquor-cabinet`
* Head to http://localhost:9001 and log in with user `minioadmin`, password
`minioadmin`
* Create a new bucket called `remotestorage` (or whatever you
change the `S3_BUCKET` config to)
* Create a new key with ID "dev-key" and secret "123456789" (or whatever you
change `S3_ACCESS_KEY` and `S3_SECRET_KEY` to). Leave the policy field empty,
as it will automatically allow access to the bucket you created.
#### Garage / remoteStorage
remoteStorage accounts use the `garage` S3-compatible object store. On first
start, Garage automatically configures a single-node cluster and creates the
`remotestorage` bucket together with a `dev-key1` access key (secret
`1234567890123456`), so no manual setup is required.
If you want to run remoteStorage accounts locally, the `garage` container is
started by default when using Docker Compose. To run just the remoteStorage
stack:
* `docker compose up web redis garage liquor-cabinet`
The S3 API is available at http://localhost:3900 (region `garage`). If you want
to start over with a fresh storage, delete the `garage-data` volume as well as
the container.
#### Accessing remoteStorage from another machine
remoteStorage clients force HTTPS for any host except `localhost`, and browsers
block plain-HTTP requests to a LAN IP as mixed content. To connect to the dev
remoteStorage from a browser on another machine (including production apps such
as Inspektor), forward the ports over SSH and connect as `localhost`:
ssh -N -L 3000:localhost:3000 -L 4567:localhost:4567 <user>@<dev-host>
Then use `<user>@localhost:3000` as the remoteStorage address in the client.
WeFinger discovery and storage requests are served through the forwarded ports,
so no TLS setup is needed.
### Adding npm modules to use with Stimulus controllers
@@ -14,3 +14,27 @@
animation-duration: 0.15s;
animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1);
}
@keyframes removeRow {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(0.75rem);
}
}
.animate-remove {
animation: removeRow 0.2s ease-out forwards;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
/* Keep the animation, so the deferred removal still completes, but make it
effectively instant. */
.animate-remove {
animation-duration: 0.01s;
}
}
@@ -1,5 +1,7 @@
<% if @image_url %>
<%= image_tag @image_url, class: "h-full w-full" %>
<%= image_tag @image_url, class: "h-full w-full rounded-lg object-contain" %>
<% else %>
<%= render partial: "icons/remotestorage", locals: { custom_class: "h-full w-full p-0.5 text-gray-200" } %>
<span class="flex h-full w-full items-center justify-center rounded-lg bg-gray-100">
<%= render partial: "icons/remotestorage", locals: { custom_class: "h-1/2 w-1/2 text-gray-400" } %>
</span>
<% end %>
@@ -0,0 +1,7 @@
<div id="<%= access_id %>">
<h5 class="mt-8 mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
Access
</h5>
<%= render RsScopeListComponent.new(scopes: permissions, root: root_access?,
icon_class: "h-5 w-5") %>
</div>
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class RsAuthAccessComponent < ViewComponent::Base
include RsAuthSummary
include RsAuthsHelper
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
end
def access_id
rs_app_dom_id(@auth.client_id, :access)
end
end
@@ -0,0 +1,35 @@
<button type="button"
id="<%= card_id %>"
class="rs-auth-card group flex h-full w-full flex-col rounded-lg border border-gray-200
bg-white p-4 text-left transition
hover:border-gray-300 hover:bg-gray-50 hover:shadow-sm
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
data-action="click->modal#open">
<span class="flex items-center gap-3">
<span class="h-12 w-12 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</span>
<span class="min-w-0 flex-grow">
<span class="mb-1 block truncate text-lg font-bold"><%= @auth.display_name %></span>
<% if @auth.client_id != @auth.display_name %>
<span class="block truncate text-sm text-gray-500"><%= @auth.client_id %></span>
<% end %>
</span>
<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>
</span>
<span class="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1 pt-4 text-sm text-gray-500">
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/monitor", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= device_summary %>
</span>
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/folder", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= category_summary %>
</span>
</span>
</button>
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class RsAuthCardComponent < ViewComponent::Base
include RsAuthSummary
include RsAuthsHelper
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
def card_id
rs_app_dom_id(@auth.client_id, :card)
end
end
+4 -27
View File
@@ -1,27 +1,4 @@
<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>
<div class="h-full" data-controller="modal" data-action="keydown.esc->modal#close">
<%= render RsAuthCardComponent.new(authorizations: @authorizations) %>
<%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %>
</div>
+6 -4
View File
@@ -1,8 +1,10 @@
# frozen_string_literal: true
# Wrapper around one app's card and its details modal. The modal's open state
# lives on this element (via the `modal` controller), so the card and the modal
# can be re-rendered independently without closing it.
class RsAuthComponent < ViewComponent::Base
def initialize(auth:)
@auth = auth
@web_app = auth.web_app
def initialize(authorizations:)
@authorizations = authorizations
end
end
end
@@ -0,0 +1,52 @@
<%= render ModalComponent.new(show_close_button: false) do %>
<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="min-w-0">
<h4 class="mb-1 text-xl font-bold truncate"><%= @auth.display_name %></h4>
<% if @auth.client_id != @auth.display_name %>
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
<% end %>
</div>
</div>
<%= render RsAuthAccessComponent.new(authorizations: @authorizations) %>
<h5 class="mt-8 mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
Connected devices
</h5>
<ul class="divide-y divide-gray-100 rounded-lg border border-gray-200" data-turbo-prefetch="false">
<% @authorizations.each do |auth| %>
<li id="<%= rs_device_dom_id(auth) %>" class="flex items-center justify-between gap-6 px-4 py-3">
<div class="min-w-0">
<p class="mb-1 truncate text-base font-medium"><%= auth.device_label %></p>
<p class="truncate text-xs text-gray-500">
Connected <%= timestamp_tag(auth.created_at, title: connected_at_title(auth)) %>
<span class="mx-1">&middot;</span>
<%= tag.span class: ("text-red-700" if auth.expired?) do %>
<% if auth.expire_at.present? %>
<%= expiry_prefix(auth) %> <%= timestamp_tag(auth.expire_at, title: expires_at_title(auth)) %>
<% else %>
Never expires
<% end %>
<% end %>
</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-8 flex flex-col sm:flex-row gap-3 sm:gap-2">
<%= link_to "Launch app", launch_app_services_storage_path(@auth.client_id),
class: "btn-md btn-blue w-full sm:grow sm:order-last text-center",
target: "_blank", rel: "noopener", data: { turbo: false } %>
<button type="button" class="btn-md btn-gray w-full sm:grow text-center"
data-action="click->modal#close:prevent">
Close
</button>
</div>
<% end %>
@@ -0,0 +1,34 @@
# frozen_string_literal: true
class RsAuthDetailsModalComponent < ViewComponent::Base
include RsAuthsHelper
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
# "Expires" / "Expired on", or nothing when there is no expiry to describe.
def expiry_prefix(auth)
return nil if auth.expire_at.blank?
auth.expired? ? "Expired on" : "Expires"
end
# Fallbacks for the hover titles, used when JavaScript is unavailable.
# app/javascript/local_time.js replaces them with the reader's own zone.
def connected_at_title(auth)
auth.created_at.strftime("%-d %b %Y, %H:%M")
end
def expires_at_title(auth)
auth.expire_at.strftime("%-d %b %Y, %H:%M")
end
# A <time> element carrying the absolute (UTC) timestamp, so the reader's own
# local time can be shown alongside the date.
def timestamp_tag(time, title:)
tag.time time.strftime("%-d %b %Y"), datetime: time.utc.iso8601, title: title
end
end
+40
View File
@@ -0,0 +1,40 @@
# frozen_string_literal: true
# Summaries of a group of authorizations for the same app, shared by the card,
# the details modal and the Turbo Stream responses that refresh them.
module RsAuthSummary
# The union of the group's scopes: an older token may grant more 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 known_device_count
@authorizations.map(&:device_name).compact.uniq.size
end
def unrecorded_device?
@authorizations.any? { |auth| auth.device_name.blank? }
end
def device_summary
count = known_device_count
return "#{count} #{'device'.pluralize(count)}" unless unrecorded_device?
# An authorization without a recorded user agent may cover several devices,
# so only a lower bound can be given.
lower_bound = count + 1
"#{lower_bound}+ #{'device'.pluralize(lower_bound)}"
end
end
@@ -0,0 +1,22 @@
<div class="space-y-2">
<% if @root %>
<p class="scope mb-0 flex items-center gap-2 text-lg">
<%= render partial: "icons/alert-triangle",
locals: { custom_class: "#{icon_classes} text-red-700" } %>
<span class="text-red-700">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 mb-0 flex items-center gap-2 text-gray-600">
<%= render partial: "icons/folder", locals: { custom_class: icon_classes } %>
<span class="text-lg"><%= scope_name(scope) %></span>
<% if scope_permissions(scope) == "r" %>
<span>(read only)</span>
<% end %>
</p>
<% end %>
<% end %>
</div>
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class RsScopeListComponent < ViewComponent::Base
include OauthHelper
def initialize(scopes:, root: false, icon_class: nil)
@scopes = scopes
@root = root
@icon_class = icon_class
end
def read_only?
@scopes.include?(":r")
end
def icon_classes
["flex-none", @icon_class].compact.join(" ")
end
end
+6
View File
@@ -3,5 +3,11 @@ class DashboardController < ApplicationController
def index
@current_section = :services
@storage_path =
if Setting.remotestorage_enabled? && current_user.remote_storage_authorizations.exists?
apps_services_storage_path
else
services_storage_path
end
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)
@@ -1,7 +1,7 @@
class Services::RemotestorageController < Services::BaseController
before_action :authenticate_user!
before_action :require_service_available
before_action :require_feature_enabled
before_action :require_service_enabled
# Dashboard
def show
@@ -17,8 +17,8 @@ class Services::RemotestorageController < Services::BaseController
http_status :not_found unless Setting.remotestorage_enabled?
end
def require_feature_enabled
unless Flipper.enabled?(:remotestorage, current_user)
def require_service_enabled
unless current_user.service_enabled?(:remotestorage)
http_status :forbidden
end
end
+65 -18
View File
@@ -1,41 +1,88 @@
class Services::RsAuthsController < Services::BaseController
before_action :authenticate_user!
before_action :require_feature_enabled
before_action :require_service_enabled
before_action :require_service_available
# before_action :require_service_enabled
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
client_id = @auth.client_id
@auth.destroy!
remaining = current_user.remote_storage_authorizations
.where(client_id: client_id)
.order(created_at: :desc)
.includes(:web_app)
.to_a
respond_to do |format|
format.html do redirect_to apps_services_storage_url, flash: {
success: 'App authorization revoked'
}
end
format.json { head :no_content }
format.html do
redirect_to apps_services_storage_url, flash: {
success: 'App authorization revoked'
}
end
format.turbo_stream { revoke_stream(client_id, remaining) }
end
end
def launch_app
user_address = Rails.env.development? ?
"#{current_user.cn}@localhost:3000" :
current_user.address
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
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
user_address =
if Rails.env.development?
"#{current_user.cn}@#{request.host_with_port}"
else
current_user.address
end
# 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 require_feature_enabled
unless Flipper.enabled?(:remotestorage, current_user)
# Revoking one of several devices updates the open details modal in place.
# Revoking the app's last device leaves nothing to show, so the dashboard
# re-renders itself instead: that keeps grouping, sorting and the empty
# state in one place.
def revoke_stream(client_id, remaining)
if remaining.empty?
redirect_to apps_services_storage_url, flash: {
success: 'App authorization revoked'
}
else
render turbo_stream: [
turbo_stream.remove(helpers.rs_device_dom_id(@auth)),
turbo_stream.replace(helpers.rs_app_dom_id(client_id, :card),
RsAuthCardComponent.new(authorizations: remaining)),
turbo_stream.replace(helpers.rs_app_dom_id(client_id, :access),
RsAuthAccessComponent.new(authorizations: remaining))
]
end
end
def app_name_for(auth)
auth.display_name
end
def require_service_enabled
unless current_user.service_enabled?(:remotestorage)
http_status :forbidden
end
end
@@ -45,7 +92,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
end
+26 -3
View File
@@ -88,19 +88,42 @@ class WebfingerController < WellKnownController
end
def remotestorage_link
auth_url = new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
storage_url = "#{Setting.rs_storage_url}/#{@username}"
auth_url =
if Rails.env.development?
new_rs_oauth_url(@username)
else
new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
end
storage_url = "#{remotestorage_storage_base_url}/#{@username}"
{
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
}
}
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?
uri = URI.parse(Setting.rs_storage_url)
uri.host = request.host
uri.to_s
end
end
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
module RsAuthsHelper
# An app is summarised by the authorizations grouped under its client_id, so
# its DOM ids derive from the client_id: revoking any single authorization must
# not invalidate them.
def rs_app_dom_id(client_id, part)
"rs_app_#{part}_#{Digest::SHA256.hexdigest(client_id)[0, 12]}"
end
def rs_device_dom_id(auth)
ActionView::RecordIdentifier.dom_id(auth, :device)
end
end
+2
View File
@@ -1,3 +1,5 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "turbo_stream_animations"
import "local_time"
+32
View File
@@ -0,0 +1,32 @@
// Timestamps are rendered by the server in UTC, next to a <time> element's
// machine-readable value. Rewrite those elements' hover titles so they show the
// reader's own local time instead.
const LOCAL_TIME_FORMAT = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
// Only absolute timestamps carry a zone to convert from: a date-only value would
// be read as UTC midnight and shift to the previous day for most readers.
const ABSOLUTE_TIMESTAMP = /T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
const localizeTimestamps = () => {
document.querySelectorAll("time[datetime]").forEach((element) => {
const value = element.getAttribute("datetime")
if (!ABSOLUTE_TIMESTAMP.test(value)) return
const timestamp = new Date(value)
if (!Number.isNaN(timestamp.getTime())) {
element.title = LOCAL_TIME_FORMAT.format(timestamp)
}
})
}
document.addEventListener("turbo:load", localizeTimestamps)
+30
View File
@@ -0,0 +1,30 @@
// A Turbo Stream `remove` action deletes its target right away. Pause that until
// the element's CSS animation has finished, so a revoked device row can fade out
// instead of vanishing.
const REMOVE_ANIMATION_CLASS = "animate-remove"
const REMOVE_ANIMATION_TIMEOUT = 1000
document.addEventListener("turbo:before-stream-render", (event) => {
const renderDefault = event.detail.render
event.detail.render = (streamElement) => {
const target =
streamElement.action === "remove" ? document.getElementById(streamElement.target) : null
if (!target) return renderDefault(streamElement)
let rendered = false
const render = () => {
if (rendered) return
rendered = true
renderDefault(streamElement)
}
target.addEventListener("animationend", render, { once: true })
target.classList.add(REMOVE_ANIMATION_CLASS)
// Don't leave the element behind if the animation cannot run at all.
setTimeout(render, REMOVE_ANIMATION_TIMEOUT)
}
})
+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"
+50 -5
View File
@@ -2,6 +2,14 @@ class RemoteStorageAuthorization < ApplicationRecord
belongs_to :user
belongs_to :web_app, class_name: "AppCatalog::WebApp", optional: true
# The plaintext token is only ever held in memory, long enough to hand it to
# the client at issuance. At rest we store only its SHA256 digest.
attr_accessor :token
# Tokens issued before user agents were recorded may have been used from any
# number of devices, so they cannot be attributed to a single one.
UNKNOWN_DEVICE = "Unknown device(s)"
serialize :permissions, coder: YAML unless Rails.env.production?
validates_presence_of :permissions
@@ -17,8 +25,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,9 +48,45 @@ 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 device_label
device_name || UNKNOWN_DEVICE
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)
value = token_digest.presence
value ||= read_attribute(:token) if self.class.column_names.include?("token")
return if value.blank?
key = "authorizations:#{user.cn}:#{value}"
redis.del key
rescue => e
Rails.logger.error e
Sentry.capture_exception(e) if Setting.sentry_enabled?
@@ -56,10 +100,11 @@ class RemoteStorageAuthorization < ApplicationRecord
def generate_token(length=16)
self.token = SecureRandom.hex(length) if self.token.blank?
self.token_digest = Digest::SHA256.hexdigest(token)
end
def store_token_in_redis
redis.sadd "authorizations:#{user.cn}:#{token}", permissions
redis.sadd "authorizations:#{user.cn}:#{token_digest}", permissions
end
def schedule_token_expiry
+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,111 @@
require "digest"
module RemoteStorageManager
# One-off, idempotent migration helper for issue #259. It backfills missing
# digests for authorizations created while the old plaintext code was still
# running, re-keys the Redis authorization sets from plaintext to digest
# keys, and removes any leftover non-digest keys.
#
# It only works while the plaintext `token` column still exists; after the
# follow-up contract migration drops it, `call` is a no-op.
class RekeyTokens < RemoteStorageManagerService
Result = Struct.new(:rekeyed, :legacy_removed, :skipped, keyword_init: true)
def call
return Result.new(rekeyed: 0, legacy_removed: 0, skipped: true) unless token_column?
rekeyed = 0
RemoteStorageAuthorization.find_each do |auth|
plaintext = persisted_token(auth)
next if plaintext.blank? && auth.token_digest.blank?
digest = auth.token_digest.presence || Digest::SHA256.hexdigest(plaintext)
auth.update_columns(token_digest: digest) if auth.token_digest.blank?
permissions = permissions_for(auth, plaintext)
redis.sadd(redis_key(auth.user.cn, digest), permissions) if permissions.present?
redis.del(redis_key(auth.user.cn, plaintext)) if plaintext.present?
rekeyed += 1
end
Result.new(rekeyed: rekeyed, legacy_removed: remove_legacy_keys, skipped: false)
end
private
def redis
@redis ||= Redis.new(url: Setting.rs_redis_url)
end
def token_column?
RemoteStorageAuthorization.column_names.include?("token")
end
# Reads the persisted plaintext directly, because the model's `token`
# accessor shadows the column after the token is moved into memory.
def persisted_token(auth)
auth.read_attribute(:token) if token_column?
end
def redis_key(cn, value)
"authorizations:#{cn}:#{value}"
end
def permissions_for(auth, plaintext)
permissions = redis.smembers(redis_key(auth.user.cn, plaintext)) if plaintext.present?
permissions.presence || permissions_from_db(auth)
end
def permissions_from_db(auth)
case (permissions = auth.permissions)
when Array then permissions.map(&:to_s)
when String then parse_permissions(permissions)
else Array(permissions).map(&:to_s)
end
end
def parse_permissions(raw)
parsed = parse_serialized(raw)
return parsed.map(&:to_s) if parsed.is_a?(Array)
raw.to_s.gsub(/[{}]/, "").split(/[,\s]+/).reject(&:empty?)
end
def parse_serialized(raw)
YAML.safe_load(raw)
rescue StandardError
begin
JSON.parse(raw)
rescue StandardError
nil
end
end
# Removes leftover plaintext authorization keys. Legacy plaintext tokens are
# 32 hex chars (SecureRandom.hex(16)); digests are 64. Matching on shape
# instead of a snapshot of known keys means a digest issued concurrently
# during the sweep is never deleted.
def remove_legacy_keys
removed = 0
cursor = "0"
loop do
cursor, keys = redis.scan(cursor, match: "authorizations:*", count: 1000)
keys.each do |key|
next if key.split(":", 3).last.match?(/\A[0-9a-f]{64}\z/)
redis.del(key)
removed += 1
end
break if cursor == "0"
end
removed
end
end
end
@@ -0,0 +1,2 @@
class RemoteStorageManagerService < ApplicationService
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
+1 -1
View File
@@ -305,7 +305,7 @@
<td>remoteStorage</td>
<td>
<%= render FormElements::ToggleComponent.new(
enabled: Flipper.enabled?(:remotestorage, current_user) && @services_enabled.include?("remotestorage"),
enabled: @services_enabled.include?("remotestorage"),
input_enabled: false
) %>
</td>
+2 -3
View File
@@ -43,12 +43,11 @@
<% end %>
</div>
<% end %>
<% if Setting.remotestorage_enabled? &&
Flipper.enabled?(:remotestorage, current_user) %>
<% if Setting.remotestorage_enabled? %>
<div class="border border-gray-300 rounded-md hover:border-gray-400
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
bg-[url(/img/logos/icon_remotestorage.svg)]">
<%= link_to services_storage_path,
<%= link_to @storage_path,
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Storage</h3>
<p class="text-gray-600">
+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">
<% @rs_apps.each do |authorizations| %>
<%= render RsAuthComponent.new(authorizations: authorizations) %>
<% end %>
</div>
<% else %>
+1 -2
View File
@@ -25,8 +25,7 @@
active: @settings_section.to_s == "lightning"
) %>
<% end %>
<% if Setting.remotestorage_enabled? &&
Flipper.enabled?(:remotestorage, current_user) %>
<% if Setting.remotestorage_enabled? %>
<%= render SidenavLinkComponent.new(
name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage",
active: @settings_section.to_s == "remotestorage"
+2
View File
@@ -4,5 +4,7 @@ pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin "turbo_stream_animations"
pin "local_time"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "tailwindcss-stimulus-components" # @4.0.3
+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
@@ -0,0 +1,26 @@
require "digest"
class AddTokenDigestToRemoteStorageAuthorizations < ActiveRecord::Migration[8.1]
class MigrationRemoteStorageAuthorization < ActiveRecord::Base
self.table_name = "remote_storage_authorizations"
end
def up
add_column :remote_storage_authorizations, :token_digest, :string
MigrationRemoteStorageAuthorization.reset_column_information
MigrationRemoteStorageAuthorization.find_each do |auth|
next if auth.token.blank?
auth.update_columns(token_digest: Digest::SHA256.hexdigest(auth.token))
end
add_index :remote_storage_authorizations, :token_digest, unique: true
end
def down
remove_index :remote_storage_authorizations, :token_digest
remove_column :remote_storage_authorizations, :token_digest
end
end
+22 -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_04_120000) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -109,14 +109,34 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
t.text "permissions", default: "--- []\n"
t.string "redirect_uri"
t.string "token"
t.string "token_digest"
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"
t.index ["token_digest"], name: "index_remote_storage_authorizations_on_token_digest", unique: true
t.index ["user_id"], name: "index_remote_storage_authorizations_on_user_id"
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 +184,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
+6 -3
View File
@@ -1,8 +1,9 @@
require 'sidekiq/testing'
ldap = LdapService.new
Sidekiq::Testing.inline! do
original_queue_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :inline
begin
ldap.delete_all_users!
puts "Create user: admin"
@@ -25,4 +26,6 @@ Sidekiq::Testing.inline! do
password: "user is user", confirmed: true
})
end
ensure
ActiveJob::Base.queue_adapter = original_queue_adapter
end
+44 -15
View File
@@ -1,6 +1,7 @@
services:
ldap:
image: 4teamwork/389ds:latest
platform: linux/amd64
volumes:
- 389ds-data:/data
networks:
@@ -12,6 +13,21 @@ services:
DS_DM_PASSWORD: passthebutter
SUFFIX_NAME: "dc=kosmos,dc=org"
ldap-init:
image: 4teamwork/389ds:latest
platform: linux/amd64
networks:
- internal_network
volumes:
- ./docker/ldap-init.sh:/ldap-init.sh:ro
environment:
LDAP_ADMIN_PASSWORD: passthebutter
LDAP_SUFFIX: "dc=kosmos,dc=org"
depends_on:
ldap:
condition: service_healthy
command: ["/bin/sh", "/ldap-init.sh"]
redis:
restart: always
image: redis:7-alpine
@@ -26,7 +42,7 @@ services:
web:
build: .
tty: true
command: bash -c "rm -f /akkounts/tmp/pids/server.pid; bin/dev"
command: ["bash", "docker/web-entrypoint.sh"]
volumes:
- .:/akkounts
- /akkounts/node_modules
@@ -57,39 +73,52 @@ services:
NOSTR_PRIVATE_KEY: 7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea
NOSTR_RELAY_URL: ws://strfry:7777
depends_on:
- ldap
- redis
ldap:
condition: service_started
ldap-init:
condition: service_completed_successfully
redis:
condition: service_started
minio:
image: quay.io/minio/minio:latest
command: "server /data --console-address ':9001'"
garage:
image: dxflrs/garage:v2.4.1
command: ["/garage", "server", "--single-node", "--default-bucket"]
networks:
- external_network
- internal_network
ports:
- "9000:9000"
- "9001:9001"
- "3900:3900"
volumes:
- minio-data:/data
- ./docker/garage/garage.toml:/etc/garage.toml:ro
- garage-data:/var/lib/garage
environment:
GARAGE_DEFAULT_ACCESS_KEY: dev-key1
GARAGE_DEFAULT_SECRET_KEY: "1234567890123456"
GARAGE_DEFAULT_BUCKET: remotestorage
liquor-cabinet:
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.1
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.2
networks:
- external_network
- internal_network
ports:
- "4567:4567"
volumes:
- ./docker/liquor-cabinet/rainbows.conf.rb:/etc/liquor-cabinet/rainbows.conf.rb:ro
command: ["bundle", "exec", "rainbows", "-c", "/etc/liquor-cabinet/rainbows.conf.rb", "--listen", "0.0.0.0:4567"]
environment:
RACK_ENV: staging
RS_HASHED_ACCESS_TOKENS: "true"
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_DB: 1
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: dev-key
S3_SECRET_KEY: 123456789
S3_ENDPOINT: http://garage:3900
S3_REGION: garage
S3_ACCESS_KEY: dev-key1
S3_SECRET_KEY: "1234567890123456"
S3_BUCKET: remotestorage
depends_on:
- minio
- garage
- redis
strfry:
@@ -127,7 +156,7 @@ networks:
volumes:
389ds-data:
driver: local
minio-data:
garage-data:
driver: local
redis-data:
driver: local
+14
View File
@@ -0,0 +1,14 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "1799bccfd7411eddcf9ebd316bc1f5287ad12a68094e1c6ac6abde7e6feae1ec"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
SUFFIX="${LDAP_SUFFIX:-dc=kosmos,dc=org}"
URI="ldap://ldap:3389"
if dsconf -D "cn=Directory Manager" -w "$LDAP_ADMIN_PASSWORD" "$URI" \
backend suffix list --suffix 2>/dev/null | grep -Fqx "$SUFFIX"; then
echo "LDAP backend for $SUFFIX already exists, skipping."
else
echo "Creating LDAP backend for $SUFFIX..."
dsconf -D "cn=Directory Manager" -w "$LDAP_ADMIN_PASSWORD" "$URI" \
backend create --suffix "$SUFFIX" --be-name dev
fi
+5
View File
@@ -0,0 +1,5 @@
Rainbows! do
use :ThreadPool
worker_connections 16
client_max_body_size 100 * 1024 * 1024
end
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -e
if [ ! -f tmp/.setup-complete ]; then
echo "First start: setting up LDAP entries and databases..."
bin/rails ldap:setup
bin/rails db:setup
touch tmp/.setup-complete
else
bin/rails db:prepare
fi
rm -f tmp/pids/server.pid
exec bin/dev
+12
View File
@@ -0,0 +1,12 @@
namespace :rs do
desc "Backfill remoteStorage token digests and re-key Redis authorization keys (idempotent)"
task rekey_tokens: :environment do
result = RemoteStorageManager::RekeyTokens.call
if result.skipped
puts "remote_storage_authorizations.token column is gone; nothing to re-key."
else
puts "Re-keyed #{result.rekeyed} authorization(s); removed #{result.legacy_removed} legacy Redis key(s)."
end
end
end
+1 -1
View File
@@ -11,7 +11,7 @@
"postcss-preset-env": "^7.8.3",
"tailwindcss": "^3.4.0"
},
"version": "0.11.0",
"version": "0.12.0",
"scripts": {
"build:css:tailwind": "bun ./node_modules/tailwindcss/lib/cli.js --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css",
"build:css": "bun run build:css:tailwind"
+293 -30
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,8 +371,14 @@ 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")
expect(response).to redirect_to(
%r{\Ahttps://example\.com#access_token=[a-f0-9]{32}&state=foobar123\z}
)
end
end
@@ -328,7 +402,7 @@ RSpec.describe Rs::OauthController, type: :controller do
end
it "updates the token" do
expect(@auth.reload.token).not_to be_nil
expect(@auth.reload.token_digest).not_to be_nil
end
end
@@ -412,6 +486,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 +620,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
@@ -6,16 +6,18 @@ RSpec.describe Services::RsAuthsController, type: :controller do
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(Flipper).to receive(:enabled?).and_return(true)
allow_any_instance_of(LdapService).to receive(:fetch_users).and_return([
{ services_enabled: ["remotestorage"] }
])
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",
@@ -23,17 +25,139 @@ 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
before do
allow_any_instance_of(LdapService).to receive(:fetch_users).and_return([
{ services_enabled: [] }
])
get :launch_app, params: { client_id: "app.example.com" }
end
it "responds with forbidden" do
expect(response).to have_http_status(:forbidden)
end
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
context "when another device remains connected to the app" do
let!(:revoked) do
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
)
end
let!(:remaining) do
user.remote_storage_authorizations.create!(
permissions: %w(documents photos), client_id: "app.example.com",
redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
)
end
after do
revoked.destroy
remaining.destroy
end
it "streams the removed device row and the refreshed card and access list" do
delete :destroy, params: { id: revoked.id }, format: :turbo_stream
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include(
%(action="remove" target="#{ActionView::RecordIdentifier.dom_id(revoked, :device)}")
)
expect(response.body).to include(
%(action="replace" target="#{controller.helpers.rs_app_dom_id('app.example.com', :card)}")
)
expect(response.body).to include(
%(action="replace" target="#{controller.helpers.rs_app_dom_id('app.example.com', :access)}")
)
end
it "leaves the other device connected" do
delete :destroy, params: { id: revoked.id }, format: :turbo_stream
expect(RemoteStorageAuthorization.exists?(remaining.id)).to be(true)
end
end
context "when the app's last device is revoked" do
it "redirects so that the dashboard re-renders itself" 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
)
delete :destroy, params: { id: auth.id }, format: :turbo_stream
expect(response).to redirect_to(apps_services_storage_url)
expect(flash[:success]).to eq("App authorization revoked")
end
end
end
end
+26
View File
@@ -11,4 +11,30 @@ RSpec.describe 'Account dashboard', type: :feature do
visit root_path
expect(page).to have_content('Services')
end
context 'with connected remoteStorage apps' do
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)
user.remote_storage_authorizations.create!(
permissions: ["documents:rw"],
client_id: "app.example.com",
redirect_uri: "https://app.example.com",
expire_at: 1.month.from_now
)
end
scenario 'links the Storage card to the connected apps dashboard' do
visit root_path
expect(page).to have_link('Storage', href: apps_services_storage_path)
end
end
context 'without connected remoteStorage apps' do
scenario 'links the Storage card to the storage info page' do
visit root_path
expect(page).to have_link('Storage', href: services_storage_path)
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
+206
View File
@@ -0,0 +1,206 @@
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
it "renders the ids that the revoke stream targets" do
visit apps_services_storage_path
expect(page).to have_selector(".rs-auth-card[id^='rs_app_card_']")
expect(page).to have_selector("div[id^='rs_app_access_']", visible: false)
expect(page).to have_selector(
"li##{ActionView::RecordIdentifier.dom_id(auth, :device)}", visible: false
)
end
it "marks the device timestamps with machine-readable UTC times" do
visit apps_services_storage_path
timestamps = page.all("time[datetime]", visible: false)
.map { |element| Time.iso8601(element["datetime"]) }
expect(timestamps).to contain_exactly(
be_within(1.second).of(auth.created_at),
be_within(1.second).of(auth.expire_at)
)
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 "with an authorization from before user agents were recorded" do
let!(:legacy) { create_auth(client: "notes.example.com", device: nil) }
after { legacy.destroy }
it "only claims a lower-bound device count and labels the device as unknown" do
visit apps_services_storage_path
within ".rs-auth-card" do
expect(page).to have_content("1+ device")
end
expect(page).to have_selector("p", text: "Unknown device(s)", visible: false)
end
end
context "with an unrecorded authorization alongside a recorded device" do
let!(:legacy) { create_auth(client: "notes.example.com", device: nil) }
let!(:laptop) { create_auth(client: "notes.example.com", device: firefox_ua) }
after do
legacy.destroy
laptop.destroy
end
it "counts the unrecorded authorization as a possible extra device" do
visit apps_services_storage_path
within ".rs-auth-card" do
expect(page).to have_content("2+ devices")
end
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
@@ -28,7 +28,7 @@ RSpec.describe RemoteStorageExpireAuthorizationJob, type: :job do
}
it "removes the RS authorization from redis" do
redis_key = "authorizations:#{@user.cn}:#{@rs_authorization.token}"
redis_key = "authorizations:#{@user.cn}:#{@rs_authorization.token_digest}"
expect(redis.keys(redis_key)).to_not be_empty
perform_enqueued_jobs { job }
+114 -12
View File
@@ -12,7 +12,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
describe "#create" do
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("authorizations:*") }
let(:auth) do
user.remote_storage_authorizations.create!(
@@ -26,11 +25,22 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
expect(auth.token).to match(/[a-zA-Z0-9]+/)
end
it "stores a token in redis" do
user_auth_keys = redis_rs.keys("authorizations:#{user.cn}:*")
expect(user_auth_keys.length).to eq(1)
it "stores only the token digest" do
expect(auth.token_digest).to eq(Digest::SHA256.hexdigest(auth.token))
end
authorizations = redis_rs.smembers(user_auth_keys.first)
it "does not persist the plaintext token" do
plaintext = auth.token
auth.reload
expect(auth.read_attribute(:token)).to be_nil
expect(auth.token_digest).to eq(Digest::SHA256.hexdigest(plaintext))
end
it "stores a token in redis" do
auth
authorizations = redis_rs.smembers("authorizations:#{user.cn}:#{auth.token_digest}")
expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort)
end
@@ -49,7 +59,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
describe "#destroy" do
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("authorizations:*") }
it "removes the token from redis" do
auth = user.remote_storage_authorizations.create!(
@@ -59,7 +68,21 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
)
auth.destroy!
expect(redis_rs.keys("authorizations:#{user.address}:*")).to be_empty
expect(redis_rs.exists?("authorizations:#{user.cn}:#{auth.token_digest}")).to be(false)
end
it "removes a legacy plaintext token from redis" do
auth = user.remote_storage_authorizations.create!(
permissions: %w(shares:rw),
client_id: "sharesome.5apps.com",
redirect_uri: "https://sharesome.5apps.com"
)
auth.update_columns(token: "legacytoken", token_digest: nil)
redis_rs.sadd("authorizations:#{user.cn}:legacytoken", %w(shares:rw))
auth.destroy!
expect(redis_rs.exists?("authorizations:#{user.cn}:legacytoken")).to be(false)
end
context "with expiry set" do
@@ -79,8 +102,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
describe "#find_or_create_web_app" do
context "with origin that looks hosted" do
after(:all) { redis_rs_delete_keys("authorizations:*") }
let(:auth) do
user.remote_storage_authorizations.create!(
permissions: %w(documents:rw),
@@ -173,8 +194,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end
describe "#launch_url" do
after(:all) { redis_rs_delete_keys("authorizations:*") }
context "without start URL" do
before do
AppCatalog::WebApp.create!(
@@ -250,7 +269,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
include ActiveJob::TestHelper
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("authorizations:*") }
before { allow(user).to receive(:display_name).and_return("Jimmy") }
@@ -311,4 +329,88 @@ 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
describe "#device_label" do
def auth_with_user_agent(user_agent)
RemoteStorageAuthorization.new(user: user, user_agent: user_agent)
end
it "falls back to the unrecorded placeholder without a user agent" do
expect(auth_with_user_agent(nil).device_label).to eq("Unknown device(s)")
end
it "returns the device name when one can be determined" do
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
expect(auth_with_user_agent(ua).device_label).to eq("Firefox on Linux")
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,114 @@
require 'rails_helper'
RSpec.describe RemoteStorageManager::RekeyTokens, type: :model do
let(:user) { create :user, cn: "ronald", ou: "kosmos.org" }
let(:redis) { redis_rs }
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)
redis.keys("authorizations:*").each { |key| redis.del(key) }
end
def authorization_keys
redis.keys("authorizations:#{user.cn}:*")
end
# A row as it looked before #259: the plaintext token lives in the DB column
# and only the plaintext Redis key exists.
def legacy_authorization(token: "legacytoken", permissions: %w(documents:rw))
auth = create :remote_storage_authorization, user: user, permissions: permissions
auth.update_columns(token: token, token_digest: nil)
redis.keys("authorizations:#{user.cn}:*").each { |key| redis.del(key) }
redis.sadd("authorizations:#{user.cn}:#{token}", permissions)
auth
end
it "re-keys an authorization from the plaintext to the digest form" do
auth = legacy_authorization
digest = Digest::SHA256.hexdigest("legacytoken")
result = described_class.call
expect(result).to have_attributes(skipped: false, rekeyed: 1)
expect(redis.smembers("authorizations:#{user.cn}:#{digest}").sort).to eq(%w(documents:rw))
expect(redis.exists?("authorizations:#{user.cn}:legacytoken")).to be(false)
expect(auth.reload.token_digest).to eq(digest)
end
it "is idempotent" do
legacy_authorization
described_class.call
expect { described_class.call }.not_to raise_error
expect(authorization_keys.length).to eq(1)
end
it "backfills the digest for rows created while the old code was still running" do
auth = legacy_authorization
described_class.call
expect(auth.reload.token_digest).to eq(Digest::SHA256.hexdigest("legacytoken"))
end
it "falls back to the stored permissions when the legacy Redis key is gone" do
auth = create :remote_storage_authorization, user: user, permissions: %w(photos:rw)
auth.update_columns(token: "legacytoken", token_digest: nil)
authorization_keys.each { |key| redis.del(key) }
described_class.call
digest = Digest::SHA256.hexdigest("legacytoken")
expect(redis.smembers("authorizations:#{user.cn}:#{digest}").sort).to eq(%w(photos:rw))
end
it "removes leftover non-digest authorization keys" do
create :remote_storage_authorization, user: user
redis.sadd("authorizations:#{user.cn}:leftovertoken", %w(photos))
result = described_class.call
expect(result.legacy_removed).to eq(1)
expect(redis.exists?("authorizations:#{user.cn}:leftovertoken")).to be(false)
end
it "keeps digest-shaped keys that are not in the database" do
fresh = Digest::SHA256.hexdigest("brand-new-token")
redis.sadd("authorizations:#{user.cn}:#{fresh}", %w(documents:rw))
result = described_class.call
expect(redis.exists?("authorizations:#{user.cn}:#{fresh}")).to be(true)
expect(result.legacy_removed).to eq(0)
end
it "is a no-op once the plaintext token column is gone" do
allow(RemoteStorageAuthorization).to receive(:column_names)
.and_return(RemoteStorageAuthorization.column_names - ["token"])
expect(described_class.call).to have_attributes(skipped: true, rekeyed: 0)
end
describe "permissions parsing" do
subject(:service) { described_class.new }
it "parses a JSON array string" do
expect(service.send(:parse_permissions, '["documents:rw", "photos"]'))
.to eq(%w(documents:rw photos))
end
it "parses a YAML array string" do
expect(service.send(:parse_permissions, "---\n- documents:rw\n- photos\n"))
.to eq(%w(documents:rw photos))
end
it "parses a Postgres brace array string" do
expect(service.send(:parse_permissions, "{documents:rw,photos}"))
.to eq(%w(documents:rw photos))
end
end
end
@@ -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
-5
View File
@@ -1,8 +1,3 @@
def redis_rs
@redis_rs ||= Redis.new(url: Setting.rs_redis_url)
end
def redis_rs_delete_keys(pattern)
keys = redis_rs.keys(pattern)
redis_rs.del(*keys)
end