Merge pull request 'remoteStorage OAuth' (#109) from feature/rs-oauth into master
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #109
This commit is contained in:
Râu Cao 2023-08-04 08:55:28 +00:00
commit 852e2fea1e
29 changed files with 1156 additions and 8 deletions

View File

@ -20,6 +20,8 @@ steps:
image: guildeducation/rails:2.7.2-14.20.0
environment:
RAILS_ENV: test
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
commands:
- bundle config unset deployment
- bundle config set cache_all 'true'
@ -42,6 +44,10 @@ steps:
branch:
- master
services:
- name: redis
image: redis
volumes:
- name: cache
host:

View File

@ -26,6 +26,7 @@ GITEA_PUBLIC_URL='https://gitea.kosmos.org'
MASTODON_PUBLIC_URL='https://kosmos.social'
MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org'
RS_STORAGE_URL='https://storage.kosmos.org'
RS_REDIS_URL='redis://localhost:6379/2'
EJABBERD_ADMIN_URL='https://xmpp.kosmos.org/admin'
EJABBERD_API_URL='https://xmpp.kosmos.org/api'

View File

@ -1,5 +1,7 @@
PRIMARY_DOMAIN=kosmos.org
REDIS_URL='redis://localhost:6379/0'
DISCOURSE_PUBLIC_URL='http://discourse.example.com'
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
@ -12,5 +14,6 @@ LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
RS_STORAGE_URL='https://storage.kosmos.org'
RS_REDIS_URL='redis://localhost:6379/1'
WEBHOOKS_ALLOWED_IPS='10.1.1.23'

View File

@ -64,6 +64,7 @@ group :development, :test do
# Use sqlite3 as the database for Active Record
gem 'sqlite3', '~> 1.4'
gem 'rspec-rails'
gem 'rails-controller-testing'
gem "byebug", "~> 11.1"
end

View File

@ -234,6 +234,8 @@ GEM
net-smtp (0.3.3)
net-protocol
nio4r (2.5.9)
nokogiri (1.15.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.2-x86_64-linux)
racc (~> 1.4)
orm_adapter (0.5.0)
@ -267,6 +269,10 @@ GEM
activesupport (= 7.0.5)
bundler (>= 1.15.0)
railties (= 7.0.5)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
@ -373,6 +379,7 @@ GEM
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sqlite3 (1.6.3-arm64-darwin)
sqlite3 (1.6.3-x86_64-linux)
stimulus-rails (1.2.1)
railties (>= 6.0.0)
@ -410,6 +417,7 @@ GEM
zeitwerk (2.6.8)
PLATFORMS
arm64-darwin-22
x86_64-linux
DEPENDENCIES
@ -440,6 +448,7 @@ DEPENDENCIES
pg (~> 1.2.3)
puma (~> 4.1)
rails (~> 7.0.2)
rails-controller-testing
rails-settings-cached (~> 2.8.3)
rqrcode (~> 2.0)
rspec-rails

View File

@ -0,0 +1,145 @@
class Rs::OauthController < ApplicationController
before_action :require_signed_in_with_username, only: :new
before_action :authenticate_user!, only: :create
def new
username, org = params[:useraddress].split("@")
@user = User.where(cn: username.downcase, ou: org).first
@scopes = parse_scopes params[:scope]
@redirect_uri = params[:redirect_uri]
@client_id = params[:client_id]
@state = params[:state]
@root_access_requested = (@scopes & [":r",":rw"]).any?
@denial_url = url_with_state("#{@redirect_uri}#error=access_denied", @state)
@expire_at_dates = [["Never", nil],
["In 1 month", 1.month.from_now],
["In 1 day", 1.day.from_now]]
http_status :bad_request and return unless @redirect_uri.present?
unless current_user == @user
sign_out :user
redirect_to new_rs_oauth_url(@user.address,
scope: params[:scope],
redirect_uri: params[:redirect_uri],
client_id: params[:client_id],
state: params[:state])
return
end
unless @client_id.present?
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_request", @state),
allow_other_host: true) and return
end
if @scopes.empty?
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_scope", @state),
allow_other_host: true) 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
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
unless current_user.id.to_s == params[:user_id]
Rails.logger.info("NO MATCH: #{params[:user_id]}, #{current_user.id}")
http_status :forbidden and return
end
permissions = parse_scopes params[:scope]
redirect_uri = params[:redirect_uri].presence
client_id = params[:client_id].presence
state = params[:state].presence
expire_at = params[:expire_at].presence
http_status :bad_request and return unless redirect_uri.present?
if permissions.empty?
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
allow_other_host: true) and return
end
unless client_id.present?
redirect_to(url_with_state("#{redirect_uri}#error=invalid_request", state),
allow_other_host: true) 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
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, #TODO use user-defined name
expire_at: expire_at
)
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
allow_other_host: true
end
# GET /rs/oauth/token/:id/launch_app
def launch_app
auth = current_user.remote_storage_authorizations.find(params[:id])
redirect_to app_auth_url(auth), allow_other_host: true
end
private
def require_signed_in_with_username
unless user_signed_in?
username, org = params[:useraddress].split("@")
redirect_to new_user_session_path(cn: username, ou: org)
end
end
def app_auth_url(auth)
url = "#{auth.url}#remotestorage=#{current_user.address}"
url += "&access_token=#{auth.token}"
url
end
def hostname_of(uri)
uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
end
def 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 url_with_state(url, state)
state ? "#{url}&state=#{CGI.escape(state)}" : url
end
end

View File

@ -0,0 +1,11 @@
module OauthHelper
def scope_name(scope)
scope.gsub(/(\:.+)/, '')
end
def scope_permissions(scope)
scope.match(/\:r$/) ? "r" : "rw"
end
end

View File

@ -0,0 +1,10 @@
class RemoteStorageExpireAuthorizationJob < ApplicationJob
queue_as :remotestorage
def perform(rs_auth_id)
rs_auth = RemoteStorageAuthorization.find rs_auth_id
return unless rs_auth.expire_at.nil? || rs_auth.expire_at <= DateTime.now
rs_auth.destroy!
end
end

View File

@ -0,0 +1,63 @@
class RemoteStorageAuthorization < ApplicationRecord
belongs_to :user
serialize :permissions
validates_presence_of :permissions
validates_presence_of :client_id
scope :valid, -> { where(expire_at: nil).or(where(expire_at: (DateTime.now)..)) }
scope :expired, -> { where(expire_at: ..(DateTime.now)) }
after_initialize do |a|
a.permissions = [] if a.permissions == nil
end
before_create :generate_token
before_create :store_token_in_redis
after_create :schedule_token_expiry
before_destroy :delete_token_from_redis
after_destroy :remove_token_expiry_job
def url
if self.redirect_uri
uri = URI.parse self.redirect_uri
"#{uri.scheme}://#{client_id}"
else
"http://#{client_id}"
end
end
def delete_token_from_redis
key = "rs:authorizations:#{user.address}:#{token}"
redis.srem? key, redis.smembers(key)
end
private
def redis
@redis ||= Redis.new(url: Setting.rs_redis_url)
end
def generate_token(length=16)
self.token = SecureRandom.hex(length) if self.token.blank?
end
def store_token_in_redis
redis.sadd "rs:authorizations:#{user.address}:#{token}", permissions
end
def schedule_token_expiry
return unless expire_at.present?
RemoteStorageExpireAuthorizationJob.set(wait_until: expire_at)
.perform_later(id)
end
def remove_token_expiry_job
queue = Sidekiq::Queue.new(RemoteStorageExpireAuthorizationJob.queue_name)
queue.each do |job|
next unless job.display_class == "RemoteStorageExpireAuthorizationJob"
job.delete if job.display_args == [id]
end
end
end

View File

@ -12,7 +12,7 @@ class Setting < RailsSettings::Base
# Internal services
#
field :redis_url, type: :string, readonly: true,
field :redis_url, type: :string,
default: ENV["REDIS_URL"] || "redis://localhost:6379/0"
#
@ -131,4 +131,7 @@ class Setting < RailsSettings::Base
field :rs_storage_url, type: :string,
default: ENV["RS_STORAGE_URL"].presence
field :rs_redis_url, type: :string,
default: ENV["RS_REDIS_URL"] || "redis://localhost:6379/1"
end

View File

@ -18,6 +18,8 @@ class User < ApplicationRecord
has_many :accounts, through: :lndhub_user
has_many :remote_storage_authorizations
validates_uniqueness_of :cn, scope: :ou
validates_length_of :cn, minimum: 3
validates_format_of :cn, with: /\A([a-z0-9\-])*\z/,

View File

@ -11,7 +11,11 @@
<% if Setting.remotestorage_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :rs_storage_url,
title: "Storage URL"
title: "Storage Base URL"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :rs_redis_url,
title: "Redis URL"
) %>
<% end %>
</ul>

View File

@ -12,7 +12,8 @@
<div class="mb-6">
<%= f.label :cn, 'User', class: 'block mb-2 font-bold' %>
<p class="flex gap-2 items-center">
<%= f.text_field :cn, autofocus: true, autocomplete: "username",
<%= f.text_field :cn, value: h(params[:cn]),
autofocus: params[:cn].blank?, autocomplete: "username",
required: true, class: "relative grow", tabindex: "1" %>
<span class="relative shrink-0 text-gray-500">@ <%= Setting.primary_domain %></span>
</p>
@ -20,7 +21,8 @@
<p class="mb-8">
<%= f.label :password, class: 'block mb-2 font-bold' %>
<%= f.password_field :password, autocomplete: "current-password",
required: true, class: "w-full", tabindex: "2" %>
autofocus: params[:cn].present?, required: true,
class: "w-full", tabindex: "2" %>
</p>
<%= tag.div class: "flex items-center mb-8 gap-x-3", data: {

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle <%= custom_class %>"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>

Before

Width:  |  Height:  |  Size: 424 B

After

Width:  |  Height:  |  Size: 445 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 512 512" fill="currentColor" stroke="currentColor" stroke-width="2" class="<%= custom_class %>"><path d="M475.31 364.144L288 256l187.31-108.144c5.74-3.314 7.706-10.653 4.392-16.392l-4-6.928c-3.314-5.74-10.653-7.706-16.392-4.392L272 228.287V12c0-6.627-5.373-12-12-12h-8c-6.627 0-12 5.373-12 12v216.287L52.69 120.144c-5.74-3.314-13.079-1.347-16.392 4.392l-4 6.928c-3.314 5.74-1.347 13.079 4.392 16.392L224 256 36.69 364.144c-5.74 3.314-7.706 10.653-4.392 16.392l4 6.928c3.314 5.74 10.653 7.706 16.392 4.392L240 283.713V500c0 6.627 5.373 12 12 12h8c6.627 0 12-5.373 12-12V283.713l187.31 108.143c5.74 3.314 13.079 1.347 16.392-4.392l4-6.928c3.314-5.74 1.347-13.079-4.392-16.392z"/></svg>

After

Width:  |  Height:  |  Size: 760 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder <%= custom_class %>"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 331 B

View File

@ -0,0 +1,58 @@
<%= render HeaderCompactComponent.new(title: "Storage") %>
<%= render MainCompactComponent.new do %>
<section class="permissions">
<p class="mb-8">
The app on
<%= link_to @client_id, "https://#{@client_id}", class: "ks-text-link" %>
is asking for access to these folders:
</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 %>
<%= form_with(url: rs_oauth_path, method: :post, data: { turbo: false }) do |f| %>
<%= f.hidden_field :redirect_uri, value: @redirect_uri %>
<%= f.hidden_field :scope, value: @scopes.join(" ") %>
<%= f.hidden_field :user_id, value: @user.id %>
<%= f.hidden_field :client_id, value: @client_id %>
<%= f.hidden_field :state, value: @state %>
<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) %>
</p>
<p class="text-sm text-gray-500">
You can revoke access for this app at any time on your storage dashboard.
</p>
<p class="mt-8 flex flex-col sm:flex-row gap-3 sm:gap-2 sm:justify-items-stretch">
<%= f.submit "Allow",
class: "btn-md btn-blue w-full sm:order-last sm:grow",
data: { disable_with: "Saving..." } %>
<%= link_to "Deny", @denial_url, class: "btn-md btn-gray text-red-700 w-full sm:grow" %>
</div>
<% end %>
</section>
<% end %>

View File

@ -0,0 +1,6 @@
<%= render HeaderCompactComponent.new(title: "404") %>
<%= render MainCompactComponent.new do %>
<h2>Bad request</h2>
<p>Please go back and try again.</p>
<% end %>

View File

@ -66,7 +66,14 @@ Rails.application.routes.draw do
end
end
get ".well-known/webfinger", to: 'webfinger#show'
namespace :rs do
resource :oauth, only: [:new, :create], path_names: {
new: ':useraddress', create: ':useraddress'
}, controller: 'oauth', constraints: { useraddress: /[^\/]+/}
get 'oauth/token/:id/launch_app' => 'oauth#launch_app', as: :launch_app
end
get '.well-known/webfinger', to: 'webfinger#show'
namespace :discourse do
get "connect", to: 'sso#connect'

View File

@ -0,0 +1,17 @@
class CreateRemoteStorageAuthorizations < ActiveRecord::Migration[7.0]
def change
create_table :remote_storage_authorizations do |t|
t.references :user, null: false, foreign_key: true
t.string :token
t.text :permissions, array: true, default: [].to_yaml
t.string :client_id
t.string :redirect_uri
t.string :app_name
t.datetime :expire_at
t.timestamps
end
add_index :remote_storage_authorizations, :permissions, using: 'gin'
end
end

View File

@ -87,10 +87,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do
t.text "ln_login_ciphertext"
t.text "ln_password_ciphertext"
t.string "ln_account"
t.string "nostr_pubkey"
t.datetime "remember_created_at"
t.string "remember_token"
t.text "preferences"
t.string "nostr_pubkey"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end

View File

@ -38,6 +38,7 @@ services:
RAILS_ENV: development
PRIMARY_DOMAIN: kosmos.org
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
LDAP_HOST: ldap
LDAP_PORT: 3389
LDAP_ADMIN_PASSWORD: passthebutter
@ -57,6 +58,7 @@ services:
RAILS_ENV: development
PRIMARY_DOMAIN: kosmos.org
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
LDAP_HOST: ldap
LDAP_PORT: 3389
LDAP_ADMIN_PASSWORD: passthebutter

View File

@ -0,0 +1,465 @@
require 'rails_helper'
RSpec.describe Rs::OauthController, type: :controller do
let(:user) { create :user }
describe "GET /rs/oauth/:useraddress" do
context "when user is signed in" do
before do
sign_in user
end
context "when username is different than current user" do
let(:other_user) { create :user, id: 23, cn: "jomokenyatta", email: "jomo@hotmail.com" }
before do
get :new, params: {
useraddress: other_user.address,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "examples"
}
end
it "logs out the users and repeats the request" do
url = new_rs_oauth_url other_user.address,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "examples"
expect(response).to redirect_to(url)
end
end
context "when no valid token exists" do
before do
get :new, params: {
useraddress: user.address,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
state: "foobar123"
}
end
it "returns a 200" do
expect(response.response_code).to eq(200)
end
it "sets the instance variables" do
expected_scopes = %w(documents photos contacts:rw videos:r tasks/work:r)
expect(assigns["user"]).to eq(user)
expect(assigns["redirect_uri"]).to eq("https://example.com")
expect(assigns["scopes"]).to eq(expected_scopes)
expect(assigns["client_id"]).to eq("example.com")
expect(assigns["root_access_requested"]).to eq(false)
expect(assigns["state"]).to eq("foobar123")
expect(assigns["denial_url"]).to eq("https://example.com#error=access_denied&state=foobar123")
end
context "no redirect_uri" do
before do
get :new, params: {
useraddress: user.address,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
client_id: "https://example.com"
}
end
it "returns a 400" do
expect(response.response_code).to eq(400)
end
end
context "no client_id" do
before do
get :new, params: {
useraddress: user.address,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com"
}
end
it "redirects with invalid_request error" do
expect(response).to redirect_to("https://example.com#error=invalid_request")
end
end
context "different host for client_id and redirect_uri" do
before do
get :new, params: {
useraddress: user.address,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com/foobar",
client_id: "https://google.com"
}
end
it "redirects with invalid_client error" do
expect(response).to redirect_to("https://example.com/foobar#error=invalid_client")
end
end
end
context "when valid token already exists" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
client_id: "example.com", redirect_uri: "https://example.com",
expire_at: 1.day.from_now
)
end
after { @auth.destroy }
context "with same host for client_id and redirect_uri" do
before do
get :new, params: {
useraddress: user.address,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "https://example.com"
}
end
it "redirects to the redirect_uri with the existing token" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}")
end
end
context "with different host for client_id and redirect_uri" do
before do
get :new, params: {
useraddress: user.address,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://app.example.com",
client_id: "https://example.com"
}
end
it "redirects with invalid_client error" do
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
end
end
context "with different redirect_uri" do
before do
get :new, params: {
useraddress: user.address,
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
end
context "with state param given" do
before do
get :new, params: {
useraddress: user.address,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
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
end
end
context "no scope" do
before do
get :new, params: {
useraddress: user.address,
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
end
it "redirects to the redirect_uri with an error code" do
expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123")
end
end
context "empty scope" do
before do
get :new, params: {
useraddress: user.address,
scope: "",
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
end
it "redirects to the redirect_uri with an error code" do
expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123")
end
end
end
context "when user is not signed in" do
it "redirects to the signin page with username pre-filled" do
get :new, params: {
useraddress: user.address,
scope: "documents,photos",
redirect_uri: "https://example.com"
}
expect(response).to redirect_to(new_user_session_path(cn: user.cn, ou: user.ou))
end
end
describe "root access" do
before do
sign_in user
end
describe "full" do
before do
get :new, params: {
useraddress: user.address,
scope: "*:rw",
redirect_uri: "https://example.com",
client_id: "example.com"
}
end
it "sets the instance variables" do
expect(assigns["scopes"]).to eq([":rw"])
expect(assigns["root_access_requested"]).to be(true)
end
end
describe "read-only" do
before do
get :new, params: {
useraddress: user.address,
scope: "*:r",
redirect_uri: "https://example.com",
client_id: "example.com"
}
end
it "sets the instance variables" do
expect(assigns["scopes"]).to eq([":r"])
expect(assigns["root_access_requested"]).to be(true)
end
end
end
end
describe "POST /rs/oauth/:useraddress" do
context "when user is signed in" do
before do
sign_in user
end
after do
user.remote_storage_authorizations.destroy_all
end
context "when no valid token exists" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
expire_at: 1.day.from_now
}
@auth = user.reload.remote_storage_authorizations.first
end
it "creates a new token" do
expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r))
end
it "redirects to the redirect_uri" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
end
context "when token is expired" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents),
client_id: "example.com",
redirect_uri: "https://example.com",
expire_at: 1.day.ago,
token: nil
)
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
expire_at: 1.month.from_now
}
end
it "updates the token" do
expect(@auth.reload.token).not_to be_nil
end
end
context "root access with several scopes" do
before do
post :create, params: {
user_id: user.id,
scope: "*:rw contacts:r",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
@auth = user.reload.remote_storage_authorizations.first
end
it "removes all scopes except for the root permission" do
expect(@auth.permissions).to eq(%w(:rw))
end
end
context "no redirect_uri" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
client_id: "example.com",
expire_at: 1.month.from_now
}
end
it "returns a 400" do
expect(response.response_code).to eq(400)
end
end
context "no client_id" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
expire_at: 1.month.from_now
}
end
it "redirects with invalid_request error" do
expect(response).to redirect_to("https://example.com#error=invalid_request")
end
end
context "hostnames of client_id and redirect_uri do not match" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
client_id: "fishing.com",
redirect_uri: "https://example.com",
expire_at: 1.month.from_now
}
end
it "redirects with invalid_client error" do
expect(response).to redirect_to("https://example.com#error=invalid_client")
end
end
context "empty scope" do
before do
post :create, params: {
user_id: user.id,
scope: "",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
expire_at: 1.month.from_now
}
end
it "redirects to the redirect_uri with an error code" do
expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123")
end
end
context "when the user_id is different from the signed in user" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,photos",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
end
it "returns a 403" do
post :create, params: {
user_id: "69",
scope: "documents,photos",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
expect(response.response_code).to eq(403)
end
end
end
context "when user is not signed in" do
it "redirects to the signin page" do
post :create, params: {
user_id: user.id,
scope: "documents,photos",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe "GET /rs/oauth/token/:id/launch_app" do
context "when user is signed in" do
before do
sign_in user
end
context "token exists" do
before 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
)
get :launch_app, params: { id: @auth.id }
end
after do
@auth.destroy
end
it "redirects to the given URL with the correct RS URL fragment params" do
launch_url = "https://app.example.com#remotestorage=#{user.address}&access_token=#{@auth.token}"
expect(response).to redirect_to(launch_url)
end
end
end
end
end

View File

@ -0,0 +1,9 @@
FactoryBot.define do
factory :remote_storage_authorization do
permissions { ["documents:rw"] }
client_id { "some-fancy-app" }
redirect_uri { "https://example.com/some-fancy-app" }
app_name { "Fancy App" }
expire_at { nil }
end
end

View File

@ -0,0 +1,66 @@
require 'rails_helper'
RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
context "when signed in" do
let(:user) { create :user }
before do
login_as user, :scope => :user
end
context "with normal permissions" do
before do
visit new_rs_oauth_path(useraddress: user.address,
redirect_uri: "http://example.com",
client_id: "http://example.com",
scope: "documents,[photos], contacts:r")
end
it "shows the permissions in a list" do
within ".permissions" do
expect(page).to have_content("documents")
expect(page).to have_content("photos")
expect(page).to have_content("contacts")
end
within ".scope:first-of-type" do
expect(page).not_to have_content("read only")
end
within ".scope:last-of-type" do
expect(page).to have_content("read only")
end
end
end
context "root access" do
context "full" do
before do
visit new_rs_oauth_path(useraddress: user.address,
redirect_uri: "http://example.com",
client_id: "http://example.com",
scope: ":rw")
end
it "shows a special permission for all files and dirs" do
within ".scope" do
expect(page).to have_content("All files and directories")
end
end
end
end
end
context "when signed out" do
let(:user) { create :user }
it "prefills the username field in the signin form" do
visit new_rs_oauth_path(useraddress: user.address,
redirect_uri: "http://example.com",
client_id: "http://example.com",
scope: "documents,[photos], contacts:r")
expect(find("#user_cn").value).to eq(user.cn)
end
end
end

View File

@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe RemoteStorageExpireAuthorizationJob, type: :job do
before do
@user = create :user, cn: "ronald", ou: "kosmos.org"
@rs_authorization = create :remote_storage_authorization, user: @user, expire_at: 1.day.ago
end
after do
clear_enqueued_jobs
clear_performed_jobs
end
subject(:job) {
described_class.perform_later(@rs_authorization.id)
}
let(:redis) {
@redis ||= Redis.new(url: Setting.rs_redis_url)
}
it "removes the RS authorization from redis" do
redis_key = "rs:authorizations:#{@user.address}:#{@rs_authorization.token}"
expect(redis.keys(redis_key)).to_not be_empty
perform_enqueued_jobs { job }
expect(redis.keys(redis_key)).to be_empty
end
it "deletes the RS authorization object" do
expect {
perform_enqueued_jobs { job }
}.to change(RemoteStorageAuthorization, :count).by(-1)
end
end

View File

@ -0,0 +1,212 @@
require 'rails_helper'
RSpec.describe RemoteStorageAuthorization, type: :model do
include ActiveJob::TestHelper
let(:user) { create :user }
describe "#create" do
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("rs:authorizations:*") }
let(:auth) do
user.remote_storage_authorizations.create!(
permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
client_id: "example.com",
redirect_uri: "https://example.com"
)
end
it "generates a token" do
expect(auth.token).to match(/[a-zA-Z0-9]+/)
end
it "stores a token in redis" do
user_auth_keys = redis_rs.keys("rs:authorizations:#{user.address}:*")
expect(user_auth_keys.length).to eq(1)
authorizations = redis_rs.smembers(user_auth_keys.first)
expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort)
end
context "with expiry set" do
it "enqueues an expiration job" do
auth_with_expiry = user.remote_storage_authorizations.create!(
permissions: %w(documents:rw), client_id: "example.com",
redirect_uri: "https://example.com",
expire_at: 1.month.from_now
)
job = enqueued_jobs.select{|j| j['job_class'] == "RemoteStorageExpireAuthorizationJob"}.first
expect(job['arguments'][0]).to eq(auth_with_expiry.id)
end
end
end
describe "#destroy" do
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("rs:authorizations:*") }
it "removes the token from redis" do
auth = user.remote_storage_authorizations.create!(
permissions: %w(shares:rw documents pictures:r),
client_id: "sharesome.5apps.com",
redirect_uri: "https://sharesome.5apps.com"
)
auth.destroy!
expect(redis_rs.keys("rs:authorizations:#{user.address}:*")).to be_empty
end
context "with expiry set" do
it "removes the expiration job" do
auth_with_expiry = user.remote_storage_authorizations.create!(
permissions: %w(documents:rw), client_id: "example.com",
redirect_uri: "https://example.com",
expire_at: 1.month.from_now
)
# Cannot test for removal from the actual Sidekiq::Queue, because it is
# not used in specs, but the method directly removes jobs from there
expect(auth_with_expiry).to receive(:remove_token_expiry_job)
auth_with_expiry.destroy!
end
end
end
# describe "#find_or_create_web_app" do
# context "with origin that looks hosted" do
# before do
# auth = user.remote_storage_authorizations.create!(
# permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
# client_id: "example.com",
# redirect_uri: "https://example.com",
# expire_at: 1.month.from_now
# )
# end
#
# it "generates a web_app" do
# expect(auth.web_app).to be_a(AppCatalog::WebApp)
# end
#
# it "uses the Web App's name as app name" do
# expect(auth.app_name).to eq("Example Domain")
# end
# end
#
# context "when creating two authorizations for the same app" do
# before do
# user_2 = create :user
# ResqueSpec.reset!
# auth_1 = user.remote_storage_authorizations.create!(
# permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
# client_id: "example.com",
# redirect_uri: "https://example.com",
# expire_at: 1.month.from_now
# )
# auth_2 = user_2.remote_storage_authorizations.create!(
# permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
# client_id: "example.com",
# redirect_uri: "https://example.com",
# expire_at: 1.month.from_now
# )
# end
#
# after do
# auth_1.destroy
# auth_2.destroy
# user_2.destroy
# end
#
# it "uses the same web app instance for both authorizations" do
# expect(auth_1.web_app).to be_a(AppCatalog::WebApp)
# expect(auth_1.web_app).to eq(auth_2.web_app)
# end
# end
#
# describe "non-production app origins" do
# context "when host is not an FQDN" do
# before do
# auth = user.remote_storage_authorizations.create!(
# permissions: %w(recipes),
# client_id: "localhost:4200",
# redirect_uri: "http://localhost:4200"
# )
# end
#
# it "does not create a web app" do
# expect(auth.web_app).to be_nil
# expect(auth.app_name).to eq("localhost:4200")
# end
# end
#
# context "when host is an IP address" do
# before do
# auth = user.remote_storage_authorizations.create!(
# permissions: %w(recipes),
# client_id: "192.168.0.23:3000",
# redirect_uri: "http://192.168.0.23:3000"
# )
# end
#
# it "does not create a web app" do
# expect(auth.web_app).to be_nil
# expect(auth.app_name).to eq("192.168.0.23:3000")
# end
# end
#
# context "when host is an extension URL" do # before do
# auth = user.remote_storage_authorizations.create!(
# permissions: %w(bookmarks),
# client_id: "123.addons.allizom.org",
# redirect_uri: "123.addons.allizom.org/foo"
# )
# end
#
# it "does not create a web app" do
# expect(auth.web_app).to be_nil
# expect(auth.app_name).to eq("123.addons.allizom.org")
# end
# end
# end
# end
# describe "auth notifications" do
# context "with auth notifications enabled" do
# before do
# ResqueSpec.reset!
# user.push(mailing_lists: "rs-auth-notifications-#{Rails.env}")
# auth = user.remote_storage_authorizations.create!(
# :permissions => %w(documents photos contacts:rw videos:r tasks/work:r),
# :client_id => "example.com",
# :redirect_uri => "https://example.com"
# )
# end
#
# it "notifies the user via email" do
# expect(enqueued_jobs.size).to eq(1)
# job = enqueued_jobs.first
# expect(job).to eq(
# job: ActionMailer::DeliveryJob,
# args: ['StorageAuthorizationMailer', 'authorized_rs_app', 'deliver_now',
# auth.id.to_s],
# queue: 'mailers'
# )
# end
# end
#
# context "with auth notifications disabled" do
# before do
# ResqueSpec.reset!
# user.pull(mailing_lists: "rs-auth-notifications-#{Rails.env}")
# auth = user.remote_storage_authorizations.create!(
# :permissions => %w(documents photos contacts:rw videos:r tasks/work:r),
# :client_id => "example.com",
# :redirect_uri => "https://example.com"
# )
# end
#
# it "does not notify the user via email about new RS app" do
# expect(enqueued_jobs.size).to eq(0)
# end
# end
# end
end

View File

@ -10,6 +10,7 @@ require 'capybara'
require 'devise'
require 'support/controller_macros'
require 'support/database_cleaner'
require 'support/helpers/redis_helper'
require "view_component/test_helpers"
require "capybara/rspec"

View File

@ -0,0 +1,8 @@
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