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)
\ No newline at end of file
diff --git a/app/javascript/turbo_stream_animations.js b/app/javascript/turbo_stream_animations.js
new file mode 100644
index 0000000..1cf8356
--- /dev/null
+++ b/app/javascript/turbo_stream_animations.js
@@ -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)
+ }
+})
\ No newline at end of file
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index d281380..0f2414b 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -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"
diff --git a/app/models/remote_storage_authorization.rb b/app/models/remote_storage_authorization.rb
index f6a7fed..6c7ae6e 100644
--- a/app/models/remote_storage_authorization.rb
+++ b/app/models/remote_storage_authorization.rb
@@ -2,6 +2,10 @@ class RemoteStorageAuthorization < ApplicationRecord
belongs_to :user
belongs_to :web_app, class_name: "AppCatalog::WebApp", optional: true
+ # 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 +21,8 @@ class RemoteStorageAuthorization < ApplicationRecord
before_create :generate_token
before_create :store_token_in_redis
before_create :find_or_create_web_app
- after_create :schedule_token_expiry
- after_create :notify_user
+ after_commit :schedule_token_expiry, on: :create
+ after_commit :notify_user, on: :create
before_destroy :delete_token_from_redis
after_destroy :remove_token_expiry_job
@@ -40,6 +44,38 @@ 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)
diff --git a/app/models/rs_authorization_code.rb b/app/models/rs_authorization_code.rb
new file mode 100644
index 0000000..482746c
--- /dev/null
+++ b/app/models/rs_authorization_code.rb
@@ -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
diff --git a/app/services/rs_oauth_manager/exchange_code.rb b/app/services/rs_oauth_manager/exchange_code.rb
new file mode 100644
index 0000000..8ed5298
--- /dev/null
+++ b/app/services/rs_oauth_manager/exchange_code.rb
@@ -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
diff --git a/app/services/rs_oauth_manager/issue_code.rb b/app/services/rs_oauth_manager/issue_code.rb
new file mode 100644
index 0000000..87ccb79
--- /dev/null
+++ b/app/services/rs_oauth_manager/issue_code.rb
@@ -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
diff --git a/app/services/rs_oauth_manager/verify_pkce.rb b/app/services/rs_oauth_manager/verify_pkce.rb
new file mode 100644
index 0000000..84fdc16
--- /dev/null
+++ b/app/services/rs_oauth_manager/verify_pkce.rb
@@ -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
diff --git a/app/services/rs_oauth_manager_service.rb b/app/services/rs_oauth_manager_service.rb
new file mode 100644
index 0000000..0b310db
--- /dev/null
+++ b/app/services/rs_oauth_manager_service.rb
@@ -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
diff --git a/app/views/rs/oauth/new.html.erb b/app/views/rs/oauth/new.html.erb
index 8cb9498..2668c14 100644
--- a/app/views/rs/oauth/new.html.erb
+++ b/app/views/rs/oauth/new.html.erb
@@ -5,34 +5,10 @@
The app on
<%= link_to @client_id, "https://#{@client_id}" %>
- is asking for access to these folders:
+ is asking for access to these categories:
- <% if @root_access_requested %>
-
-
- <%= render partial: "icons/alert-triangle",
- locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
- All files and directories
-
- <% if (@scopes & [":r"]).any? %>
- (read only)
- <% end %>
-
- <% else %>
- <% @scopes.each do |scope| %>
-
-
- <%= render partial: "icons/folder",
- locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
- <%= scope_name(scope) %>
-
- <% if scope_permissions(scope) == "r" %>
- (read only)
- <% end %>
-
- <% 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 %>
<%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %>
<%= f.select :expire_at, options_for_select(@expire_at_dates) %>
diff --git a/app/views/services/rs_auths/index.html.erb b/app/views/services/rs_auths/index.html.erb
index 939df14..cbb6352 100644
--- a/app/views/services/rs_auths/index.html.erb
+++ b/app/views/services/rs_auths/index.html.erb
@@ -10,10 +10,10 @@
<%= render partial: "shared/tabnav_remotestorage" %>
- <% if @rs_auths.any? %>
-
- <% @rs_auths.each do |auth| %>
- <%= render RsAuthComponent.new(auth: auth) %>
+ <% if @rs_apps.any? %>
+
+ <% @rs_apps.each do |authorizations| %>
+ <%= render RsAuthComponent.new(authorizations: authorizations) %>
<% end %>
<% else %>
diff --git a/config/importmap.rb b/config/importmap.rb
index cb396cc..495f9f2 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -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
diff --git a/config/recurring.yml b/config/recurring.yml
index d045b19..67b575c 100644
--- a/config/recurring.yml
+++ b/config/recurring.yml
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 548d59f..5b78bad 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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'
diff --git a/db/migrate/20261102120000_create_rs_authorization_codes.rb b/db/migrate/20261102120000_create_rs_authorization_codes.rb
new file mode 100644
index 0000000..38e4b3f
--- /dev/null
+++ b/db/migrate/20261102120000_create_rs_authorization_codes.rb
@@ -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
diff --git a/db/migrate/20261102130000_add_user_agent_to_rs_authorizations.rb b/db/migrate/20261102130000_add_user_agent_to_rs_authorizations.rb
new file mode 100644
index 0000000..d310450
--- /dev/null
+++ b/db/migrate/20261102130000_add_user_agent_to_rs_authorizations.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 15e3cda..4518582 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
+ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -110,6 +110,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
t.string "redirect_uri"
t.string "token"
t.datetime "updated_at", null: false
+ t.text "user_agent"
t.integer "user_id", null: false
t.integer "web_app_id"
t.index ["permissions"], name: "index_remote_storage_authorizations_on_permissions"
@@ -117,6 +118,23 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
t.index ["web_app_id"], name: "index_remote_storage_authorizations_on_web_app_id"
end
+ create_table "rs_authorization_codes", force: :cascade do |t|
+ t.string "client_id", null: false
+ t.string "code_challenge", null: false
+ t.string "code_challenge_method", default: "S256", null: false
+ t.string "code_digest", null: false
+ t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.datetime "permission_expire_at"
+ t.text "permissions", default: "[]"
+ t.string "redirect_uri", null: false
+ t.datetime "updated_at", null: false
+ t.text "user_agent"
+ t.integer "user_id", null: false
+ t.index ["code_digest"], name: "index_rs_authorization_codes_on_code_digest", unique: true
+ t.index ["user_id"], name: "index_rs_authorization_codes_on_user_id"
+ end
+
create_table "settings", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -164,5 +182,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "remote_storage_authorizations", "app_catalog_web_apps", column: "web_app_id"
add_foreign_key "remote_storage_authorizations", "users"
+ add_foreign_key "rs_authorization_codes", "users"
add_foreign_key "zaps", "users"
end
diff --git a/spec/controllers/rs/oauth_controller_spec.rb b/spec/controllers/rs/oauth_controller_spec.rb
index 78ea3d0..e06be1a 100644
--- a/spec/controllers/rs/oauth_controller_spec.rb
+++ b/spec/controllers/rs/oauth_controller_spec.rb
@@ -128,8 +128,10 @@ RSpec.describe Rs::OauthController, type: :controller do
}
end
- it "redirects to the redirect_uri with the existing token" do
- expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}")
+ it "does not reuse the existing token (renders the consent dialog)" do
+ expect(response.response_code).to eq(200)
+ expect(response.location).to be_nil
+ expect(user.remote_storage_authorizations.count).to eq(1)
end
end
@@ -147,36 +149,100 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
end
end
+ end
- context "with different redirect_uri" do
- before do
- get :new, params: {
- username: user.cn,
- scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
- redirect_uri: "https://example.com/a_new_route",
- client_id: "https://example.com"
- }
- end
-
- it "redirects to the new redirect_uri" do
- expect(response).to redirect_to("https://example.com/a_new_route#access_token=#{@auth.token}")
- end
+ context "when response_type is code" do
+ before do
+ get :new, params: {
+ username: user.cn,
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ scope: "documents",
+ response_type: "code",
+ code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ code_challenge_method: "S256",
+ state: "foobar123"
+ }
end
- context "with state param given" do
- before do
- get :new, params: {
- username: user.cn,
- scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
- redirect_uri: "https://example.com",
- client_id: "https://example.com",
- state: "foobar123"
- }
- end
+ it "renders the consent dialog and exposes the code challenge" do
+ expect(response.response_code).to eq(200)
+ expect(assigns["response_type"]).to eq("code")
+ expect(assigns["code_challenge"]).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+ expect(assigns["code_challenge_method"]).to eq("S256")
+ end
- it "redirects to the redirect_uri with token and state" do
- expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
- end
+ it "uses a query-based denial url" do
+ expect(assigns["denial_url"]).to eq("https://example.com?error=access_denied&state=foobar123")
+ end
+ end
+
+ context "when response_type is code without a code_challenge" do
+ before do
+ get :new, params: {
+ username: user.cn,
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ scope: "documents",
+ response_type: "code",
+ state: "foobar123"
+ }
+ end
+
+ it "redirects with invalid_request using a query param" do
+ expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
+ end
+ end
+
+ context "when response_type is code with the plain method" do
+ before do
+ get :new, params: {
+ username: user.cn,
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ scope: "documents",
+ response_type: "code",
+ code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ code_challenge_method: "plain"
+ }
+ end
+
+ it "redirects with invalid_request" do
+ expect(response).to redirect_to("https://example.com?error=invalid_request")
+ end
+ end
+
+ context "with an unsupported response_type" do
+ before do
+ get :new, params: {
+ username: user.cn,
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ scope: "documents",
+ response_type: "id_token"
+ }
+ end
+
+ it "redirects with unsupported_response_type" do
+ expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
+ end
+ end
+
+ context "with a malformed code_challenge" do
+ before do
+ get :new, params: {
+ username: user.cn,
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ scope: "documents",
+ response_type: "code",
+ code_challenge: "too-short",
+ code_challenge_method: "S256"
+ }
+ end
+
+ it "redirects with invalid_request" do
+ expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
@@ -280,10 +346,12 @@ RSpec.describe Rs::OauthController, type: :controller do
context "when user is signed in" do
before do
sign_in user
+ request.user_agent = "RSpec Device"
end
after do
user.remote_storage_authorizations.destroy_all
+ RsAuthorizationCode.destroy_all
end
context "when no valid token exists" do
@@ -303,6 +371,10 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r))
end
+ it "stores the device's user agent" do
+ expect(@auth.user_agent).to eq("RSpec Device")
+ end
+
it "redirects to the redirect_uri" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
@@ -412,6 +484,101 @@ RSpec.describe Rs::OauthController, type: :controller do
end
end
+ context "with response_type=code" do
+ before do
+ post :create, params: {
+ user_id: user.id,
+ scope: "documents,[photos], contacts:rw",
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ state: "foobar123",
+ response_type: "code",
+ code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ code_challenge_method: "S256",
+ expire_at: 1.month.from_now
+ }
+ @code = RsAuthorizationCode.last
+ end
+
+ it "creates an authorization code" do
+ expect(@code.permissions).to eq(%w(documents photos contacts:rw))
+ expect(@code.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+ expect(@code.code_challenge_method).to eq("S256")
+ expect(@code.user_agent).to eq("RSpec Device")
+ end
+
+ it "does not create a remote storage authorization" do
+ expect(user.reload.remote_storage_authorizations.count).to eq(0)
+ end
+
+ it "redirects with the code in the query string" do
+ location = response.location
+ expect(location).to match(%r{\Ahttps://example\.com\?code=.+&state=foobar123\z})
+
+ raw_code = CGI.parse(URI.parse(location).query)["code"].first
+ expect(RsAuthorizationCode.find_by_code(raw_code)).to eq(@code)
+ end
+ end
+
+ context "with response_type=code but no code_challenge" do
+ before do
+ post :create, params: {
+ user_id: user.id,
+ scope: "documents",
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ state: "foobar123",
+ response_type: "code",
+ expire_at: 1.month.from_now
+ }
+ end
+
+ it "redirects with invalid_request using a query param" do
+ expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
+ end
+
+ it "does not create an authorization code" do
+ expect(RsAuthorizationCode.count).to eq(0)
+ end
+ end
+
+ context "with an unsupported response_type" do
+ before do
+ post :create, params: {
+ user_id: user.id,
+ scope: "documents",
+ redirect_uri: "https://example.com",
+ client_id: "example.com",
+ response_type: "id_token",
+ expire_at: 1.month.from_now
+ }
+ end
+
+ it "redirects with unsupported_response_type" do
+ expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
+ end
+ end
+
+ context "with response_type=code and a redirect_uri that already has a query" do
+ before do
+ post :create, params: {
+ user_id: user.id,
+ scope: "documents",
+ redirect_uri: "https://example.com/?foo=bar",
+ client_id: "example.com",
+ state: "foobar123",
+ response_type: "code",
+ code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ code_challenge_method: "S256",
+ expire_at: 1.month.from_now
+ }
+ end
+
+ it "appends the code with an ampersand" do
+ expect(response.location).to match(%r{\Ahttps://example\.com/\?foo=bar&code=.+&state=foobar123\z})
+ end
+ end
+
context "when the user_id is different from the signed in user" do
before do
post :create, params: {
@@ -451,4 +618,98 @@ RSpec.describe Rs::OauthController, type: :controller do
end
end
end
+
+ describe "POST /rs/oauth/token" do
+ # Example values from RFC 7636, Appendix B
+ let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
+ let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
+
+ def issue_code
+ RsOauthManager::IssueCode.call(
+ user: user,
+ client_id: "example.com",
+ redirect_uri: "https://example.com",
+ permissions: %w(documents:rw),
+ code_challenge: code_challenge,
+ code_challenge_method: "S256"
+ )
+ end
+
+ def token_params(code, **overrides)
+ {
+ grant_type: "authorization_code",
+ code: code.code,
+ client_id: "https://example.com",
+ redirect_uri: "https://example.com",
+ code_verifier: code_verifier
+ }.merge(overrides)
+ end
+
+ after do
+ user.remote_storage_authorizations.destroy_all
+ RsAuthorizationCode.destroy_all
+ end
+
+ context "with a valid code" do
+ before do
+ @code = issue_code
+ post :token, params: token_params(@code)
+ end
+
+ it "returns an access token" do
+ expect(response.response_code).to eq(200)
+
+ json = JSON.parse(response.body)
+ expect(json["access_token"]).to be_present
+ expect(json["token_type"]).to eq("bearer")
+ expect(json["scope"]).to eq("documents:rw")
+ end
+
+ it "does not cache the response" do
+ expect(response.headers["Cache-Control"]).to eq("no-store")
+ end
+
+ it "sets CORS headers" do
+ expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
+ end
+ end
+
+ context "with a bad code_verifier" do
+ before do
+ @code = issue_code
+ post :token, params: token_params(@code, code_verifier: "wrong-verifier")
+ end
+
+ it "returns invalid_grant" do
+ expect(response.response_code).to eq(400)
+ expect(JSON.parse(response.body)["error"]).to eq("invalid_grant")
+ end
+ end
+
+ context "with an unsupported grant_type" do
+ before { post :token, params: { grant_type: "password" } }
+
+ it "returns unsupported_grant_type" do
+ expect(response.response_code).to eq(400)
+ expect(JSON.parse(response.body)["error"]).to eq("unsupported_grant_type")
+ end
+ end
+
+ context "with missing required parameters" do
+ before { post :token, params: { grant_type: "authorization_code", code: "x" } }
+
+ it "returns invalid_request" do
+ expect(response.response_code).to eq(400)
+ expect(JSON.parse(response.body)["error"]).to eq("invalid_request")
+ end
+ end
+
+ context "on error" do
+ before { post :token, params: { grant_type: "password" } }
+
+ it "does not cache the response" do
+ expect(response.headers["Cache-Control"]).to eq("no-store")
+ end
+ end
+ end
end
diff --git a/spec/controllers/services/rs_auths_controller_spec.rb b/spec/controllers/services/rs_auths_controller_spec.rb
index 1c619c2..cd4813a 100644
--- a/spec/controllers/services/rs_auths_controller_spec.rb
+++ b/spec/controllers/services/rs_auths_controller_spec.rb
@@ -11,13 +11,13 @@ RSpec.describe Services::RsAuthsController, type: :controller do
])
end
- describe "GET /services/storage/rs_auths/:id/launch_app" do
+ describe "GET /services/storage/apps/:client_id/launch (storage-first)" do
context "when user is signed in" do
before do
sign_in user
end
- context "token exists" do
+ context "authorization exists" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
@@ -25,17 +25,21 @@ RSpec.describe Services::RsAuthsController, type: :controller do
expire_at: 2.days.from_now
)
- get :launch_app, params: { id: @auth.id }
+ get :launch_app, params: { client_id: @auth.client_id }
end
after do
@auth.destroy
end
- it "redirects to the given URL with the correct RS URL fragment params" do
+ it "hands the app the user address in the URL fragment" do
launch_url = "https://app.example.com#remotestorage=#{user.address}"
expect(response).to redirect_to(launch_url)
end
+
+ it "does not hand over an access token" do
+ expect(response.location).not_to include("access_token")
+ end
end
context "when remoteStorage is not enabled for the user" do
@@ -44,7 +48,7 @@ RSpec.describe Services::RsAuthsController, type: :controller do
{ services_enabled: [] }
])
- get :launch_app, params: { id: 1 }
+ get :launch_app, params: { client_id: "app.example.com" }
end
it "responds with forbidden" do
@@ -53,4 +57,108 @@ RSpec.describe Services::RsAuthsController, type: :controller do
end
end
end
+
+ describe "GET /services/storage/apps/:client_id/launch for an unknown app" do
+ before { sign_in user }
+
+ it "returns 404 instead of raising" do
+ get :launch_app, params: { client_id: "nonexistent.example.com" }
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "DELETE /services/storage/rs_auths/:id" do
+ context "when user is signed in" do
+ before { sign_in user }
+
+ it "revokes the authorization" do
+ auth = user.remote_storage_authorizations.create!(
+ permissions: %w(documents), client_id: "app.example.com",
+ redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
+ )
+
+ expect {
+ delete :destroy, params: { id: auth.id }
+ }.to change(RemoteStorageAuthorization, :count).by(-1)
+
+ expect(response).to redirect_to(apps_services_storage_url)
+ end
+
+ it "returns 404 for an unknown authorization" do
+ delete :destroy, params: { id: 0 }
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "returns 404 for another user's authorization" do
+ other = create :user, id: 99, cn: "otheruser", email: "other@example.com"
+ auth = other.remote_storage_authorizations.create!(
+ permissions: %w(documents), client_id: "app.example.com",
+ redirect_uri: "https://app.example.com"
+ )
+
+ delete :destroy, params: { id: auth.id }
+
+ expect(response).to have_http_status(:not_found)
+ expect(RemoteStorageAuthorization.exists?(auth.id)).to be(true)
+ end
+
+ 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
end
diff --git a/spec/features/rs/oauth_spec.rb b/spec/features/rs/oauth_spec.rb
index 9e499b8..085198e 100644
--- a/spec/features/rs/oauth_spec.rb
+++ b/spec/features/rs/oauth_spec.rb
@@ -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
diff --git a/spec/features/services/rs_auths_spec.rb b/spec/features/services/rs_auths_spec.rb
new file mode 100644
index 0000000..edb2f20
--- /dev/null
+++ b/spec/features/services/rs_auths_spec.rb
@@ -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
diff --git a/spec/models/remote_storage_authorization_spec.rb b/spec/models/remote_storage_authorization_spec.rb
index 378ebb6..7616603 100644
--- a/spec/models/remote_storage_authorization_spec.rb
+++ b/spec/models/remote_storage_authorization_spec.rb
@@ -303,4 +303,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
diff --git a/spec/models/rs_authorization_code_spec.rb b/spec/models/rs_authorization_code_spec.rb
new file mode 100644
index 0000000..4593446
--- /dev/null
+++ b/spec/models/rs_authorization_code_spec.rb
@@ -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
diff --git a/spec/requests/rs/oauth_spec.rb b/spec/requests/rs/oauth_spec.rb
new file mode 100644
index 0000000..84429d5
--- /dev/null
+++ b/spec/requests/rs/oauth_spec.rb
@@ -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
diff --git a/spec/requests/webfinger_spec.rb b/spec/requests/webfinger_spec.rb
index a37d3cb..fc063d2 100644
--- a/spec/requests/webfinger_spec.rb
+++ b/spec/requests/webfinger_spec.rb
@@ -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
diff --git a/spec/services/rs_oauth_manager/exchange_code_spec.rb b/spec/services/rs_oauth_manager/exchange_code_spec.rb
new file mode 100644
index 0000000..e0169a2
--- /dev/null
+++ b/spec/services/rs_oauth_manager/exchange_code_spec.rb
@@ -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
diff --git a/spec/services/rs_oauth_manager/issue_code_spec.rb b/spec/services/rs_oauth_manager/issue_code_spec.rb
new file mode 100644
index 0000000..3e424d1
--- /dev/null
+++ b/spec/services/rs_oauth_manager/issue_code_spec.rb
@@ -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
diff --git a/spec/services/rs_oauth_manager/verify_pkce_spec.rb b/spec/services/rs_oauth_manager/verify_pkce_spec.rb
new file mode 100644
index 0000000..46deef7
--- /dev/null
+++ b/spec/services/rs_oauth_manager/verify_pkce_spec.rb
@@ -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